Couponss, Blogs, reviews
This commit is contained in:
parent
b88fb93637
commit
b1f5985224
13 changed files with 2612 additions and 0 deletions
5
backend/package-lock.json
generated
5
backend/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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
285
backend/src/routes/blog.js
Normal 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;
|
||||||
|
};
|
||||||
496
backend/src/routes/blogAdmin.js
Normal file
496
backend/src/routes/blogAdmin.js
Normal 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;
|
||||||
|
};
|
||||||
208
backend/src/routes/blogCommentsAdmin.js
Normal file
208
backend/src/routes/blogCommentsAdmin.js
Normal 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;
|
||||||
|
};
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
689
backend/src/routes/couponAdmin.js
Normal file
689
backend/src/routes/couponAdmin.js
Normal 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;
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
246
backend/src/routes/productReviews.js
Normal file
246
backend/src/routes/productReviews.js
Normal 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;
|
||||||
|
};
|
||||||
208
backend/src/routes/productReviewsAdmin.js
Normal file
208
backend/src/routes/productReviewsAdmin.js
Normal 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;
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue