Couponss, Blogs, reviews

This commit is contained in:
2ManyProjects 2025-04-29 18:43:03 -05:00
parent b88fb93637
commit b1f5985224
13 changed files with 2612 additions and 0 deletions

View file

@ -281,6 +281,11 @@
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
}, },
"slugify": {
"version": "1.6.6",
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.6.tgz",
"integrity": "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw=="
},
"streamsearch": { "streamsearch": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",

View file

@ -18,6 +18,7 @@
"nodemailer": "^6.9.1", "nodemailer": "^6.9.1",
"pg": "^8.10.0", "pg": "^8.10.0",
"pg-hstore": "^2.3.4", "pg-hstore": "^2.3.4",
"slugify": "^1.6.6",
"stripe": "^12.0.0", "stripe": "^12.0.0",
"uuid": "^9.0.0" "uuid": "^9.0.0"
}, },

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 KiB

View file

@ -24,8 +24,14 @@ const productAdminRoutes = require('./routes/productAdmin');
const categoryAdminRoutes = require('./routes/categoryAdmin'); const categoryAdminRoutes = require('./routes/categoryAdmin');
const usersAdminRoutes = require('./routes/userAdmin'); const usersAdminRoutes = require('./routes/userAdmin');
const ordersAdminRoutes = require('./routes/orderAdmin'); const ordersAdminRoutes = require('./routes/orderAdmin');
const couponsAdminRoutes = require('./routes/couponAdmin');
const userOrdersRoutes = require('./routes/userOrders'); const userOrdersRoutes = require('./routes/userOrders');
const shippingRoutes = require('./routes/shipping'); const shippingRoutes = require('./routes/shipping');
const blogRoutes = require('./routes/blog');
const blogAdminRoutes = require('./routes/blogAdmin');
const blogCommentsAdminRoutes = require('./routes/blogCommentsAdmin');
const productReviewsRoutes = require('./routes/productReviews');
const productReviewsAdminRoutes = require('./routes/productReviewsAdmin');
// Create Express app // Create Express app
const app = express(); const app = express();
@ -42,6 +48,10 @@ if (!fs.existsSync(uploadsDir)) {
if (!fs.existsSync(productImagesDir)) { if (!fs.existsSync(productImagesDir)) {
fs.mkdirSync(productImagesDir, { recursive: true }); fs.mkdirSync(productImagesDir, { recursive: true });
} }
const blogImagesDir = path.join(uploadsDir, 'blog');
if (!fs.existsSync(blogImagesDir)) {
fs.mkdirSync(blogImagesDir, { recursive: true });
}
// Configure storage // Configure storage
const storage = multer.diskStorage({ const storage = multer.diskStorage({
@ -151,6 +161,8 @@ app.use('/images', express.static(path.join(__dirname, '../public/uploads')));
app.use('/uploads', express.static(path.join(__dirname, '../public/uploads'))); app.use('/uploads', express.static(path.join(__dirname, '../public/uploads')));
app.use('/api/uploads', express.static(path.join(__dirname, '../public/uploads'))); app.use('/api/uploads', express.static(path.join(__dirname, '../public/uploads')));
app.use('/api/images', express.static(path.join(__dirname, '../public/uploads'))); app.use('/api/images', express.static(path.join(__dirname, '../public/uploads')));
app.use('/uploads/blog', express.static(path.join(__dirname, '../public/uploads/blog')));
app.use('/api/uploads/blog', express.static(path.join(__dirname, '../public/uploads/blog')));
if (!fs.existsSync(path.join(__dirname, '../public/uploads'))) { if (!fs.existsSync(path.join(__dirname, '../public/uploads'))) {
fs.mkdirSync(path.join(__dirname, '../public/uploads'), { recursive: true }); fs.mkdirSync(path.join(__dirname, '../public/uploads'), { recursive: true });
@ -184,8 +196,16 @@ app.post('/api/image/upload', upload.single('image'), (req, res) => {
imagePath: `/uploads/${req.file.filename}` imagePath: `/uploads/${req.file.filename}`
}); });
}); });
app.use('/api/product-reviews', productReviewsRoutes(pool, query, authMiddleware(pool, query)));
app.use('/api/admin/product-reviews', productReviewsAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
app.use('/api/admin/users', usersAdminRoutes(pool, query, adminAuthMiddleware(pool, query))); app.use('/api/admin/users', usersAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
app.use('/api/admin/coupons', couponsAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
app.use('/api/admin/orders', ordersAdminRoutes(pool, query, adminAuthMiddleware(pool, query))); app.use('/api/admin/orders', ordersAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
app.use('/api/admin/blog', blogAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
app.use('/api/admin/blog-comments', blogCommentsAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
// Admin-only product image upload // Admin-only product image upload
app.post('/api/image/product', adminAuthMiddleware(pool, query), upload.single('image'), (req, res) => { app.post('/api/image/product', adminAuthMiddleware(pool, query), upload.single('image'), (req, res) => {
console.log('/api/image/product', req.file); console.log('/api/image/product', req.file);
@ -272,6 +292,8 @@ app.use('/api/admin/settings', settingsAdminRoutes(pool, query, adminAuthMiddlew
app.use('/api/products', productRoutes(pool, query)); app.use('/api/products', productRoutes(pool, query));
app.use('/api/auth', authRoutes(pool, query)); app.use('/api/auth', authRoutes(pool, query));
app.use('/api/user/orders', userOrdersRoutes(pool, query, authMiddleware(pool, query))); app.use('/api/user/orders', userOrdersRoutes(pool, query, authMiddleware(pool, query)));
app.use('/api/blog', blogRoutes(pool, query, authMiddleware(pool, query)));
app.use('/api/cart', cartRoutes(pool, query, authMiddleware(pool, query))); app.use('/api/cart', cartRoutes(pool, query, authMiddleware(pool, query)));
app.use('/api/admin/products', productAdminRoutes(pool, query, adminAuthMiddleware(pool, query))); app.use('/api/admin/products', productAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
app.use('/api/admin/categories', categoryAdminRoutes(pool, query, adminAuthMiddleware(pool, query))); app.use('/api/admin/categories', categoryAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));

285
backend/src/routes/blog.js Normal file
View file

@ -0,0 +1,285 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const router = express.Router();
module.exports = (pool, query, authMiddleware) => {
// Get all published blog posts (public)
router.get('/', async (req, res, next) => {
try {
const { category, tag, search, page = 1, limit = 10 } = req.query;
const offset = (page - 1) * limit;
let whereConditions = ["b.status = 'published'"];
const params = [];
let paramIndex = 1;
// Filter by category
if (category) {
params.push(category);
whereConditions.push(`bc.name = $${paramIndex}`);
paramIndex++;
}
// Filter by tag
if (tag) {
params.push(tag);
whereConditions.push(`t.name = $${paramIndex}`);
paramIndex++;
}
// Search functionality
if (search) {
params.push(`%${search}%`);
whereConditions.push(`(b.title ILIKE $${paramIndex} OR b.excerpt ILIKE $${paramIndex} OR b.content ILIKE $${paramIndex})`);
paramIndex++;
}
// Prepare WHERE clause
const whereClause = whereConditions.length > 0
? `WHERE ${whereConditions.join(' AND ')}`
: '';
// Get total count for pagination
const countQuery = `
SELECT COUNT(DISTINCT b.id)
FROM blog_posts b
LEFT JOIN blog_categories bc ON b.category_id = bc.id
LEFT JOIN blog_post_tags bpt ON b.id = bpt.post_id
LEFT JOIN tags t ON bpt.tag_id = t.id
${whereClause}
`;
const countResult = await query(countQuery, params);
const total = parseInt(countResult.rows[0].count);
// Query posts with all related data
const postsQuery = `
SELECT
b.id, b.title, b.slug, b.excerpt, b.featured_image_path,
b.published_at, b.created_at, b.updated_at,
u.id as author_id, u.first_name as author_first_name, u.last_name as author_last_name,
bc.id as category_id, bc.name as category_name,
ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS tags,
COUNT(DISTINCT c.id) as comment_count
FROM blog_posts b
LEFT JOIN users u ON b.author_id = u.id
LEFT JOIN blog_categories bc ON b.category_id = bc.id
LEFT JOIN blog_post_tags bpt ON b.id = bpt.post_id
LEFT JOIN tags t ON bpt.tag_id = t.id
LEFT JOIN blog_comments c ON b.id = c.post_id AND c.is_approved = true
${whereClause}
GROUP BY b.id, u.id, bc.id
ORDER BY b.published_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
params.push(parseInt(limit), parseInt(offset));
const result = await query(postsQuery, params);
res.json({
posts: result.rows,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
pages: Math.ceil(total / limit)
}
});
} catch (error) {
next(error);
}
});
// Get single blog post by slug (public)
router.get('/:slug', async (req, res, next) => {
try {
const { slug } = req.params;
// Get post with author, category, and tags
const postQuery = `
SELECT
b.id, b.title, b.slug, b.content, b.excerpt,
b.featured_image_path, b.status, b.published_at,
b.created_at, b.updated_at,
u.id as author_id, u.first_name as author_first_name, u.last_name as author_last_name,
bc.id as category_id, bc.name as category_name,
ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS tags
FROM blog_posts b
LEFT JOIN users u ON b.author_id = u.id
LEFT JOIN blog_categories bc ON b.category_id = bc.id
LEFT JOIN blog_post_tags bpt ON b.id = bpt.post_id
LEFT JOIN tags t ON bpt.tag_id = t.id
WHERE b.slug = $1 AND b.status = 'published'
GROUP BY b.id, u.id, bc.id
`;
const postResult = await query(postQuery, [slug]);
if (postResult.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Blog post not found'
});
}
const post = postResult.rows[0];
// Get post images
const imagesQuery = `
SELECT id, image_path, caption, display_order
FROM blog_post_images
WHERE post_id = $1
ORDER BY display_order
`;
const imagesResult = await query(imagesQuery, [post.id]);
// Get approved comments
const commentsQuery = `
SELECT
c.id, c.content, c.created_at,
c.parent_id,
u.id as user_id, u.first_name, u.last_name
FROM blog_comments c
JOIN users u ON c.user_id = u.id
WHERE c.post_id = $1 AND c.is_approved = true
ORDER BY c.created_at
`;
const commentsResult = await query(commentsQuery, [post.id]);
// Organize comments into threads
const commentThreads = [];
const commentMap = {};
// First, create a map of all comments
commentsResult.rows.forEach(comment => {
commentMap[comment.id] = {
...comment,
replies: []
};
});
// Then, organize into threads
commentsResult.rows.forEach(comment => {
if (comment.parent_id) {
// This is a reply
if (commentMap[comment.parent_id]) {
commentMap[comment.parent_id].replies.push(commentMap[comment.id]);
}
} else {
// This is a top-level comment
commentThreads.push(commentMap[comment.id]);
}
});
// Return the post with images and comments
res.json({
...post,
images: imagesResult.rows,
comments: commentThreads
});
} catch (error) {
next(error);
}
});
// Get blog categories (public)
router.get('/categories/all', async (req, res, next) => {
try {
const result = await query('SELECT * FROM blog_categories ORDER BY name');
res.json(result.rows);
} catch (error) {
next(error);
}
});
router.use(authMiddleware);
// Add comment to a blog post (authenticated users only)
router.post('/:postId/comments', async (req, res, next) => {
try {
const { postId } = req.params;
const { content, parentId, userId } = req.body;
// Check if user is authenticated
if (req.user.id !== userId) {
return res.status(401).json({
error: true,
message: 'You must be logged in to comment'
});
}
// Validate content
if (!content || content.trim() === '') {
return res.status(400).json({
error: true,
message: 'Comment content is required'
});
}
// Check if post exists and is published
const postCheck = await query(
"SELECT id FROM blog_posts WHERE id = $1 AND status = 'published'",
[postId]
);
if (postCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Blog post not found or not published'
});
}
// If this is a reply, check if parent comment exists
if (parentId) {
const parentCheck = await query(
'SELECT id FROM blog_comments WHERE id = $1 AND post_id = $2',
[parentId, postId]
);
if (parentCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Parent comment not found'
});
}
}
// Determine if comment needs moderation
const isApproved = req.user.is_admin ? true : false;
// Insert comment
const result = await query(
`INSERT INTO blog_comments
(id, post_id, user_id, parent_id, content, is_approved)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[uuidv4(), postId, userId, parentId || null, content, isApproved]
);
// Get user info for the response
const userResult = await query(
'SELECT first_name, last_name FROM users WHERE id = $1',
[userId]
);
const comment = {
...result.rows[0],
first_name: userResult.rows[0].first_name,
last_name: userResult.rows[0].last_name,
replies: []
};
res.status(201).json({
message: isApproved
? 'Comment added successfully'
: 'Comment submitted and awaiting approval',
comment
});
} catch (error) {
next(error);
}
});
return router;
};

View file

@ -0,0 +1,496 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const router = express.Router();
const slugify = require('slugify');
module.exports = (pool, query, authMiddleware) => {
// Apply authentication middleware to all routes
router.use(authMiddleware);
// Get all blog posts (admin)
router.get('/', async (req, res, next) => {
try {
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Get all posts with basic info
const result = await query(`
SELECT
b.id, b.title, b.slug, b.excerpt, b.status,
b.featured_image_path, b.published_at, b.created_at,
u.first_name as author_first_name, u.last_name as author_last_name,
bc.name as category_name
FROM blog_posts b
LEFT JOIN users u ON b.author_id = u.id
LEFT JOIN blog_categories bc ON b.category_id = bc.id
ORDER BY
CASE WHEN b.status = 'draft' THEN 1
WHEN b.status = 'published' THEN 2
ELSE 3
END,
b.created_at DESC
`);
res.json(result.rows);
} catch (error) {
next(error);
}
});
// Get single blog post for editing (admin)
router.get('/:id', async (req, res, next) => {
try {
const { id } = req.params;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Get post with all details
const postQuery = `
SELECT
b.id, b.title, b.slug, b.content, b.excerpt,
b.featured_image_path, b.status, b.published_at,
b.created_at, b.updated_at, b.author_id, b.category_id,
u.first_name as author_first_name, u.last_name as author_last_name,
bc.name as category_name,
ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS tags
FROM blog_posts b
LEFT JOIN users u ON b.author_id = u.id
LEFT JOIN blog_categories bc ON b.category_id = bc.id
LEFT JOIN blog_post_tags bpt ON b.id = bpt.post_id
LEFT JOIN tags t ON bpt.tag_id = t.id
WHERE b.id = $1
GROUP BY b.id, u.id, bc.id
`;
const postResult = await query(postQuery, [id]);
if (postResult.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Blog post not found'
});
}
const post = postResult.rows[0];
// Get post images
const imagesQuery = `
SELECT id, image_path, caption, display_order
FROM blog_post_images
WHERE post_id = $1
ORDER BY display_order
`;
const imagesResult = await query(imagesQuery, [id]);
// Return the post with images
res.json({
...post,
images: imagesResult.rows
});
} catch (error) {
next(error);
}
});
// Create a new blog post
router.post('/', async (req, res, next) => {
try {
const {
title, content, excerpt, categoryId,
tags, featuredImagePath, status, publishNow
} = req.body;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Validate required fields
if (!title || !content) {
return res.status(400).json({
error: true,
message: 'Title and content are required'
});
}
// Generate slug from title
let slug = slugify(title, {
lower: true, // convert to lower case
strict: true, // strip special characters
remove: /[*+~.()'"!:@]/g // regex to remove characters
});
// Check if slug already exists
const slugCheck = await query(
'SELECT id FROM blog_posts WHERE slug = $1',
[slug]
);
// If slug exists, append a random string
if (slugCheck.rows.length > 0) {
const randomString = Math.random().toString(36).substring(2, 8);
slug = `${slug}-${randomString}`;
}
// Begin transaction
const client = await pool.connect();
try {
await client.query('BEGIN');
// Set published_at date if publishing now
const publishedAt = (status === 'published' && publishNow) ? new Date() : null;
// Create the post
const postId = uuidv4();
const postResult = await client.query(
`INSERT INTO blog_posts
(id, title, slug, content, excerpt, author_id, category_id, status, featured_image_path, published_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
[postId, title, slug, content, excerpt || null, req.user.id, categoryId || null,
status || 'draft', featuredImagePath || null, publishedAt]
);
// Add tags if provided
if (tags && Array.isArray(tags) && tags.length > 0) {
for (const tagName of tags) {
// Get or create tag
let tagId;
const tagResult = await client.query(
'SELECT id FROM tags WHERE name = $1',
[tagName]
);
if (tagResult.rows.length > 0) {
tagId = tagResult.rows[0].id;
} else {
const newTagResult = await client.query(
'INSERT INTO tags (id, name) VALUES ($1, $2) RETURNING id',
[uuidv4(), tagName]
);
tagId = newTagResult.rows[0].id;
}
// Add tag to post
await client.query(
'INSERT INTO blog_post_tags (post_id, tag_id) VALUES ($1, $2)',
[postId, tagId]
);
}
}
await client.query('COMMIT');
res.status(201).json({
message: 'Blog post created successfully',
post: postResult.rows[0]
});
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} catch (error) {
next(error);
}
});
// Update blog post
router.put('/:id', async (req, res, next) => {
try {
const { id } = req.params;
const {
title, content, excerpt, categoryId,
tags, featuredImagePath, status, publishNow
} = req.body;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Validate required fields
if (!title || !content) {
return res.status(400).json({
error: true,
message: 'Title and content are required'
});
}
// Check if post exists
const postCheck = await query(
'SELECT id, slug, status, published_at FROM blog_posts WHERE id = $1',
[id]
);
if (postCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Blog post not found'
});
}
const existingPost = postCheck.rows[0];
let updatedSlug = existingPost.slug;
// Update slug if title changed
if (title) {
const newSlug = slugify(title, {
lower: true,
strict: true,
remove: /[*+~.()'"!:@]/g
});
// Only update slug if it's different
if (newSlug !== existingPost.slug) {
// Check if new slug already exists
const slugCheck = await query(
'SELECT id FROM blog_posts WHERE slug = $1 AND id != $2',
[newSlug, id]
);
if (slugCheck.rows.length === 0) {
updatedSlug = newSlug;
} else {
// Append random string to slug
const randomString = Math.random().toString(36).substring(2, 8);
updatedSlug = `${newSlug}-${randomString}`;
}
}
}
// Begin transaction
const client = await pool.connect();
try {
await client.query('BEGIN');
// Determine published_at date
let publishedAt = existingPost.published_at;
if (status === 'published' && publishNow && !existingPost.published_at) {
publishedAt = new Date();
}
// Update the post
const postResult = await client.query(
`UPDATE blog_posts SET
title = $1,
slug = $2,
content = $3,
excerpt = $4,
category_id = $5,
status = $6,
featured_image_path = $7,
published_at = $8,
updated_at = NOW()
WHERE id = $9
RETURNING *`,
[title, updatedSlug, content, excerpt || null, categoryId || null,
status || existingPost.status, featuredImagePath, publishedAt, id]
);
// Update tags if provided
if (tags !== undefined) {
// Remove existing tags
await client.query('DELETE FROM blog_post_tags WHERE post_id = $1', [id]);
// Add new tags
if (Array.isArray(tags) && tags.length > 0) {
for (const tagName of tags) {
// Get or create tag
let tagId;
const tagResult = await client.query(
'SELECT id FROM tags WHERE name = $1',
[tagName]
);
if (tagResult.rows.length > 0) {
tagId = tagResult.rows[0].id;
} else {
const newTagResult = await client.query(
'INSERT INTO tags (id, name) VALUES ($1, $2) RETURNING id',
[uuidv4(), tagName]
);
tagId = newTagResult.rows[0].id;
}
// Add tag to post
await client.query(
'INSERT INTO blog_post_tags (post_id, tag_id) VALUES ($1, $2)',
[id, tagId]
);
}
}
}
await client.query('COMMIT');
res.json({
message: 'Blog post updated successfully',
post: postResult.rows[0]
});
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} catch (error) {
next(error);
}
});
// Delete blog post
router.delete('/:id', async (req, res, next) => {
try {
const { id } = req.params;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if post exists
const postCheck = await query(
'SELECT id FROM blog_posts WHERE id = $1',
[id]
);
if (postCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Blog post not found'
});
}
// Delete post - cascade will handle related records
await query(
'DELETE FROM blog_posts WHERE id = $1',
[id]
);
res.json({
message: 'Blog post deleted successfully'
});
} catch (error) {
next(error);
}
});
// Upload blog post image
router.post('/:postId/images', async (req, res, next) => {
try {
const { postId } = req.params;
const { imagePath, caption, displayOrder } = req.body;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if post exists
const postCheck = await query(
'SELECT id FROM blog_posts WHERE id = $1',
[postId]
);
if (postCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Blog post not found'
});
}
// Check if image path is provided
if (!imagePath) {
return res.status(400).json({
error: true,
message: 'Image path is required'
});
}
// Add image to post
const result = await query(
`INSERT INTO blog_post_images (id, post_id, image_path, caption, display_order)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[uuidv4(), postId, imagePath, caption || null, displayOrder || 0]
);
res.status(201).json({
message: 'Image added to blog post',
image: result.rows[0]
});
} catch (error) {
next(error);
}
});
// Delete blog post image
router.delete('/:postId/images/:imageId', async (req, res, next) => {
try {
const { postId, imageId } = req.params;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if image exists and belongs to post
const imageCheck = await query(
'SELECT id FROM blog_post_images WHERE id = $1 AND post_id = $2',
[imageId, postId]
);
if (imageCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Image not found or does not belong to this post'
});
}
// Delete image
await query(
'DELETE FROM blog_post_images WHERE id = $1',
[imageId]
);
res.json({
message: 'Image deleted successfully'
});
} catch (error) {
next(error);
}
});
return router;
};

View file

@ -0,0 +1,208 @@
const express = require('express');
const router = express.Router();
module.exports = (pool, query, authMiddleware) => {
// Apply authentication middleware to all routes
router.use(authMiddleware);
// Get all pending comments (admin)
router.get('/pending', async (req, res, next) => {
try {
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Get all pending comments with related data
const result = await query(`
SELECT
c.id, c.content, c.created_at,
c.parent_id, c.post_id, c.user_id,
u.first_name, u.last_name, u.email,
b.title as post_title, b.slug as post_slug
FROM blog_comments c
JOIN users u ON c.user_id = u.id
JOIN blog_posts b ON c.post_id = b.id
WHERE c.is_approved = false
ORDER BY c.created_at DESC
`);
res.json(result.rows);
} catch (error) {
next(error);
}
});
// Get all comments for a post (admin)
router.get('/posts/:postId', async (req, res, next) => {
try {
const { postId } = req.params;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if post exists
const postCheck = await query(
'SELECT id, title FROM blog_posts WHERE id = $1',
[postId]
);
if (postCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Blog post not found'
});
}
const post = postCheck.rows[0];
// Get all comments for the post
const commentsQuery = `
SELECT
c.id, c.content, c.created_at,
c.parent_id, c.is_approved,
u.id as user_id, u.first_name, u.last_name, u.email
FROM blog_comments c
JOIN users u ON c.user_id = u.id
WHERE c.post_id = $1
ORDER BY c.created_at DESC
`;
const commentsResult = await query(commentsQuery, [postId]);
// Organize comments into threads
const commentThreads = [];
const commentMap = {};
// First, create a map of all comments
commentsResult.rows.forEach(comment => {
commentMap[comment.id] = {
...comment,
replies: []
};
});
// Then, organize into threads
commentsResult.rows.forEach(comment => {
if (comment.parent_id) {
// This is a reply
if (commentMap[comment.parent_id]) {
commentMap[comment.parent_id].replies.push(commentMap[comment.id]);
}
} else {
// This is a top-level comment
commentThreads.push(commentMap[comment.id]);
}
});
res.json({
post: {
id: post.id,
title: post.title
},
comments: commentThreads
});
} catch (error) {
next(error);
}
});
// Approve a comment
router.post('/:commentId/approve', async (req, res, next) => {
try {
const { commentId } = req.params;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if comment exists
const commentCheck = await query(
'SELECT id, is_approved FROM blog_comments WHERE id = $1',
[commentId]
);
if (commentCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Comment not found'
});
}
// Check if comment is already approved
if (commentCheck.rows[0].is_approved) {
return res.status(400).json({
error: true,
message: 'Comment is already approved'
});
}
// Approve comment
const result = await query(
'UPDATE blog_comments SET is_approved = true WHERE id = $1 RETURNING *',
[commentId]
);
res.json({
message: 'Comment approved successfully',
comment: result.rows[0]
});
} catch (error) {
next(error);
}
});
// Delete a comment
router.delete('/:commentId', async (req, res, next) => {
try {
const { commentId } = req.params;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if comment exists
const commentCheck = await query(
'SELECT id FROM blog_comments WHERE id = $1',
[commentId]
);
if (commentCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Comment not found'
});
}
// Delete comment - cascade will handle child comments
await query(
'DELETE FROM blog_comments WHERE id = $1',
[commentId]
);
res.json({
message: 'Comment deleted successfully'
});
} catch (error) {
next(error);
}
});
return router;
};

View file

@ -103,6 +103,375 @@ module.exports = (pool, query, authMiddleware) => {
} }
}); });
/**
* Apply coupon to cart
* POST /api/cart/apply-coupon
*/
router.post('/apply-coupon', async (req, res, next) => {
try {
const { userId, code } = req.body;
if (req.user.id !== userId) {
return res.status(403).json({
error: true,
message: 'You can only modify your own cart'
});
}
// Get cart
const cartResult = await query(
'SELECT * FROM carts WHERE user_id = $1',
[userId]
);
if (cartResult.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Cart not found'
});
}
const cartId = cartResult.rows[0].id;
// Check if coupon code exists and is valid
const couponResult = await query(`
SELECT *
FROM coupons
WHERE code = $1 AND is_active = true
`, [code.toUpperCase()]);
if (couponResult.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Invalid coupon code or coupon is inactive'
});
}
const coupon = couponResult.rows[0];
// Check if coupon is expired
if (coupon.end_date && new Date(coupon.end_date) < new Date()) {
return res.status(400).json({
error: true,
message: 'Coupon has expired'
});
}
// Check if coupon has not started yet
if (coupon.start_date && new Date(coupon.start_date) > new Date()) {
return res.status(400).json({
error: true,
message: 'Coupon is not yet active'
});
}
// Check redemption limit
if (coupon.redemption_limit !== null && coupon.current_redemptions >= coupon.redemption_limit) {
return res.status(400).json({
error: true,
message: 'Coupon redemption limit has been reached'
});
}
// Get cart items with product details
const cartItems = await query(`
SELECT ci.*, p.id as product_id, p.name, p.price, p.category_id,
pc.name as category_name,
(
SELECT array_agg(t.id)
FROM product_tags pt
JOIN tags t ON pt.tag_id = t.id
WHERE pt.product_id = p.id
) as tag_ids
FROM cart_items ci
JOIN products p ON ci.product_id = p.id
JOIN product_categories pc ON p.category_id = pc.id
WHERE ci.cart_id = $1
`, [cartId]);
if (cartItems.rows.length === 0) {
return res.status(400).json({
error: true,
message: 'Cart is empty'
});
}
// Calculate subtotal
const subtotal = cartItems.rows.reduce((sum, item) => {
return sum + (parseFloat(item.price) * item.quantity);
}, 0);
// Check minimum purchase requirement
if (coupon.min_purchase_amount && subtotal < coupon.min_purchase_amount) {
return res.status(400).json({
error: true,
message: `Minimum purchase amount of ${coupon.min_purchase_amount.toFixed(2)} not met`,
minimumAmount: coupon.min_purchase_amount
});
}
// Get coupon categories
const couponCategories = await query(`
SELECT category_id
FROM coupon_categories
WHERE coupon_id = $1
`, [coupon.id]);
// Get coupon tags
const couponTags = await query(`
SELECT tag_id
FROM coupon_tags
WHERE coupon_id = $1
`, [coupon.id]);
// Get blacklisted products
const blacklistResult = await query(`
SELECT product_id
FROM coupon_blacklist
WHERE coupon_id = $1
`, [coupon.id]);
const blacklistedProductIds = blacklistResult.rows.map(row => row.product_id);
// Calculate discount based on eligible products
let discountableAmount = 0;
const categoryIds = couponCategories.rows.map(row => row.category_id);
const tagIds = couponTags.rows.map(row => row.tag_id);
for (const item of cartItems.rows) {
// Skip blacklisted products
if (blacklistedProductIds.includes(item.product_id)) {
continue;
}
let isEligible = false;
// If no categories or tags are specified, all products are eligible
if (categoryIds.length === 0 && tagIds.length === 0) {
isEligible = true;
} else {
// Check if product belongs to eligible category
if (categoryIds.includes(item.category_id)) {
isEligible = true;
}
// Check if product has eligible tag
if (!isEligible && item.tag_ids && item.tag_ids.some(tagId => tagIds.includes(tagId))) {
isEligible = true;
}
}
if (isEligible) {
discountableAmount += parseFloat(item.price) * item.quantity;
}
}
// Calculate discount amount
let discountAmount = 0;
if (coupon.discount_type === 'percentage') {
discountAmount = discountableAmount * (coupon.discount_value / 100);
} else { // fixed_amount
discountAmount = Math.min(discountableAmount, coupon.discount_value);
}
// Apply maximum discount cap if set
if (coupon.max_discount_amount && discountAmount > coupon.max_discount_amount) {
discountAmount = coupon.max_discount_amount;
}
// Round to 2 decimal places
discountAmount = Math.round(discountAmount * 100) / 100;
// Update cart metadata with coupon information
await query(`
UPDATE carts
SET metadata = jsonb_set(
COALESCE(metadata, '{}'::jsonb),
'{coupon}',
$1::jsonb
)
WHERE id = $2
`, [
JSON.stringify({
id: coupon.id,
code: coupon.code,
discount_type: coupon.discount_type,
discount_value: coupon.discount_value,
discount_amount: discountAmount
}),
cartId
]);
// Get updated cart with all items
const updatedCartItems = await query(`
SELECT ci.*, p.id AS product_id, p.name, p.description, p.price, p.stock_quantity,
p.category_id, pc.name AS category_name,
p.weight_grams, p.length_cm, p.width_cm, p.height_cm,
(
SELECT json_agg(
json_build_object(
'id', pi.id,
'path', pi.image_path,
'isPrimary', pi.is_primary,
'displayOrder', pi.display_order
) ORDER BY pi.display_order
)
FROM product_images pi
WHERE pi.product_id = p.id
) AS images
FROM cart_items ci
JOIN products p ON ci.product_id = p.id
JOIN product_categories pc ON p.category_id = pc.id
WHERE ci.cart_id = $1
GROUP BY ci.id, ci.quantity, ci.added_at, p.id, p.name, p.description, p.price,
p.stock_quantity, p.category_id, pc.name, p.weight_grams, p.length_cm, p.width_cm, p.height_cm
`, [cartId]);
// Get cart metadata with coupon
const cartMetadata = await query(`
SELECT metadata
FROM carts
WHERE id = $1
`, [cartId]);
// Process images to add primary_image field
const processedItems = updatedCartItems.rows.map(item => {
// Add primary_image field derived from images array
let primaryImage = null;
if (item.images && item.images.length > 0) {
primaryImage = item.images.find(img => img.isPrimary === true) || item.images[0];
}
return {
...item,
primary_image: primaryImage
};
});
// Calculate subtotal
const cartSubtotal = processedItems.reduce((sum, item) => {
return sum + (parseFloat(item.price) * item.quantity);
}, 0);
// Calculate total with discount
const total = cartSubtotal - discountAmount;
// Format coupon info for response
const couponInfo = cartMetadata.rows[0].metadata.coupon;
res.json({
id: cartId,
userId,
items: processedItems,
itemCount: processedItems.length,
subtotal: cartSubtotal,
couponDiscount: discountAmount,
couponCode: couponInfo.code,
couponId: couponInfo.id,
total: total
});
} catch (error) {
next(error);
}
});
/**
* Remove coupon from cart
* POST /api/cart/remove-coupon
*/
router.post('/remove-coupon', async (req, res, next) => {
try {
const { userId } = req.body;
if (req.user.id !== userId) {
return res.status(403).json({
error: true,
message: 'You can only modify your own cart'
});
}
// Get cart
const cartResult = await query(
'SELECT * FROM carts WHERE user_id = $1',
[userId]
);
if (cartResult.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Cart not found'
});
}
const cartId = cartResult.rows[0].id;
// Remove coupon from cart metadata
await query(`
UPDATE carts
SET metadata = metadata - 'coupon'
WHERE id = $1
`, [cartId]);
// Get updated cart with all items
const updatedCartItems = await query(`
SELECT ci.*, p.id AS product_id, p.name, p.description, p.price, p.stock_quantity,
p.category_id, pc.name AS category_name,
p.weight_grams, p.length_cm, p.width_cm, p.height_cm,
(
SELECT json_agg(
json_build_object(
'id', pi.id,
'path', pi.image_path,
'isPrimary', pi.is_primary,
'displayOrder', pi.display_order
) ORDER BY pi.display_order
)
FROM product_images pi
WHERE pi.product_id = p.id
) AS images
FROM cart_items ci
JOIN products p ON ci.product_id = p.id
JOIN product_categories pc ON p.category_id = pc.id
WHERE ci.cart_id = $1
GROUP BY ci.id, ci.quantity, ci.added_at, p.id, p.name, p.description, p.price,
p.stock_quantity, p.category_id, pc.name, p.weight_grams, p.length_cm, p.width_cm, p.height_cm
`, [cartId]);
// Process images to add primary_image field
const processedItems = updatedCartItems.rows.map(item => {
// Add primary_image field derived from images array
let primaryImage = null;
if (item.images && item.images.length > 0) {
primaryImage = item.images.find(img => img.isPrimary === true) || item.images[0];
}
return {
...item,
primary_image: primaryImage
};
});
// Calculate subtotal
const subtotal = processedItems.reduce((sum, item) => {
return sum + (parseFloat(item.price) * item.quantity);
}, 0);
res.json({
id: cartId,
userId,
items: processedItems,
itemCount: processedItems.length,
subtotal,
total: subtotal,
message: 'Coupon removed successfully'
});
} catch (error) {
next(error);
}
});
// Add item to cart // Add item to cart
router.post('/add', async (req, res, next) => { router.post('/add', async (req, res, next) => {
try { try {

View file

@ -0,0 +1,689 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const router = express.Router();
module.exports = (pool, query, authMiddleware) => {
// Apply authentication middleware to all routes
router.use(authMiddleware);
/**
* GET /api/admin/coupons
* Get all coupons
*/
router.get('/', async (req, res, next) => {
try {
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Get all coupons with category and tag information
const result = await query(`
SELECT c.*,
(
SELECT json_agg(
json_build_object(
'id', pc.id,
'name', pc.name
)
)
FROM coupon_categories cc
JOIN product_categories pc ON cc.category_id = pc.id
WHERE cc.coupon_id = c.id
) AS categories,
(
SELECT json_agg(
json_build_object(
'id', t.id,
'name', t.name
)
)
FROM coupon_tags ct
JOIN tags t ON ct.tag_id = t.id
WHERE ct.coupon_id = c.id
) AS tags,
(
SELECT COUNT(*)
FROM coupon_redemptions cr
WHERE cr.coupon_id = c.id
) AS redemption_count
FROM coupons c
ORDER BY c.created_at DESC
`);
res.json(result.rows);
} catch (error) {
next(error);
}
});
/**
* GET /api/admin/coupons/:id
* Get single coupon by ID
*/
router.get('/:id', async (req, res, next) => {
try {
const { id } = req.params;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Get coupon with categories, tags, and blacklisted products
const result = await query(`
SELECT c.*,
(
SELECT json_agg(
json_build_object(
'id', pc.id,
'name', pc.name
)
)
FROM coupon_categories cc
JOIN product_categories pc ON cc.category_id = pc.id
WHERE cc.coupon_id = c.id
) AS categories,
(
SELECT json_agg(
json_build_object(
'id', t.id,
'name', t.name
)
)
FROM coupon_tags ct
JOIN tags t ON ct.tag_id = t.id
WHERE ct.coupon_id = c.id
) AS tags,
(
SELECT json_agg(
json_build_object(
'id', p.id,
'name', p.name
)
)
FROM coupon_blacklist bl
JOIN products p ON bl.product_id = p.id
WHERE bl.coupon_id = c.id
) AS blacklisted_products
FROM coupons c
WHERE c.id = $1
`, [id]);
if (result.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Coupon not found'
});
}
res.json(result.rows[0]);
} catch (error) {
next(error);
}
});
/**
* POST /api/admin/coupons
* Create a new coupon
*/
router.post('/', async (req, res, next) => {
try {
const {
code,
description,
discountType,
discountValue,
minPurchaseAmount,
maxDiscountAmount,
redemptionLimit,
startDate,
endDate,
isActive,
categories,
tags,
blacklistedProducts
} = req.body;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Validate required fields
if (!code || !discountType || !discountValue) {
return res.status(400).json({
error: true,
message: 'Code, discount type, and discount value are required'
});
}
// Validate discount type
if (!['percentage', 'fixed_amount'].includes(discountType)) {
return res.status(400).json({
error: true,
message: 'Discount type must be either "percentage" or "fixed_amount"'
});
}
// Validate discount value
if (discountType === 'percentage' && (discountValue <= 0 || discountValue > 100)) {
return res.status(400).json({
error: true,
message: 'Percentage discount must be between 0 and 100'
});
}
if (discountType === 'fixed_amount' && discountValue <= 0) {
return res.status(400).json({
error: true,
message: 'Fixed amount discount must be greater than 0'
});
}
// Check if code is already used
const existingCoupon = await query(
'SELECT * FROM coupons WHERE code = $1',
[code]
);
if (existingCoupon.rows.length > 0) {
return res.status(400).json({
error: true,
message: 'Coupon code already exists'
});
}
// Begin transaction
const client = await pool.connect();
try {
await client.query('BEGIN');
// Create coupon
const couponResult = await client.query(`
INSERT INTO coupons (
id, code, description, discount_type, discount_value,
min_purchase_amount, max_discount_amount, redemption_limit,
start_date, end_date, is_active
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *
`, [
uuidv4(),
code,
description || null,
discountType,
discountValue,
minPurchaseAmount || null,
maxDiscountAmount || null,
redemptionLimit || null,
startDate || null,
endDate || null,
isActive !== undefined ? isActive : true
]);
const couponId = couponResult.rows[0].id;
// Add categories if provided
if (categories && categories.length > 0) {
for (const categoryId of categories) {
await client.query(
'INSERT INTO coupon_categories (coupon_id, category_id) VALUES ($1, $2)',
[couponId, categoryId]
);
}
}
// Add tags if provided
if (tags && tags.length > 0) {
for (const tagId of tags) {
await client.query(
'INSERT INTO coupon_tags (coupon_id, tag_id) VALUES ($1, $2)',
[couponId, tagId]
);
}
}
// Add blacklisted products if provided
if (blacklistedProducts && blacklistedProducts.length > 0) {
for (const productId of blacklistedProducts) {
await client.query(
'INSERT INTO coupon_blacklist (coupon_id, product_id) VALUES ($1, $2)',
[couponId, productId]
);
}
}
await client.query('COMMIT');
// Get the complete coupon data
const result = await query(`
SELECT c.*,
(
SELECT json_agg(
json_build_object(
'id', pc.id,
'name', pc.name
)
)
FROM coupon_categories cc
JOIN product_categories pc ON cc.category_id = pc.id
WHERE cc.coupon_id = c.id
) AS categories,
(
SELECT json_agg(
json_build_object(
'id', t.id,
'name', t.name
)
)
FROM coupon_tags ct
JOIN tags t ON ct.tag_id = t.id
WHERE ct.coupon_id = c.id
) AS tags,
(
SELECT json_agg(
json_build_object(
'id', p.id,
'name', p.name
)
)
FROM coupon_blacklist bl
JOIN products p ON bl.product_id = p.id
WHERE bl.coupon_id = c.id
) AS blacklisted_products
FROM coupons c
WHERE c.id = $1
`, [couponId]);
res.status(201).json({
message: 'Coupon created successfully',
coupon: result.rows[0]
});
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} catch (error) {
next(error);
}
});
/**
* PUT /api/admin/coupons/:id
* Update a coupon
*/
router.put('/:id', async (req, res, next) => {
try {
const { id } = req.params;
const {
code,
description,
discountType,
discountValue,
minPurchaseAmount,
maxDiscountAmount,
redemptionLimit,
startDate,
endDate,
isActive,
categories,
tags,
blacklistedProducts
} = req.body;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if coupon exists
const couponCheck = await query(
'SELECT * FROM coupons WHERE id = $1',
[id]
);
if (couponCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Coupon not found'
});
}
// Validate discount type if provided
if (discountType && !['percentage', 'fixed_amount'].includes(discountType)) {
return res.status(400).json({
error: true,
message: 'Discount type must be either "percentage" or "fixed_amount"'
});
}
// Validate discount value if provided
if (discountType === 'percentage' && discountValue !== undefined && (discountValue <= 0 || discountValue > 100)) {
return res.status(400).json({
error: true,
message: 'Percentage discount must be between 0 and 100'
});
}
if (discountType === 'fixed_amount' && discountValue !== undefined && discountValue <= 0) {
return res.status(400).json({
error: true,
message: 'Fixed amount discount must be greater than 0'
});
}
// Check if new code conflicts with existing coupons
if (code && code !== couponCheck.rows[0].code) {
const codeCheck = await query(
'SELECT * FROM coupons WHERE code = $1 AND id != $2',
[code, id]
);
if (codeCheck.rows.length > 0) {
return res.status(400).json({
error: true,
message: 'Coupon code already exists'
});
}
}
// Begin transaction
const client = await pool.connect();
try {
await client.query('BEGIN');
// Update coupon
const updateFields = [];
const updateValues = [];
let paramIndex = 1;
if (code !== undefined) {
updateFields.push(`code = $${paramIndex}`);
updateValues.push(code);
paramIndex++;
}
if (description !== undefined) {
updateFields.push(`description = $${paramIndex}`);
updateValues.push(description);
paramIndex++;
}
if (discountType !== undefined) {
updateFields.push(`discount_type = $${paramIndex}`);
updateValues.push(discountType);
paramIndex++;
}
if (discountValue !== undefined) {
updateFields.push(`discount_value = $${paramIndex}`);
updateValues.push(discountValue);
paramIndex++;
}
if (minPurchaseAmount !== undefined) {
updateFields.push(`min_purchase_amount = $${paramIndex}`);
updateValues.push(minPurchaseAmount);
paramIndex++;
}
if (maxDiscountAmount !== undefined) {
updateFields.push(`max_discount_amount = $${paramIndex}`);
updateValues.push(maxDiscountAmount);
paramIndex++;
}
if (redemptionLimit !== undefined) {
updateFields.push(`redemption_limit = $${paramIndex}`);
updateValues.push(redemptionLimit);
paramIndex++;
}
if (startDate !== undefined) {
updateFields.push(`start_date = $${paramIndex}`);
updateValues.push(startDate);
paramIndex++;
}
if (endDate !== undefined) {
updateFields.push(`end_date = $${paramIndex}`);
updateValues.push(endDate);
paramIndex++;
}
if (isActive !== undefined) {
updateFields.push(`is_active = $${paramIndex}`);
updateValues.push(isActive);
paramIndex++;
}
// Only update if there are fields to update
if (updateFields.length > 0) {
updateValues.push(id);
await client.query(`
UPDATE coupons
SET ${updateFields.join(', ')}
WHERE id = $${paramIndex}
`, updateValues);
}
// Update categories if provided
if (categories !== undefined) {
// Remove existing categories
await client.query(
'DELETE FROM coupon_categories WHERE coupon_id = $1',
[id]
);
// Add new categories
if (categories && categories.length > 0) {
for (const categoryId of categories) {
await client.query(
'INSERT INTO coupon_categories (coupon_id, category_id) VALUES ($1, $2)',
[id, categoryId]
);
}
}
}
// Update tags if provided
if (tags !== undefined) {
// Remove existing tags
await client.query(
'DELETE FROM coupon_tags WHERE coupon_id = $1',
[id]
);
// Add new tags
if (tags && tags.length > 0) {
for (const tagId of tags) {
await client.query(
'INSERT INTO coupon_tags (coupon_id, tag_id) VALUES ($1, $2)',
[id, tagId]
);
}
}
}
// Update blacklisted products if provided
if (blacklistedProducts !== undefined) {
// Remove existing blacklisted products
await client.query(
'DELETE FROM coupon_blacklist WHERE coupon_id = $1',
[id]
);
// Add new blacklisted products
if (blacklistedProducts && blacklistedProducts.length > 0) {
for (const productId of blacklistedProducts) {
await client.query(
'INSERT INTO coupon_blacklist (coupon_id, product_id) VALUES ($1, $2)',
[id, productId]
);
}
}
}
await client.query('COMMIT');
// Get the updated coupon data
const result = await query(`
SELECT c.*,
(
SELECT json_agg(
json_build_object(
'id', pc.id,
'name', pc.name
)
)
FROM coupon_categories cc
JOIN product_categories pc ON cc.category_id = pc.id
WHERE cc.coupon_id = c.id
) AS categories,
(
SELECT json_agg(
json_build_object(
'id', t.id,
'name', t.name
)
)
FROM coupon_tags ct
JOIN tags t ON ct.tag_id = t.id
WHERE ct.coupon_id = c.id
) AS tags,
(
SELECT json_agg(
json_build_object(
'id', p.id,
'name', p.name
)
)
FROM coupon_blacklist bl
JOIN products p ON bl.product_id = p.id
WHERE bl.coupon_id = c.id
) AS blacklisted_products
FROM coupons c
WHERE c.id = $1
`, [id]);
res.json({
message: 'Coupon updated successfully',
coupon: result.rows[0]
});
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} catch (error) {
next(error);
}
});
/**
* DELETE /api/admin/coupons/:id
* Delete a coupon
*/
router.delete('/:id', async (req, res, next) => {
try {
const { id } = req.params;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if coupon exists
const couponCheck = await query(
'SELECT * FROM coupons WHERE id = $1',
[id]
);
if (couponCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Coupon not found'
});
}
// Delete coupon (cascade will handle related records)
await query(
'DELETE FROM coupons WHERE id = $1',
[id]
);
res.json({
message: 'Coupon deleted successfully'
});
} catch (error) {
next(error);
}
});
/**
* GET /api/admin/coupons/:id/redemptions
* Get coupon redemption history
*/
router.get('/:id/redemptions', async (req, res, next) => {
try {
const { id } = req.params;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if coupon exists
const couponCheck = await query(
'SELECT * FROM coupons WHERE id = $1',
[id]
);
if (couponCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Coupon not found'
});
}
// Get redemption history
const result = await query(`
SELECT cr.*, u.email, u.first_name, u.last_name, o.total_amount
FROM coupon_redemptions cr
JOIN users u ON cr.user_id = u.id
JOIN orders o ON cr.order_id = o.id
WHERE cr.coupon_id = $1
ORDER BY cr.redeemed_at DESC
`, [id]);
res.json(result.rows);
} catch (error) {
next(error);
}
});
return router;
};

View file

@ -93,5 +93,88 @@ module.exports = (pool, query) => {
} }
}); });
// Upload blog post image (admin only)
router.post('/admin/blog', adminAuthMiddleware(pool, query), upload.single('image'), async (req, res, next) => {
try {
if (!req.file) {
return res.status(400).json({
error: true,
message: 'No image file provided'
});
}
// Get the relative path to the image
const imagePath = `/uploads/blog/${req.file.filename}`;
res.json({
success: true,
imagePath,
filename: req.file.filename
});
} catch (error) {
next(error);
}
});
// Upload multiple blog images (admin only)
router.post('/admin/blog/multiple', adminAuthMiddleware(pool, query), upload.array('images', 10), async (req, res, next) => {
try {
if (!req.files || req.files.length === 0) {
return res.status(400).json({
error: true,
message: 'No image files provided'
});
}
// Get the relative paths to the images
const imagePaths = req.files.map(file => ({
imagePath: `/uploads/blog/${file.filename}`,
filename: file.filename
}));
res.json({
success: true,
images: imagePaths
});
} catch (error) {
next(error);
}
});
// Delete blog image (admin only)
router.delete('/admin/blog/:filename', adminAuthMiddleware(pool, query), async (req, res, next) => {
try {
const { filename } = req.params;
// Prevent path traversal attacks
if (filename.includes('..') || filename.includes('/')) {
return res.status(400).json({
error: true,
message: 'Invalid filename'
});
}
const filePath = path.join(__dirname, '../../public/uploads/blog', filename);
// Check if file exists
if (!fs.existsSync(filePath)) {
return res.status(404).json({
error: true,
message: 'Image not found'
});
}
// Delete the file
fs.unlinkSync(filePath);
res.json({
success: true,
message: 'Image deleted successfully'
});
} catch (error) {
next(error);
}
});
return router; return router;
}; };

View file

@ -0,0 +1,246 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const router = express.Router();
module.exports = (pool, query, authMiddleware) => {
// Apply authentication middleware to all routes
router.use(authMiddleware);
// Get reviews for a specific product
router.get('/:productId', async (req, res, next) => {
try {
const { productId } = req.params;
// Get all approved reviews with user info and replies
const reviewsQuery = `
SELECT
r.id, r.title, r.content, r.rating, r.is_verified_purchase,
r.created_at, r.parent_id,
u.id as user_id, u.first_name, u.last_name
FROM product_reviews r
JOIN users u ON r.user_id = u.id
WHERE r.product_id = $1 AND r.is_approved = true
ORDER BY r.created_at DESC
`;
const reviewsResult = await query(reviewsQuery, [productId]);
// Organize reviews into threads
const reviewThreads = [];
const reviewMap = {};
// First, create a map of all reviews
reviewsResult.rows.forEach(review => {
reviewMap[review.id] = {
...review,
replies: []
};
});
// Then, organize into threads
reviewsResult.rows.forEach(review => {
if (review.parent_id) {
// This is a reply
if (reviewMap[review.parent_id]) {
reviewMap[review.parent_id].replies.push(reviewMap[review.id]);
}
} else {
// This is a top-level review
reviewThreads.push(reviewMap[review.id]);
}
});
res.json(reviewThreads);
} catch (error) {
next(error);
}
});
// Add a review to a product
router.post('/:productId', async (req, res, next) => {
try {
const { productId } = req.params;
const { title, content, rating, parentId } = req.body;
const userId = req.user.id;
// Validate required fields
if (!title) {
return res.status(400).json({
error: true,
message: 'Review title is required'
});
}
// If it's a top-level review, rating is required
if (!parentId && (!rating || rating < 1 || rating > 5)) {
return res.status(400).json({
error: true,
message: 'Valid rating (1-5) is required for reviews'
});
}
// Check if product exists
const productCheck = await query(
'SELECT id FROM products WHERE id = $1',
[productId]
);
if (productCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Product not found'
});
}
// If this is a reply, check if parent review exists
if (parentId) {
const parentCheck = await query(
'SELECT id FROM product_reviews WHERE id = $1 AND product_id = $2',
[parentId, productId]
);
if (parentCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Parent review not found'
});
}
}
// Check if user has purchased the product
const purchaseCheck = await query(`
SELECT o.id
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
WHERE o.user_id = $1
AND oi.product_id = $2
AND o.payment_completed = true
LIMIT 1
`, [userId, productId]);
const isVerifiedPurchase = purchaseCheck.rows.length > 0;
// Check if user is admin
const isAdmin = req.user.is_admin || false;
// Only allow reviews if user has purchased the product or is an admin
if (!isVerifiedPurchase && !isAdmin && !parentId) {
return res.status(403).json({
error: true,
message: 'You must purchase this product before you can review it'
});
}
// For replies, we don't need verified purchase
const isApproved = isAdmin ? true : false; // Auto-approve admin reviews
const reviewData = {
id: uuidv4(),
productId,
userId,
parentId: parentId || null,
title,
content: content || null,
rating: parentId ? null : rating,
isApproved,
isVerifiedPurchase: parentId ? null : isVerifiedPurchase
};
// Insert review
const insertQuery = `
INSERT INTO product_reviews
(id, product_id, user_id, parent_id, title, content, rating, is_approved, is_verified_purchase)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *
`;
const result = await query(
insertQuery,
[
reviewData.id,
reviewData.productId,
reviewData.userId,
reviewData.parentId,
reviewData.title,
reviewData.content,
reviewData.rating,
reviewData.isApproved,
reviewData.isVerifiedPurchase
]
);
// Get user info for the response
const userResult = await query(
'SELECT first_name, last_name FROM users WHERE id = $1',
[userId]
);
const review = {
...result.rows[0],
first_name: userResult.rows[0].first_name,
last_name: userResult.rows[0].last_name,
replies: []
};
res.status(201).json({
message: isApproved
? 'Review added successfully'
: 'Review submitted and awaiting approval',
review
});
} catch (error) {
next(error);
}
});
// Check if user can review a product
router.get('/:productId/can-review', async (req, res, next) => {
try {
const { productId } = req.params;
const userId = req.user.id;
const isAdmin = req.user.is_admin || false;
// Check if user has already reviewed this product
const existingReviewCheck = await query(`
SELECT id FROM product_reviews
WHERE product_id = $1 AND user_id = $2 AND parent_id IS NULL
LIMIT 1
`, [productId, userId]);
if (existingReviewCheck.rows.length > 0) {
return res.json({
canReview: false,
reason: 'You have already reviewed this product'
});
}
// If user is admin, they can always review
if (isAdmin) {
return res.json({
canReview: true,
isAdmin: true
});
}
// Check if user has purchased the product
const purchaseCheck = await query(`
SELECT o.id
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
WHERE o.user_id = $1
AND oi.product_id = $2
AND o.payment_completed = true
LIMIT 1
`, [userId, productId]);
res.json({
canReview: purchaseCheck.rows.length > 0,
isPurchaser: purchaseCheck.rows.length > 0,
isAdmin: false
});
} catch (error) {
next(error);
}
});
return router;
};

View file

@ -0,0 +1,208 @@
const express = require('express');
const router = express.Router();
module.exports = (pool, query, authMiddleware) => {
// Apply authentication middleware to all routes
router.use(authMiddleware);
// Get all pending reviews (admin)
router.get('/pending', async (req, res, next) => {
try {
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Get all pending reviews with related data
const result = await query(`
SELECT
r.id, r.title, r.content, r.rating, r.created_at,
r.parent_id, r.product_id, r.user_id, r.is_verified_purchase,
u.first_name, u.last_name, u.email,
p.name as product_name
FROM product_reviews r
JOIN users u ON r.user_id = u.id
JOIN products p ON r.product_id = p.id
WHERE r.is_approved = false
ORDER BY r.created_at DESC
`);
res.json(result.rows);
} catch (error) {
next(error);
}
});
// Get all reviews for a product (admin)
router.get('/products/:productId', async (req, res, next) => {
try {
const { productId } = req.params;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if product exists
const productCheck = await query(
'SELECT id, name FROM products WHERE id = $1',
[productId]
);
if (productCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Product not found'
});
}
const product = productCheck.rows[0];
// Get all reviews for the product
const reviewsQuery = `
SELECT
r.id, r.title, r.content, r.rating, r.created_at,
r.parent_id, r.is_approved, r.is_verified_purchase,
u.id as user_id, u.first_name, u.last_name, u.email
FROM product_reviews r
JOIN users u ON r.user_id = u.id
WHERE r.product_id = $1
ORDER BY r.created_at DESC
`;
const reviewsResult = await query(reviewsQuery, [productId]);
// Organize reviews into threads
const reviewThreads = [];
const reviewMap = {};
// First, create a map of all reviews
reviewsResult.rows.forEach(review => {
reviewMap[review.id] = {
...review,
replies: []
};
});
// Then, organize into threads
reviewsResult.rows.forEach(review => {
if (review.parent_id) {
// This is a reply
if (reviewMap[review.parent_id]) {
reviewMap[review.parent_id].replies.push(reviewMap[review.id]);
}
} else {
// This is a top-level review
reviewThreads.push(reviewMap[review.id]);
}
});
res.json({
product: {
id: product.id,
name: product.name
},
reviews: reviewThreads
});
} catch (error) {
next(error);
}
});
// Approve a review
router.post('/:reviewId/approve', async (req, res, next) => {
try {
const { reviewId } = req.params;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if review exists
const reviewCheck = await query(
'SELECT id, is_approved FROM product_reviews WHERE id = $1',
[reviewId]
);
if (reviewCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Review not found'
});
}
// Check if review is already approved
if (reviewCheck.rows[0].is_approved) {
return res.status(400).json({
error: true,
message: 'Review is already approved'
});
}
// Approve review
const result = await query(
'UPDATE product_reviews SET is_approved = true WHERE id = $1 RETURNING *',
[reviewId]
);
res.json({
message: 'Review approved successfully',
review: result.rows[0]
});
} catch (error) {
next(error);
}
});
// Delete a review
router.delete('/:reviewId', async (req, res, next) => {
try {
const { reviewId } = req.params;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if review exists
const reviewCheck = await query(
'SELECT id FROM product_reviews WHERE id = $1',
[reviewId]
);
if (reviewCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Review not found'
});
}
// Delete review and all replies (cascading delete handles this)
await query(
'DELETE FROM product_reviews WHERE id = $1',
[reviewId]
);
res.json({
message: 'Review deleted successfully'
});
} catch (error) {
next(error);
}
});
return router;
};