webupdate

This commit is contained in:
Local Server
2026-01-18 02:22:05 -06:00
parent 6fc159051a
commit 2a2a3d99e5
135 changed files with 54897 additions and 9825 deletions

View File

@@ -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,
};

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

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

View File

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