webupdate
This commit is contained in:
@@ -10,6 +10,7 @@ const logger = require("../config/logger");
|
||||
*/
|
||||
const invalidateProductCache = () => {
|
||||
cache.deletePattern("products");
|
||||
cache.deletePattern("product:"); // Clear individual product caches
|
||||
cache.deletePattern("featured");
|
||||
logger.debug("Product cache invalidated");
|
||||
};
|
||||
@@ -38,6 +39,17 @@ const invalidateHomepageCache = () => {
|
||||
logger.debug("Homepage cache invalidated");
|
||||
};
|
||||
|
||||
/**
|
||||
* Invalidate pages cache
|
||||
*/
|
||||
const invalidatePagesCache = () => {
|
||||
cache.deletePattern("pages");
|
||||
cache.deletePattern("page:");
|
||||
cache.deletePattern("/pages");
|
||||
cache.deletePattern("GET:/api/pages");
|
||||
logger.debug("Pages cache invalidated");
|
||||
};
|
||||
|
||||
/**
|
||||
* Invalidate all caches
|
||||
*/
|
||||
@@ -51,5 +63,6 @@ module.exports = {
|
||||
invalidateBlogCache,
|
||||
invalidatePortfolioCache,
|
||||
invalidateHomepageCache,
|
||||
invalidatePagesCache,
|
||||
invalidateAllCache,
|
||||
};
|
||||
|
||||
227
backend/utils/crudFactory.js
Normal file
227
backend/utils/crudFactory.js
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* CRUD Route Factory
|
||||
* Generates standardized CRUD routes with consistent patterns
|
||||
*/
|
||||
|
||||
const { query } = require("../config/database");
|
||||
const { asyncHandler } = require("../middleware/errorHandler");
|
||||
const { requireAuth } = require("../middleware/auth");
|
||||
const { sendSuccess, sendNotFound } = require("../utils/responseHelpers");
|
||||
const { getById, deleteById, countRecords } = require("./queryHelpers");
|
||||
const { validateRequiredFields, generateSlug } = require("./validation");
|
||||
const { HTTP_STATUS } = require("../config/constants");
|
||||
|
||||
/**
|
||||
* Create standardized CRUD routes for a resource
|
||||
* @param {Object} config - Configuration object
|
||||
* @param {string} config.table - Database table name
|
||||
* @param {string} config.resourceName - Resource name (plural, e.g., 'products')
|
||||
* @param {string} config.singularName - Singular resource name (e.g., 'product')
|
||||
* @param {string[]} config.listFields - Fields to select in list endpoint
|
||||
* @param {string[]} config.requiredFields - Required fields for creation
|
||||
* @param {Function} config.beforeCreate - Hook before creation
|
||||
* @param {Function} config.afterCreate - Hook after creation
|
||||
* @param {Function} config.beforeUpdate - Hook before update
|
||||
* @param {Function} config.afterUpdate - Hook after update
|
||||
* @param {Function} config.cacheInvalidate - Function to invalidate cache
|
||||
* @returns {Object} Object with route handlers
|
||||
*/
|
||||
const createCRUDHandlers = (config) => {
|
||||
const {
|
||||
table,
|
||||
resourceName,
|
||||
singularName,
|
||||
listFields = "*",
|
||||
requiredFields = [],
|
||||
beforeCreate,
|
||||
afterCreate,
|
||||
beforeUpdate,
|
||||
afterUpdate,
|
||||
cacheInvalidate,
|
||||
} = config;
|
||||
|
||||
return {
|
||||
/**
|
||||
* List all resources
|
||||
* GET /:resource
|
||||
*/
|
||||
list: asyncHandler(async (req, res) => {
|
||||
const result = await query(
|
||||
`SELECT ${listFields} FROM ${table} ORDER BY createdat DESC`
|
||||
);
|
||||
sendSuccess(res, { [resourceName]: result.rows });
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get single resource by ID
|
||||
* GET /:resource/:id
|
||||
*/
|
||||
getById: asyncHandler(async (req, res) => {
|
||||
const item = await getById(table, req.params.id);
|
||||
if (!item) {
|
||||
return sendNotFound(res, singularName);
|
||||
}
|
||||
sendSuccess(res, { [singularName]: item });
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create new resource
|
||||
* POST /:resource
|
||||
*/
|
||||
create: asyncHandler(async (req, res) => {
|
||||
// Validate required fields
|
||||
if (requiredFields.length > 0) {
|
||||
validateRequiredFields(req.body, requiredFields);
|
||||
}
|
||||
|
||||
// Run beforeCreate hook if provided
|
||||
let data = { ...req.body };
|
||||
if (beforeCreate) {
|
||||
data = await beforeCreate(data, req);
|
||||
}
|
||||
|
||||
// Build insert query dynamically
|
||||
const fields = Object.keys(data);
|
||||
const placeholders = fields.map((_, i) => `$${i + 1}`).join(", ");
|
||||
const values = fields.map((key) => data[key]);
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO ${table} (${fields.join(", ")}, createdat)
|
||||
VALUES (${placeholders}, NOW())
|
||||
RETURNING *`,
|
||||
values
|
||||
);
|
||||
|
||||
let created = result.rows[0];
|
||||
|
||||
// Run afterCreate hook if provided
|
||||
if (afterCreate) {
|
||||
created = await afterCreate(created, req);
|
||||
}
|
||||
|
||||
// Invalidate cache if function provided
|
||||
if (cacheInvalidate) {
|
||||
cacheInvalidate();
|
||||
}
|
||||
|
||||
sendSuccess(
|
||||
res,
|
||||
{
|
||||
[singularName]: created,
|
||||
message: `${singularName} created successfully`,
|
||||
},
|
||||
HTTP_STATUS.CREATED
|
||||
);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update resource by ID
|
||||
* PUT /:resource/:id
|
||||
*/
|
||||
update: asyncHandler(async (req, res) => {
|
||||
// Check if resource exists
|
||||
const existing = await getById(table, req.params.id);
|
||||
if (!existing) {
|
||||
return sendNotFound(res, singularName);
|
||||
}
|
||||
|
||||
// Run beforeUpdate hook if provided
|
||||
let data = { ...req.body };
|
||||
if (beforeUpdate) {
|
||||
data = await beforeUpdate(data, req, existing);
|
||||
}
|
||||
|
||||
// Build update query dynamically
|
||||
const updates = [];
|
||||
const values = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
updates.push(`${key} = $${paramIndex}`);
|
||||
values.push(value);
|
||||
paramIndex++;
|
||||
}
|
||||
});
|
||||
|
||||
if (updates.length === 0) {
|
||||
return sendSuccess(res, {
|
||||
[singularName]: existing,
|
||||
message: "No changes to update",
|
||||
});
|
||||
}
|
||||
|
||||
updates.push(`updatedat = NOW()`);
|
||||
values.push(req.params.id);
|
||||
|
||||
const result = await query(
|
||||
`UPDATE ${table} SET ${updates.join(
|
||||
", "
|
||||
)} WHERE id = $${paramIndex} RETURNING *`,
|
||||
values
|
||||
);
|
||||
|
||||
let updated = result.rows[0];
|
||||
|
||||
// Run afterUpdate hook if provided
|
||||
if (afterUpdate) {
|
||||
updated = await afterUpdate(updated, req, existing);
|
||||
}
|
||||
|
||||
// Invalidate cache if function provided
|
||||
if (cacheInvalidate) {
|
||||
cacheInvalidate();
|
||||
}
|
||||
|
||||
sendSuccess(res, {
|
||||
[singularName]: updated,
|
||||
message: `${singularName} updated successfully`,
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete resource by ID
|
||||
* DELETE /:resource/:id
|
||||
*/
|
||||
delete: asyncHandler(async (req, res) => {
|
||||
const deleted = await deleteById(table, req.params.id);
|
||||
if (!deleted) {
|
||||
return sendNotFound(res, singularName);
|
||||
}
|
||||
|
||||
// Invalidate cache if function provided
|
||||
if (cacheInvalidate) {
|
||||
cacheInvalidate();
|
||||
}
|
||||
|
||||
sendSuccess(res, {
|
||||
message: `${singularName} deleted successfully`,
|
||||
});
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Attach CRUD handlers to a router
|
||||
* @param {Router} router - Express router
|
||||
* @param {string} path - Base path for routes
|
||||
* @param {Object} handlers - CRUD handlers object
|
||||
* @param {Function} authMiddleware - Authentication middleware (default: requireAuth)
|
||||
*/
|
||||
const attachCRUDRoutes = (
|
||||
router,
|
||||
path,
|
||||
handlers,
|
||||
authMiddleware = requireAuth
|
||||
) => {
|
||||
router.get(`/${path}`, authMiddleware, handlers.list);
|
||||
router.get(`/${path}/:id`, authMiddleware, handlers.getById);
|
||||
router.post(`/${path}`, authMiddleware, handlers.create);
|
||||
router.put(`/${path}/:id`, authMiddleware, handlers.update);
|
||||
router.delete(`/${path}/:id`, authMiddleware, handlers.delete);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createCRUDHandlers,
|
||||
attachCRUDRoutes,
|
||||
};
|
||||
195
backend/utils/queryBuilders.js
Normal file
195
backend/utils/queryBuilders.js
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Optimized Query Builders
|
||||
* Reusable SQL query builders with proper field selection and pagination
|
||||
*/
|
||||
|
||||
const PRODUCT_BASE_FIELDS = [
|
||||
"p.id",
|
||||
"p.name",
|
||||
"p.slug",
|
||||
"p.shortdescription",
|
||||
"p.description",
|
||||
"p.price",
|
||||
"p.category",
|
||||
"p.stockquantity",
|
||||
"p.sku",
|
||||
"p.weight",
|
||||
"p.dimensions",
|
||||
"p.material",
|
||||
"p.isfeatured",
|
||||
"p.isbestseller",
|
||||
"p.createdat",
|
||||
];
|
||||
|
||||
const PRODUCT_IMAGE_AGG = `
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', pi.id,
|
||||
'image_url', pi.image_url,
|
||||
'color_variant', pi.color_variant,
|
||||
'color_code', pi.color_code,
|
||||
'alt_text', pi.alt_text,
|
||||
'is_primary', pi.is_primary,
|
||||
'display_order', pi.display_order,
|
||||
'variant_price', pi.variant_price,
|
||||
'variant_stock', pi.variant_stock
|
||||
) ORDER BY pi.display_order, pi.created_at
|
||||
) FILTER (WHERE pi.id IS NOT NULL),
|
||||
'[]'::json
|
||||
) as images
|
||||
`;
|
||||
|
||||
/**
|
||||
* Build product query with images
|
||||
* @param {Object} options - Query options
|
||||
* @param {string[]} options.fields - Additional fields to select
|
||||
* @param {string} options.where - WHERE clause
|
||||
* @param {string} options.orderBy - ORDER BY clause
|
||||
* @param {number} options.limit - LIMIT value
|
||||
* @returns {string} SQL query
|
||||
*/
|
||||
const buildProductQuery = ({
|
||||
fields = [],
|
||||
where = "p.isactive = true",
|
||||
orderBy = "p.createdat DESC",
|
||||
limit = null,
|
||||
} = {}) => {
|
||||
const selectFields = [...PRODUCT_BASE_FIELDS, ...fields].join(", ");
|
||||
|
||||
return `
|
||||
SELECT ${selectFields}, ${PRODUCT_IMAGE_AGG}
|
||||
FROM products p
|
||||
LEFT JOIN product_images pi ON pi.product_id = p.id
|
||||
WHERE ${where}
|
||||
GROUP BY p.id
|
||||
ORDER BY ${orderBy}
|
||||
${limit ? `LIMIT ${limit}` : ""}
|
||||
`.trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* Build optimized query for single product by ID or slug
|
||||
* @param {string} identifier - Product ID or slug
|
||||
* @returns {Object} Query object with text and values
|
||||
*/
|
||||
const buildSingleProductQuery = (identifier) => {
|
||||
const isUUID = identifier.length === 36 && identifier.indexOf("-") === 8;
|
||||
const whereClause = isUUID ? "p.id = $1" : "(p.id = $1 OR p.slug = $1)";
|
||||
|
||||
return {
|
||||
text:
|
||||
buildProductQuery({ where: `${whereClause} AND p.isactive = true` }) +
|
||||
" LIMIT 1",
|
||||
values: [identifier],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Build blog post query with field selection
|
||||
* @param {Object} options - Query options
|
||||
* @param {boolean} options.includeContent - Include full content
|
||||
* @param {boolean} options.publishedOnly - Filter by published status
|
||||
* @returns {string} SQL query
|
||||
*/
|
||||
const buildBlogQuery = ({
|
||||
includeContent = true,
|
||||
publishedOnly = true,
|
||||
} = {}) => {
|
||||
const fields = includeContent
|
||||
? "id, title, slug, excerpt, content, featuredimage, imageurl, images, ispublished, createdat"
|
||||
: "id, title, slug, excerpt, featuredimage, imageurl, ispublished, createdat";
|
||||
|
||||
const whereClause = publishedOnly ? "WHERE ispublished = true" : "";
|
||||
|
||||
return `SELECT ${fields} FROM blogposts ${whereClause} ORDER BY createdat DESC`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build pages query with field selection
|
||||
* @param {Object} options - Query options
|
||||
* @param {boolean} options.includeContent - Include page content
|
||||
* @param {boolean} options.activeOnly - Filter by active status
|
||||
* @returns {string} SQL query
|
||||
*/
|
||||
const buildPagesQuery = ({ includeContent = true, activeOnly = true } = {}) => {
|
||||
const fields = includeContent
|
||||
? "id, title, slug, pagecontent as content, metatitle, metadescription, isactive, createdat"
|
||||
: "id, title, slug, metatitle, metadescription, isactive, createdat";
|
||||
|
||||
const whereClause = activeOnly ? "WHERE isactive = true" : "";
|
||||
|
||||
return `SELECT ${fields} FROM pages ${whereClause} ORDER BY createdat DESC`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build portfolio projects query
|
||||
* @param {boolean} activeOnly - Filter by active status
|
||||
* @returns {string} SQL query
|
||||
*/
|
||||
const buildPortfolioQuery = (activeOnly = true) => {
|
||||
const whereClause = activeOnly ? "WHERE isactive = true" : "";
|
||||
|
||||
return `
|
||||
SELECT
|
||||
id, title, description, imageurl, images,
|
||||
category, categoryid, isactive, createdat, displayorder
|
||||
FROM portfolioprojects
|
||||
${whereClause}
|
||||
ORDER BY displayorder ASC, createdat DESC
|
||||
`.trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* Build categories query
|
||||
* @returns {string} SQL query
|
||||
*/
|
||||
const buildCategoriesQuery = () => {
|
||||
return `
|
||||
SELECT DISTINCT category
|
||||
FROM products
|
||||
WHERE isactive = true
|
||||
AND category IS NOT NULL
|
||||
AND category != ''
|
||||
ORDER BY category ASC
|
||||
`.trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* Pagination helper
|
||||
* @param {number} page - Page number (1-indexed)
|
||||
* @param {number} limit - Items per page
|
||||
* @returns {Object} Offset and limit
|
||||
*/
|
||||
const getPagination = (page = 1, limit = 20) => {
|
||||
const validPage = Math.max(1, parseInt(page) || 1);
|
||||
const validLimit = Math.min(100, Math.max(1, parseInt(limit) || 20));
|
||||
const offset = (validPage - 1) * validLimit;
|
||||
|
||||
return { offset, limit: validLimit, page: validPage };
|
||||
};
|
||||
|
||||
/**
|
||||
* Add pagination to query
|
||||
* @param {string} query - Base SQL query
|
||||
* @param {number} page - Page number
|
||||
* @param {number} limit - Items per page
|
||||
* @returns {string} SQL query with pagination
|
||||
*/
|
||||
const addPagination = (query, page, limit) => {
|
||||
const { offset, limit: validLimit } = getPagination(page, limit);
|
||||
return `${query} LIMIT ${validLimit} OFFSET ${offset}`;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
buildProductQuery,
|
||||
buildSingleProductQuery,
|
||||
buildBlogQuery,
|
||||
buildPagesQuery,
|
||||
buildPortfolioQuery,
|
||||
buildCategoriesQuery,
|
||||
getPagination,
|
||||
addPagination,
|
||||
PRODUCT_BASE_FIELDS,
|
||||
PRODUCT_IMAGE_AGG,
|
||||
};
|
||||
@@ -41,6 +41,39 @@ const getById = async (table, id) => {
|
||||
return result.rows[0] || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get product with images by ID
|
||||
* @param {string} productId - Product ID
|
||||
* @returns {Promise<Object|null>} Product with images or null
|
||||
*/
|
||||
const getProductWithImages = async (productId) => {
|
||||
const result = await query(
|
||||
`SELECT p.*,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', pi.id,
|
||||
'image_url', pi.image_url,
|
||||
'color_variant', pi.color_variant,
|
||||
'color_code', pi.color_code,
|
||||
'alt_text', pi.alt_text,
|
||||
'display_order', pi.display_order,
|
||||
'is_primary', pi.is_primary,
|
||||
'variant_price', pi.variant_price,
|
||||
'variant_stock', pi.variant_stock
|
||||
) ORDER BY pi.display_order
|
||||
) FILTER (WHERE pi.id IS NOT NULL),
|
||||
'[]'::json
|
||||
) as images
|
||||
FROM products p
|
||||
LEFT JOIN product_images pi ON pi.product_id = p.id
|
||||
WHERE p.id = $1
|
||||
GROUP BY p.id`,
|
||||
[productId]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
};
|
||||
|
||||
const getAllActive = async (table, orderBy = "createdat DESC") => {
|
||||
validateTableName(table);
|
||||
const result = await query(
|
||||
@@ -65,11 +98,112 @@ const countRecords = async (table, condition = "") => {
|
||||
return parseInt(result.rows[0].count);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if record exists
|
||||
* @param {string} table - Table name
|
||||
* @param {string} field - Field name
|
||||
* @param {any} value - Field value
|
||||
* @returns {Promise<boolean>} True if exists
|
||||
*/
|
||||
const exists = async (table, field, value) => {
|
||||
validateTableName(table);
|
||||
const result = await query(
|
||||
`SELECT EXISTS(SELECT 1 FROM ${table} WHERE ${field} = $1) as exists`,
|
||||
[value]
|
||||
);
|
||||
return result.rows[0].exists;
|
||||
};
|
||||
|
||||
/**
|
||||
* Batch insert records
|
||||
* @param {string} table - Table name
|
||||
* @param {Array<Object>} records - Array of records
|
||||
* @param {Array<string>} fields - Field names (must match for all records)
|
||||
* @returns {Promise<Array>} Inserted records
|
||||
*/
|
||||
const batchInsert = async (table, records, fields) => {
|
||||
if (!records || records.length === 0) return [];
|
||||
validateTableName(table);
|
||||
|
||||
const values = [];
|
||||
const placeholders = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
records.forEach((record) => {
|
||||
const rowPlaceholders = fields.map(() => `$${paramIndex++}`);
|
||||
placeholders.push(`(${rowPlaceholders.join(", ")})`);
|
||||
fields.forEach((field) => values.push(record[field]));
|
||||
});
|
||||
|
||||
const sql = `
|
||||
INSERT INTO ${table} (${fields.join(", ")})
|
||||
VALUES ${placeholders.join(", ")}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await query(sql, values);
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update multiple records by IDs
|
||||
* @param {string} table - Table name
|
||||
* @param {Array<string>} ids - Array of record IDs
|
||||
* @param {Object} updates - Fields to update
|
||||
* @returns {Promise<Array>} Updated records
|
||||
*/
|
||||
const batchUpdate = async (table, ids, updates) => {
|
||||
if (!ids || ids.length === 0) return [];
|
||||
validateTableName(table);
|
||||
|
||||
const updateFields = Object.keys(updates);
|
||||
const setClause = updateFields
|
||||
.map((field, i) => `${field} = $${i + 1}`)
|
||||
.join(", ");
|
||||
|
||||
const values = [...Object.values(updates), ids];
|
||||
const sql = `
|
||||
UPDATE ${table}
|
||||
SET ${setClause}, updatedat = NOW()
|
||||
WHERE id = ANY($${updateFields.length + 1})
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await query(sql, values);
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute query with transaction
|
||||
* @param {Function} callback - Callback function that receives client
|
||||
* @returns {Promise<any>} Result from callback
|
||||
*/
|
||||
const withTransaction = async (callback) => {
|
||||
const { pool } = require("../config/database");
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const result = await callback(client);
|
||||
await client.query("COMMIT");
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
buildSelectQuery,
|
||||
getById,
|
||||
getProductWithImages,
|
||||
getAllActive,
|
||||
deleteById,
|
||||
countRecords,
|
||||
exists,
|
||||
batchInsert,
|
||||
batchUpdate,
|
||||
withTransaction,
|
||||
validateTableName,
|
||||
};
|
||||
|
||||
245
backend/utils/validation.js
Normal file
245
backend/utils/validation.js
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Input Validation Utilities
|
||||
* Reusable validation functions with consistent error messages
|
||||
*/
|
||||
|
||||
const { AppError } = require("../middleware/errorHandler");
|
||||
const { HTTP_STATUS } = require("../config/constants");
|
||||
|
||||
/**
|
||||
* Validate required fields
|
||||
* @param {Object} data - Data object to validate
|
||||
* @param {string[]} requiredFields - Array of required field names
|
||||
* @throws {AppError} If validation fails
|
||||
*/
|
||||
const validateRequiredFields = (data, requiredFields) => {
|
||||
const missingFields = requiredFields.filter(
|
||||
(field) =>
|
||||
!data[field] ||
|
||||
(typeof data[field] === "string" && data[field].trim() === ""),
|
||||
);
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
throw new AppError(
|
||||
`Missing required fields: ${missingFields.join(", ")}`,
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate email format
|
||||
* @param {string} email - Email to validate
|
||||
* @returns {boolean} True if valid
|
||||
*/
|
||||
const isValidEmail = (email) => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate email field
|
||||
* @param {string} email - Email to validate
|
||||
* @throws {AppError} If validation fails
|
||||
*/
|
||||
const validateEmail = (email) => {
|
||||
if (!email || !isValidEmail(email)) {
|
||||
throw new AppError("Invalid email format", HTTP_STATUS.BAD_REQUEST);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate UUID format
|
||||
* @param {string} id - UUID to validate
|
||||
* @returns {boolean} True if valid UUID
|
||||
*/
|
||||
const isValidUUID = (id) => {
|
||||
const uuidRegex =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
return uuidRegex.test(id);
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate number range
|
||||
* @param {number} value - Value to validate
|
||||
* @param {number} min - Minimum value
|
||||
* @param {number} max - Maximum value
|
||||
* @param {string} fieldName - Field name for error message
|
||||
* @throws {AppError} If validation fails
|
||||
*/
|
||||
const validateNumberRange = (value, min, max, fieldName = "Value") => {
|
||||
const num = parseFloat(value);
|
||||
if (isNaN(num) || num < min || num > max) {
|
||||
throw new AppError(
|
||||
`${fieldName} must be between ${min} and ${max}`,
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
return num;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate string length
|
||||
* @param {string} value - String to validate
|
||||
* @param {number} min - Minimum length
|
||||
* @param {number} max - Maximum length
|
||||
* @param {string} fieldName - Field name for error message
|
||||
* @throws {AppError} If validation fails
|
||||
*/
|
||||
const validateStringLength = (value, min, max, fieldName = "Field") => {
|
||||
if (!value || value.length < min || value.length > max) {
|
||||
throw new AppError(
|
||||
`${fieldName} must be between ${min} and ${max} characters`,
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitize string input (remove HTML tags, trim)
|
||||
* @param {string} input - String to sanitize
|
||||
* @returns {string} Sanitized string
|
||||
*/
|
||||
const sanitizeString = (input) => {
|
||||
if (typeof input !== "string") return "";
|
||||
return input
|
||||
.replace(/<[^>]*>/g, "") // Remove HTML tags
|
||||
.trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate and sanitize slug
|
||||
* @param {string} slug - Slug to validate
|
||||
* @returns {string} Valid slug
|
||||
* @throws {AppError} If validation fails
|
||||
*/
|
||||
const validateSlug = (slug) => {
|
||||
const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
const sanitized = slug.toLowerCase().trim();
|
||||
|
||||
if (!slugRegex.test(sanitized)) {
|
||||
throw new AppError(
|
||||
"Slug can only contain lowercase letters, numbers, and hyphens",
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate slug from string
|
||||
* @param {string} text - Text to convert to slug
|
||||
* @returns {string} Generated slug
|
||||
*/
|
||||
const generateSlug = (text) => {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate pagination parameters
|
||||
* @param {Object} query - Query parameters
|
||||
* @returns {Object} Validated pagination params
|
||||
*/
|
||||
const validatePagination = (query) => {
|
||||
const page = Math.max(1, parseInt(query.page) || 1);
|
||||
const limit = Math.min(100, Math.max(1, parseInt(query.limit) || 20));
|
||||
|
||||
return { page, limit };
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate image file
|
||||
* @param {Object} file - Multer file object
|
||||
* @throws {AppError} If validation fails
|
||||
*/
|
||||
const validateImageFile = (file) => {
|
||||
const allowedMimeTypes = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/bmp",
|
||||
"image/tiff",
|
||||
"image/svg+xml",
|
||||
"image/x-icon",
|
||||
"image/vnd.microsoft.icon",
|
||||
"image/ico",
|
||||
"image/avif",
|
||||
"image/heic",
|
||||
"image/heif",
|
||||
];
|
||||
const maxSize = 10 * 1024 * 1024; // 10MB for larger image formats
|
||||
|
||||
if (!file) {
|
||||
throw new AppError("No file provided", HTTP_STATUS.BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (!allowedMimeTypes.includes(file.mimetype)) {
|
||||
throw new AppError(
|
||||
"Invalid file type. Allowed: JPEG, PNG, GIF, WebP, BMP, TIFF, SVG, ICO, AVIF, HEIC",
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
if (file.size > maxSize) {
|
||||
throw new AppError(
|
||||
"File too large. Maximum size is 5MB",
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate price value
|
||||
* @param {number} price - Price to validate
|
||||
* @param {string} fieldName - Field name for error message
|
||||
* @returns {number} Validated price
|
||||
* @throws {AppError} If validation fails
|
||||
*/
|
||||
const validatePrice = (price, fieldName = "Price") => {
|
||||
return validateNumberRange(price, 0, 999999, fieldName);
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate stock quantity
|
||||
* @param {number} stock - Stock to validate
|
||||
* @returns {number} Validated stock
|
||||
* @throws {AppError} If validation fails
|
||||
*/
|
||||
const validateStock = (stock) => {
|
||||
return validateNumberRange(stock, 0, 999999, "Stock quantity");
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate color code (hex format)
|
||||
* @param {string} colorCode - Color code to validate
|
||||
* @returns {boolean} True if valid
|
||||
*/
|
||||
const isValidColorCode = (colorCode) => {
|
||||
const hexRegex = /^#[0-9A-F]{6}$/i;
|
||||
return hexRegex.test(colorCode);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
validateRequiredFields,
|
||||
validateEmail,
|
||||
isValidEmail,
|
||||
isValidUUID,
|
||||
validateNumberRange,
|
||||
validateStringLength,
|
||||
sanitizeString,
|
||||
validateSlug,
|
||||
generateSlug,
|
||||
validatePagination,
|
||||
validateImageFile,
|
||||
validatePrice,
|
||||
validateStock,
|
||||
isValidColorCode,
|
||||
};
|
||||
Reference in New Issue
Block a user