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",
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
"nodemailer": "^6.9.1",
|
||||
"pg": "^8.10.0",
|
||||
"pg-hstore": "^2.3.4",
|
||||
"slugify": "^1.6.6",
|
||||
"stripe": "^12.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 usersAdminRoutes = require('./routes/userAdmin');
|
||||
const ordersAdminRoutes = require('./routes/orderAdmin');
|
||||
const couponsAdminRoutes = require('./routes/couponAdmin');
|
||||
const userOrdersRoutes = require('./routes/userOrders');
|
||||
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
|
||||
const app = express();
|
||||
|
|
@ -42,6 +48,10 @@ if (!fs.existsSync(uploadsDir)) {
|
|||
if (!fs.existsSync(productImagesDir)) {
|
||||
fs.mkdirSync(productImagesDir, { recursive: true });
|
||||
}
|
||||
const blogImagesDir = path.join(uploadsDir, 'blog');
|
||||
if (!fs.existsSync(blogImagesDir)) {
|
||||
fs.mkdirSync(blogImagesDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Configure storage
|
||||
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('/api/uploads', 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'))) {
|
||||
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}`
|
||||
});
|
||||
});
|
||||
|
||||
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/coupons', couponsAdminRoutes(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
|
||||
app.post('/api/image/product', adminAuthMiddleware(pool, query), upload.single('image'), (req, res) => {
|
||||
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/auth', authRoutes(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/admin/products', productAdminRoutes(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
|
||||
router.post('/add', async (req, res, next) => {
|
||||
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;
|
||||
};
|
||||
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