diff --git a/backend/controllers/productController.js b/backend/controllers/productController.js index 91c7c83..69b273d 100644 --- a/backend/controllers/productController.js +++ b/backend/controllers/productController.js @@ -89,7 +89,9 @@ const getProducts = async (req, res) => { image, category, stock, - featured + featured, + rating, + num_reviews ${baseQuery} ORDER BY id DESC LIMIT ? @@ -194,7 +196,9 @@ const getSingleProduct = async (req, res) => { image, category, stock, - featured + featured, + rating, + num_reviews FROM products WHERE id = ? `; @@ -429,7 +433,7 @@ const DeleteeProduct = async (req, res) => { }); } - const query = "DeleteE FROM products WHERE id = ?"; + const query = "DELETE FROM products WHERE id = ?"; try { const [result] = await db.query(query, [id]); @@ -443,7 +447,7 @@ const DeleteeProduct = async (req, res) => { res.status(200).json({ success: true, - message: "Product Deleteed successfully" + message: "Product deleted successfully" }); } catch (error) { console.error(error); @@ -480,4 +484,4 @@ module.exports = { updateProduct, DeleteeProduct, getProductSuggestions -}; \ No newline at end of file +}; diff --git a/backend/controllers/reviewController.js b/backend/controllers/reviewController.js new file mode 100644 index 0000000..4dfa43a --- /dev/null +++ b/backend/controllers/reviewController.js @@ -0,0 +1,268 @@ +const db = require("../config/db"); +const Review = require("../models/Review"); +const { + safeArray, + safeInteger, + sanitizeString +} = require("../utils/helpers"); + +async function productExists(productId) { + const [products] = await db.query( + "SELECT id FROM products WHERE id = ? LIMIT 1", + [productId] + ); + + return safeArray(products).length > 0; +} + +async function refreshProductReviewStats(productId, connection = db) { + const [stats] = await connection.query( + ` + SELECT + COALESCE(ROUND(AVG(rating), 2), 0) AS average_rating, + COUNT(*) AS review_count + FROM reviews + WHERE product_id = ? + `, + [productId] + ); + + const averageRating = Number(stats?.[0]?.average_rating || 0); + const reviewCount = Number(stats?.[0]?.review_count || 0); + + await connection.query( + ` + UPDATE products + SET rating = ?, num_reviews = ? + WHERE id = ? + `, + [averageRating, reviewCount, productId] + ); + + return { + averageRating, + reviewCount + }; +} + +const getProductReviews = async (req, res) => { + const productId = safeInteger(req.params.id); + + if (!productId) { + return res.status(400).json({ + success: false, + message: "Invalid product ID" + }); + } + + try { + if (!(await productExists(productId))) { + return res.status(404).json({ + success: false, + message: "Product not found" + }); + } + + const [reviews] = await db.query( + ` + SELECT + r.id, + r.product_id, + r.user_id, + u.name AS user_name, + r.rating, + r.comment, + r.created_at + FROM reviews r + JOIN users u ON u.id = r.user_id + WHERE r.product_id = ? + ORDER BY r.created_at DESC, r.id DESC + `, + [productId] + ); + + const [stats] = await db.query( + ` + SELECT + rating AS average_rating, + num_reviews AS review_count + FROM products + WHERE id = ? + LIMIT 1 + `, + [productId] + ); + + return res.status(200).json({ + success: true, + message: "Reviews fetched successfully", + averageRating: Number(stats?.[0]?.average_rating || 0), + reviewCount: Number(stats?.[0]?.review_count || 0), + reviews: safeArray(reviews).map((review) => new Review(review)) + }); + } catch (error) { + console.error("GET PRODUCT REVIEWS ERROR:", error); + + return res.status(500).json({ + success: false, + message: "Failed to fetch reviews" + }); + } +}; + +const createProductReview = async (req, res) => { + const productId = safeInteger(req.params.id); + const userId = safeInteger(req.user?.id); + const rating = safeInteger(req.body.rating); + const comment = sanitizeString(req.body.comment); + + if (!productId) { + return res.status(400).json({ + success: false, + message: "Invalid product ID" + }); + } + + if (!userId) { + return res.status(401).json({ + success: false, + message: "Authentication required" + }); + } + + if (rating < 1 || rating > 5) { + return res.status(400).json({ + success: false, + message: "Rating must be between 1 and 5" + }); + } + + if (!comment || comment.length < 3 || comment.length > 1000) { + return res.status(400).json({ + success: false, + message: "Review comment must be between 3 and 1000 characters" + }); + } + + const connection = await db.getConnection(); + + try { + await connection.beginTransaction(); + + const [products] = await connection.query( + "SELECT id FROM products WHERE id = ? LIMIT 1", + [productId] + ); + + if (!safeArray(products).length) { + await connection.rollback(); + + return res.status(404).json({ + success: false, + message: "Product not found" + }); + } + + const [existing] = await connection.query( + "SELECT id FROM reviews WHERE product_id = ? AND user_id = ? LIMIT 1", + [productId, userId] + ); + + if (safeArray(existing).length > 0) { + await connection.rollback(); + + return res.status(400).json({ + success: false, + message: "You have already reviewed this product" + }); + } + + const [result] = await connection.query( + ` + INSERT INTO reviews (product_id, user_id, rating, comment) + VALUES (?, ?, ?, ?) + `, + [productId, userId, rating, comment] + ); + + const stats = await refreshProductReviewStats(productId, connection); + + await connection.commit(); + + return res.status(201).json({ + success: true, + message: "Review submitted successfully", + reviewId: result.insertId, + ...stats + }); + } catch (error) { + await connection.rollback(); + console.error("CREATE PRODUCT REVIEW ERROR:", error); + + return res.status(500).json({ + success: false, + message: "Failed to submit review" + }); + } finally { + connection.release(); + } +}; + +const deleteProductReview = async (req, res) => { + const productId = safeInteger(req.params.id); + const reviewId = safeInteger(req.params.reviewId); + const connection = await db.getConnection(); + + if (!productId || !reviewId) { + connection.release(); + + return res.status(400).json({ + success: false, + message: "Invalid review request" + }); + } + + try { + await connection.beginTransaction(); + + const [result] = await connection.query( + "DELETE FROM reviews WHERE id = ? AND product_id = ?", + [reviewId, productId] + ); + + if (result.affectedRows === 0) { + await connection.rollback(); + + return res.status(404).json({ + success: false, + message: "Review not found" + }); + } + + const stats = await refreshProductReviewStats(productId, connection); + + await connection.commit(); + + return res.status(200).json({ + success: true, + message: "Review deleted successfully", + ...stats + }); + } catch (error) { + await connection.rollback(); + console.error("DELETE PRODUCT REVIEW ERROR:", error); + + return res.status(500).json({ + success: false, + message: "Failed to delete review" + }); + } finally { + connection.release(); + } +}; + +module.exports = { + getProductReviews, + createProductReview, + deleteProductReview +}; diff --git a/backend/models/Review.js b/backend/models/Review.js new file mode 100644 index 0000000..7f5d224 --- /dev/null +++ b/backend/models/Review.js @@ -0,0 +1,13 @@ +class Review { + constructor(review) { + this.id = review.id; + this.productId = review.product_id; + this.userId = review.user_id; + this.userName = review.user_name || review.name || ""; + this.rating = review.rating || 0; + this.comment = review.comment || ""; + this.createdAt = review.created_at || new Date(); + } +} + +module.exports = Review; diff --git a/backend/routes/chatRoutes.js b/backend/routes/chatRoutes.js index a061776..e750e97 100644 --- a/backend/routes/chatRoutes.js +++ b/backend/routes/chatRoutes.js @@ -1,6 +1,7 @@ const express = require("express"); const router = express.Router(); -const { authMiddleware, authorizeRoles } = require("../middleware/authMiddleware"); +const authMiddleware = require("../middleware/authMiddleware"); +const { authorizeRoles } = require("../middleware/rbacMiddleware"); const { getConversations, getConversationDetails, updateStatus, assignAdmin } = require("../controllers/chat.controller"); // Require auth for all chat routes diff --git a/backend/routes/productRoutes.js b/backend/routes/productRoutes.js index 034c277..edcbfe1 100644 --- a/backend/routes/productRoutes.js +++ b/backend/routes/productRoutes.js @@ -10,6 +10,12 @@ const { getProductSuggestions } = require("../controllers/productController"); +const { + getProductReviews, + createProductReview, + deleteProductReview +} = require("../controllers/reviewController"); + const authMiddleware = require("../middleware/authMiddleware"); const { authorizeRoles } = require("../middleware/rbacMiddleware"); const { validateCreateProduct, validateUpdateProduct } = require("../middleware/validators/productValidator"); @@ -35,12 +41,18 @@ router.get("/status/check", (req, res) => { router.get("/search-suggestions", getProductSuggestions); router.get("/", getProducts); +router.get("/:id/reviews", getProductReviews); +router.post("/:id/review", authMiddleware, createProductReview); +router.delete( + "/:id/reviews/:reviewId", + authMiddleware, + authorizeRoles("admin"), + deleteProductReview +); router.get("/:id", getSingleProduct); router.post("/", authMiddleware, authorizeRoles("admin"), validateCreateProduct, createProduct); - router.put("/:id", authMiddleware, authorizeRoles("admin"), validateUpdateProduct, updateProduct); - router.delete("/:id", authMiddleware, authorizeRoles("admin"), DeleteeProduct); // Fallback diff --git a/backend/schema.sql b/backend/schema.sql index dabb856..1c74f59 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -47,7 +47,7 @@ CREATE TABLE IF NOT EXISTS products ( stock INT DEFAULT 0, - image VARCHAR(500), + image TEXT, category VARCHAR(100), @@ -212,6 +212,42 @@ ON wishlist_items(user_id); CREATE INDEX idx_wishlist_items_product ON wishlist_items(product_id); +-- reviews table +CREATE TABLE IF NOT EXISTS reviews ( + id INT AUTO_INCREMENT PRIMARY KEY, + + product_id INT + NOT NULL, + + user_id INT + NOT NULL, + + rating TINYINT + NOT NULL, + + comment TEXT + NOT NULL, + + created_at TIMESTAMP + DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (product_id) + REFERENCES products(id) + ON DELETE CASCADE, + + FOREIGN KEY (user_id) + REFERENCES users(id) + ON DELETE CASCADE, + + CHECK (rating >= 1 AND rating <= 5) +); + +CREATE INDEX idx_reviews_product +ON reviews(product_id); + +CREATE INDEX idx_reviews_user +ON reviews(user_id); + -- user interactions table CREATE TABLE IF NOT EXISTS user_interactions ( id INT AUTO_INCREMENT PRIMARY KEY, diff --git a/backend/seedProducts.js b/backend/seedProducts.js new file mode 100644 index 0000000..eb5d288 --- /dev/null +++ b/backend/seedProducts.js @@ -0,0 +1,44 @@ +const mysql = require("mysql2/promise"); +require("dotenv").config(); + +const products = [ + { name: 'Classic Cotton T-Shirt', description: 'Summer collection soft cotton tee.', price: 19.99, image: '/assets/images/f1.jpg', category: 'T-Shirts', stock: 50, featured: 1}, + { name: 'Graphic Summer Tee', description: 'Vibrant graphic tee for summer.', price: 24.99, image: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRyY_WkHP0-WBtjcePjG1sLSoMouKgAaav1hg&s', category: 'T-Shirts', stock: 30, featured: 0}, + { name: 'Striped Casual Tee', description: 'Comfortable striped tee.', price: 21.99, image: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQtKfu-ZwefkG7NDs5d3PhgFBSgTHhEN01ENQ&s', category: 'T-Shirts', stock: 22, featured: 0}, + { name: 'V-Neck Tee', description: 'Soft v-neck t-shirt.', price: 17.99, image: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTsSS_5k6nLRteqeDYfhRBiA65nBAAXQA2Nwg&s', category: 'T-Shirts', stock: 18, featured: 0}, + { name: 'Pocket Tee', description: 'Casual pocket tee.', price: 18.99, image: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRuw0Ab_4tzp6NAd8VHYZzV9OYF59aklaeEeA&s', category: 'T-Shirts', stock: 12, featured: 0}, + + { name: 'Cozy Hoodie', description: 'Lightweight hoodie for cool evenings.', price: 39.99, image: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcSPkTgVrxAbFAb4VMnRmqKfLq1mlPwKCf3PQg&s', category: 'Hoodies', stock: 40, featured: 1}, + { name: 'Zip-Up Hoodie', description: 'Casual zip hoodie.', price: 44.99, image: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQEk5MDT4E_9vjAF7bci9TCMuPYw_yUrdJ1Gw&s', category: 'Hoodies', stock: 40, featured: 0}, + { name: 'Pullover Hoodie', description: 'Cozy pullover style.', price: 42.99, image: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTua6NALhSutNTSeAx3JEGPipEhDhEoUAoISw&s', category: 'Hoodies', stock: 28, featured: 0}, + { name: 'Fleece Hoodie', description: 'Warm fleece hoodie.', price: 49.99, image: '/assets/images/f2.jpg', category: 'Hoodies', stock: 14, featured: 0}, + { name: 'Sport Hoodie', description: 'Performance hoodie for workouts.', price: 46.99, image: '/assets/images/f3.jpg', category: 'Hoodies', stock: 32, featured: 0}, + + { name: 'Windbreaker Jacket', description: 'Water-resistant windbreaker.', price: 59.99, image: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRUk5pMwkIaq5m32eSfcLfuTTYXPd_gZxmDrg&s', category: 'Jackets', stock: 20, featured: 0}, + { name: 'Denim Jacket', description: 'Classic denim jacket.', price: 69.99, image: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcS742gOUs3PYj6pGiEL7vxtCYtcdntbkSzg1Q&s', category: 'Jackets', stock: 15, featured: 1}, + { name: 'Leather Jacket', description: 'Stylish faux-leather jacket.', price: 119.99, image: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcS3Zm904oGz9FyfDetC1LY41uK35Udmgl01bQ&s', category: 'Jackets', stock: 8, featured: 0}, + { name: 'Bomber Jacket', description: 'Classic bomber jacket.', price: 89.99, image: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTT_Z8bOxgvwY4ahNxGg4TkryUn2gowHQL51w&s', category: 'Jackets', stock: 11, featured: 0}, + { name: 'Denim Trucker', description: 'Lightweight trucker jacket.', price: 74.99, image: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQOazqR3Pt7KgK3iTwtReHvCLpQaIKhphOTjA&s', category: 'Jackets', stock: 6, featured: 0} +]; + +async function seed() { + const connection = await mysql.createConnection({ + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + port: 3306 + }); + + console.log("Connected to DB, inserting products..."); + for (const p of products) { + await connection.execute( + `INSERT INTO products (name, description, price, image, category, stock, featured) VALUES (?, ?, ?, ?, ?, ?, ?)`, + [p.name, p.description, p.price, p.image, p.category, p.stock, p.featured] + ); + } + console.log("Successfully seeded database with placeholder products!"); + await connection.end(); +} + +seed().catch(console.error); diff --git a/frontend/product.html b/frontend/product.html index 9b54e2a..7a778f0 100644 --- a/frontend/product.html +++ b/frontend/product.html @@ -673,49 +673,86 @@