diff --git a/backend/package-lock.json b/backend/package-lock.json index 101fc2f..e2fe3bd 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index b6ae4dd..d98cba9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" }, diff --git a/backend/public/uploads/products/wilson-1745952400108-890575617.jpg b/backend/public/uploads/products/wilson-1745952400108-890575617.jpg new file mode 100644 index 0000000..10487c6 Binary files /dev/null and b/backend/public/uploads/products/wilson-1745952400108-890575617.jpg differ diff --git a/backend/public/uploads/products/wilson-1745952603966-106384496.jpg b/backend/public/uploads/products/wilson-1745952603966-106384496.jpg new file mode 100644 index 0000000..10487c6 Binary files /dev/null and b/backend/public/uploads/products/wilson-1745952603966-106384496.jpg differ diff --git a/backend/src/index.js b/backend/src/index.js index f704000..cd63ee1 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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))); diff --git a/backend/src/routes/blog.js b/backend/src/routes/blog.js new file mode 100644 index 0000000..038a70a --- /dev/null +++ b/backend/src/routes/blog.js @@ -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; +}; \ No newline at end of file diff --git a/backend/src/routes/blogAdmin.js b/backend/src/routes/blogAdmin.js new file mode 100644 index 0000000..f2bee22 --- /dev/null +++ b/backend/src/routes/blogAdmin.js @@ -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; +}; \ No newline at end of file diff --git a/backend/src/routes/blogCommentsAdmin.js b/backend/src/routes/blogCommentsAdmin.js new file mode 100644 index 0000000..0563310 --- /dev/null +++ b/backend/src/routes/blogCommentsAdmin.js @@ -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; +}; \ No newline at end of file diff --git a/backend/src/routes/cart.js b/backend/src/routes/cart.js index 1cb25eb..d79f084 100644 --- a/backend/src/routes/cart.js +++ b/backend/src/routes/cart.js @@ -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 { diff --git a/backend/src/routes/couponAdmin.js b/backend/src/routes/couponAdmin.js new file mode 100644 index 0000000..37c437e --- /dev/null +++ b/backend/src/routes/couponAdmin.js @@ -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; +}; \ No newline at end of file diff --git a/backend/src/routes/images.js b/backend/src/routes/images.js index 60975ec..e49ab53 100644 --- a/backend/src/routes/images.js +++ b/backend/src/routes/images.js @@ -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; }; \ No newline at end of file diff --git a/backend/src/routes/productReviews.js b/backend/src/routes/productReviews.js new file mode 100644 index 0000000..c58fae2 --- /dev/null +++ b/backend/src/routes/productReviews.js @@ -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; +}; \ No newline at end of file diff --git a/backend/src/routes/productReviewsAdmin.js b/backend/src/routes/productReviewsAdmin.js new file mode 100644 index 0000000..59cf253 --- /dev/null +++ b/backend/src/routes/productReviewsAdmin.js @@ -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; +}; \ No newline at end of file