diff --git a/.env.example b/.env.example deleted file mode 100644 index 42f97f1..0000000 --- a/.env.example +++ /dev/null @@ -1,53 +0,0 @@ -# ========================= -# SERVER CONFIG -# ========================= -PORT=5000 - -# ========================= -# DATABASE (MongoDB) -# ========================= -MONGODB_URI=mongodb://localhost:27017/your_database_name - -# ========================= -# JWT AUTH -# ========================= -JWT_SECRET=your_jwt_secret_key_here -JWT_EXPIRES_IN=7d - -# ========================= -# AWS S3 (File Uploads) -# ========================= -AWS_REGION=ap-southeast-1 -AWS_ACCESS_KEY_ID=your_aws_access_key -AWS_SECRET_ACCESS_KEY=your_aws_secret_key -AWS_S3_BUCKET_NAME=your_bucket_name - -# ========================= -# STRIPE (Payments - Global) -# ========================= -STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key -STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret - -# ========================= -# PAYMONGO (Philippines Payments) -# ========================= -PAYMONGO_SECRET_KEY=sk_test_your_paymongo_secret_key -PAYMONGO_PUBLIC_KEY=pk_test_your_paymongo_public_key - -# ========================= -# FRONTEND URL (CORS) -# ========================= -CLIENT_URL=http://localhost:3000 - -# ========================= -# OPTIONAL (Email service) -# ========================= -EMAIL_HOST=smtp.gmail.com -EMAIL_PORT=587 -EMAIL_USER=your_email@gmail.com -EMAIL_PASS=your_email_password - -# ========================= -# LOGGING -# ========================= -LOG_LEVEL=debug \ No newline at end of file diff --git a/config/cloudinary.js b/config/cloudinary.js new file mode 100644 index 0000000..876c89e --- /dev/null +++ b/config/cloudinary.js @@ -0,0 +1,9 @@ +const cloudinary = require('cloudinary').v2; + +cloudinary.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, +}); + +module.exports = cloudinary; \ No newline at end of file diff --git a/config/db.js b/config/db.js index 91826b0..ea21b08 100644 --- a/config/db.js +++ b/config/db.js @@ -2,7 +2,7 @@ const mongoose = require('mongoose'); const connectDB = async () => { try { - const mongoURI = process.env.MONGO_URI || 'mongodb://127.0.0.1:27017/artisan_hub'; + const mongoURI = process.env.MONGO_URI || 'mongodb://localhost:27017/artisan_hub'; await mongoose.connect(mongoURI); console.log('✅ MongoDB Connected'); } catch (err) { @@ -11,4 +11,4 @@ const connectDB = async () => { } }; -module.exports = connectDB; +module.exports = connectDB; \ No newline at end of file diff --git a/middleware/.gitkeep b/middleware/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/middleware/auth.js b/middleware/auth.js new file mode 100644 index 0000000..5f12185 --- /dev/null +++ b/middleware/auth.js @@ -0,0 +1,231 @@ +// TODO: Implement auth middleware + +const jwt = require('jsonwebtoken'); +const Shop = require('../modules/shops/shop.model'); // adjust path if needed +const Order = require('../modules/orders/order.model'); // adjust if existsProduct is not defined +const Product = require('../modules/products/product.model'); +/** + * verifyToken + * Checks Authorization: Bearer , decodes it, + * and attaches { id, role } to req.user. + * Usage: router.get('/protected', verifyToken, handler) + */ +const verifyToken = (req, res, next) => { + try { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ + status: 'error', + message: 'No token provided' + }); + } + + const token = authHeader.split(' ')[1]; + + const decoded = jwt.verify(token, process.env.JWT_SECRET); + + req.user = { + id: decoded.id, + role: decoded.role + }; + + next(); + + } catch (error) { + return res.status(401).json({ + status: 'error', + message: 'Invalid or expired token' + }); + } +}; + +/** + * requireRole(...roles) + * Factory — checks req.user.role against the allowed list. + * Usage: router.get('/admin', verifyToken, requireRole('admin'), handler) + */ +const requireRole = (...roles) => { + return (req, res, next) => { + if (!req.user) { + return res.status(401).json({ + status: `error ${req.user}`, + message: 'Unauthorized' + }); + } + + if (!roles.includes(req.user.role)) { + return res.status(403).json({ + status: 'error', + message: 'Forbidden: insufficient permissions' + }); + } + + next(); + }; +}; + +/** + * isShopOwner + * Confirms shop.owner === req.user.id before allowing mutations. + * Expects :shopId or :id param that resolves to a Shop document. + */ +const isShopOwner = async (req, res, next) => { + try { + const shopId = req.params.shopId || req.params.id; + + if (!shopId) { + return res.status(400).json({ + status: 'error', + message: 'Shop ID is required' + }); + } + + const shop = await Shop.findById(shopId); + + if (!shop) { + return res.status(404).json({ + status: 'error', + message: 'Shop not found' + }); + } + + if (shop.owner.toString() !== req.user.id) { + return res.status(403).json({ + status: 'error', + message: 'You are not the owner of this shop' + }); + } + + req.shop = shop; + next(); + + } catch (error) { + next(error); + } +}; + +const isProductOwner = async (req, res, next) => { + try { + const { id } = req.params; + + const product = await Product.findById(id); + + if (!product) { + return res.status(404).json({ + status: 'error', + message: 'Product not found' + }); + } + + const shop = await Shop.findById(product.shop); + + if (!shop) { + return res.status(404).json({ + status: 'error', + message: 'Shop not found' + }); + } + + if (shop.owner.toString() !== req.user.id) { + return res.status(403).json({ + status: 'error', + message: 'You are not the owner of this product' + }); + } + + req.product = product; + req.shop = shop; + + next(); + + } catch (error) { + next(error); + } +}; + +/** + * isOrderOwner + * Confirms order.buyer === req.user.id (buyer access) + * OR order.shop.owner === req.user.id (seller access). + */ +const isOrderOwner = async (req, res, next) => { + try { + const orderId = req.params.id; + + const order = await Order.findById(orderId).populate('shop'); + + if (!order) { + return res.status(404).json({ + status: 'error', + message: 'Order not found' + }); + } + + const isBuyer = order.buyer.toString() === req.user.id; + const isSeller = order.shop.owner.toString() === req.user.id; + + if (!isBuyer && !isSeller) { + return res.status(403).json({ + status: 'error', + message: 'Access denied' + }); + } + + req.order = order; + next(); + + } catch (error) { + next(error); + } +}; + + +/** + * verifyWebhookSignature + * Validates the PayMongo webhook signature header + * using PAYMONGO_WEBHOOK_SECRET from env. + */ +const verifyWebhookSignature = (req, res, next) => { + try { + const signature = req.headers['paymongo-signature']; + + if (!signature) { + return res.status(401).json({ + status: 'error', + message: 'Missing webhook signature' + }); + } + + const expected = process.env.PAYMONGO_WEBHOOK_SECRET; + + if (!expected) { + return res.status(500).json({ + status: 'error', + message: 'Webhook secret not configured' + }); + } + + // Simple validation (depends on PayMongo format) + if (signature !== expected) { + return res.status(401).json({ + status: 'error', + message: 'Invalid webhook signature' + }); + } + + next(); + + } catch (error) { + next(error); + } +}; + +module.exports = { + verifyToken, + requireRole, + isShopOwner, + isOrderOwner, + verifyWebhookSignature, + isProductOwner, +}; diff --git a/middleware/multer.js b/middleware/multer.js new file mode 100644 index 0000000..020ec9d --- /dev/null +++ b/middleware/multer.js @@ -0,0 +1,14 @@ +const multer = require('multer'); + +const storage = multer.diskStorage({ + destination: function (req, file, cb) { + cb(null, 'uploads/'); + }, + filename: function (req, file, cb) { + cb(null, Date.now() + '-' + file.originalname); + } +}); + +const upload = multer({ storage }); + +module.exports = upload; \ No newline at end of file diff --git a/modules/admin/admin.controller.js b/modules/admin/admin.controller.js new file mode 100644 index 0000000..70a92e8 --- /dev/null +++ b/modules/admin/admin.controller.js @@ -0,0 +1,173 @@ +const User = require('../users/user.model'); +const Shop = require('../shops/shop.model'); +const Order = require('../orders/order.model'); + + +// TODO: Implement admin controller +// Planned handlers: +// getStats — aggregate: total users, sellers, active shops, total orders, gross revenue +// getUsers — list all users (paginated) +// toggleUser — flip user.isActive true/false +// getShops — list all shops (paginated) +// toggleShop — flip shop.isActive (removes from / restores to map) +// getAllOrders — read-only paginated order ledger across all buyers/sellers + +const getStats = async (req, res, next) => { + try { + // 1. TOTAL USERS + const users = await User.countDocuments(); + + // 2. TOTAL SHOPS + const shops = await Shop.countDocuments(); + + // 3. TOTAL ORDERS + const orders = await Order.countDocuments(); + + // 4. REVENUE (only delivered orders) + const deliveredOrders = await Order.find({ + status: 'delivered' + }); + + const revenue = deliveredOrders.reduce((sum, order) => { + return sum + (order.total || 0); + }, 0); + + // 5. RESPONSE + res.status(200).json({ + status: 'success', + data: { + users, + shops, + orders, + revenue + } + }); + + } catch (error) { + next(error); + } +}; + +const getUsers = async (req, res, next) => { + try { + const users = await User.find() + .select('-password') + .sort({ createdAt: -1 }); + + res.status(200).json({ + status: 'success', + count: users.length, + data: users + }); + + } catch (error) { + next(error); + } +}; + +const toggleUser = async (req, res, next) => { + try { + const { id } = req.params; + + const user = await User.findById(id); + + if (!user) { + return res.status(404).json({ + status: 'error', + message: 'User not found' + }); + } + + // toggle status + user.isActive = !user.isActive; + + await user.save(); + + res.status(200).json({ + status: 'success', + message: `User has been ${user.isActive ? 'activated' : 'suspended'}`, + data: { + id: user._id, + isActive: user.isActive + } + }); + + } catch (error) { + next(error); + } +}; + +const getShops = async (req, res, next) => { + try { + const shops = await Shop.find() + .populate('owner', 'name email role') // show who owns the shop + .sort({ createdAt: -1 }); + + res.status(200).json({ + status: 'success', + count: shops.length, + data: shops + }); + + } catch (error) { + next(error); + } +}; + +const toggleShop = async (req, res, next) => { + try { + const { id } = req.params; + + const shop = await Shop.findById(id); + + if (!shop) { + return res.status(404).json({ + status: 'error', + message: 'Shop not found' + }); + } + + // toggle status + shop.isActive = !shop.isActive; + + await shop.save(); + + res.status(200).json({ + status: 'success', + message: `Shop has been ${shop.isActive ? 'activated' : 'hidden'}`, + data: { + id: shop._id, + isActive: shop.isActive + } + }); + + } catch (error) { + next(error); + } +}; + +const getOrders = async (req, res, next) => { + try { + const orders = await Order.find() + .populate('buyer', 'name email') + .populate('shop', 'name owner') + .sort({ createdAt: -1 }); + + res.status(200).json({ + status: 'success', + count: orders.length, + data: orders + }); + + } catch (error) { + next(error); + } +}; +module.exports = { + getStats, + getUsers, + toggleUser, + getShops, + toggleShop, + getOrders +}; diff --git a/modules/admin/admin.routes.js b/modules/admin/admin.routes.js new file mode 100644 index 0000000..df41408 --- /dev/null +++ b/modules/admin/admin.routes.js @@ -0,0 +1,36 @@ +// TODO: Implement admin routes +const express = require('express'); +const router = express.Router(); + +const { verifyToken, requireRole } = require('../../middleware/auth'); +const { getStats,getUsers,toggleUser,getShops,toggleShop,getOrders } = require('./admin.controller'); +// All routes require: verifyToken + requireRole('admin') + +// GET /api/admin/stats — dashboard: total users, shops, orders, revenue +// GET /api/admin/users — list all users +// PUT /api/admin/users/:id/toggle — toggle user isActive (suspend / unsuspend) +// GET /api/admin/shops — list all shops +// PUT /api/admin/shops/:id/toggle — toggle shop isActive (show / hide from map) +// GET /api/admin/orders — read-only ledger of all orders + + +// GET /api/admin/stats — dashboard: total users, shops, orders, revenue +router.get('/stats',verifyToken,requireRole('admin'),getStats); + +// GET /api/admin/users — list all users +router.get('/users',verifyToken,requireRole('admin'),getUsers); + +// PUT /api/admin/users/:id/toggle — toggle user isActive (suspend / unsuspend) +router.put('/users/:id/toggle',verifyToken,requireRole('admin'),toggleUser); + +// GET /api/admin/shops — list all shops +router.get('/shops',verifyToken,requireRole('admin'),getShops); + +// PUT /api/admin/shops/:id/toggle — toggle shop isActive (show / hide from map) +router.put('/shops/:id/toggle',verifyToken,requireRole('admin'),toggleShop); + +// GET /api/admin/orders — read-only ledger of all orders +router.get('/orders',verifyToken,requireRole('admin'),getOrders); + + +module.exports = router; diff --git a/modules/auth/.gitkeep b/modules/auth/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/modules/auth/auth.controller.js b/modules/auth/auth.controller.js new file mode 100644 index 0000000..949674d --- /dev/null +++ b/modules/auth/auth.controller.js @@ -0,0 +1,186 @@ +// TODO: Implement auth controller +// Planned handlers: +// register — hash password (bcrypt), create user with role, return JWT (okay na to) +// login — compare hash, check isActive, return JWT + user object +// getMe — decode token, return current user (used to restore session on app load) + +const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); +const User = require('../users/user.model'); +const { uploadImage } = require('../../services/uploader'); + + +// register the user +const register = async (req, res, next) => { + try { + const { + name, + email, + phone, + password, + role + } = req.body; + + // Validation + if (!name || !email || !password) { + return res.status(400).json({ + status: 'error', + message: 'Name, email, and password are required' + }); + } + + let avatarUrl = ''; + + if (req.file) { + const uploadedImage = await uploadImage(req.file.path, 'users'); + avatarUrl = uploadedImage.secure_url; + } + + // Check existing user (bawal ang dalawa yung email ofc) + const existingUser = await User.findOne({ email }); + + if (existingUser) { + return res.status(400).json({ + status: 'error', + message: 'Email already registered' + }); + } + + // Hash password plus yung bcrypt + const hashedPassword = await bcrypt.hash(password, 10); + + // Create user + const user = await User.create({ + name, + email, + phone, + password: hashedPassword, + avatarUrl, + role: role || 'buyer' + }); + + // Generate ng JWtoken Mag expire ng 7days + const token = jwt.sign( + { + id: user._id, + role: user.role + }, + process.env.JWT_SECRET, + { + expiresIn: process.env.JWT_EXPIRES_IN + } + ); + + res.status(201).json({ + status: 'success', + message: 'Registration successful', + token, + user + }); + + } catch (error) { + next(error); + } +}; + +// const login = async (req , res, next)=>{ +// const {username , password} = req.body; + +// if(!username || !password){ +// return res.status(400).json({ +// status : "error", +// message : "Username and password is required" +// }); +// } + +// }; +const login = async (req, res, next) => { + try { + const { email, password } = req.body; + + // Validation + if (!email || !password) { + return res.status(400).json({ + status: 'error', + message: 'Email and password are required' + }); + } + + // Find user + const user = await User.findOne({ email }).select('+password'); + + if (!user) { + return res.status(400).json({ + status: 'error', + message: 'Invalid email or password' + }); + } + + // Check if user is active + if (!user.isActive) { + return res.status(403).json({ + status: 'error', + message: 'Account is disabled' + }); + } + + // Compare password or verify nalang sana kaso naka native yung bcrypt + const isMatch = await bcrypt.compare(password, user.password); + + if (!isMatch) { + return res.status(400).json({ + status: 'error', + message: 'Invalid email or password' + }); + } + + // Create token + const token = jwt.sign( + { + id: user._id, + role: user.role + }, + process.env.JWT_SECRET, + { + expiresIn: process.env.JWT_EXPIRES_IN || '7d' + } + ); + + res.status(200).json({ + status: 'success', + message: 'Login successful', + token, + user + }); + + } catch (error) { + next(error); + } +}; + + +const getMe = async (req, res, next) => { + try { + const user = await User.findById(req.user.id); + + if (!user) { + return res.status(404).json({ + status: 'error', + message: 'User not found' + }); + } + + res.status(200).json({ + status: 'success', + user + }); + + } catch (error) { + next(error); + } +}; +module.exports = { + register, + login, + getMe +}; diff --git a/modules/auth/auth.model.js b/modules/auth/auth.model.js new file mode 100644 index 0000000..beda185 --- /dev/null +++ b/modules/auth/auth.model.js @@ -0,0 +1,6 @@ +// RefreshToken model removed for MVP. +// Using a single long-lived JWT (7d expiry) — sufficient for a demo/competition context. +// Re-add refresh token rotation post-competition when security hardening begins. + +// Placeholder to preserve the module folder structure. +module.exports = {}; diff --git a/modules/auth/auth.routes.js b/modules/auth/auth.routes.js new file mode 100644 index 0000000..82db909 --- /dev/null +++ b/modules/auth/auth.routes.js @@ -0,0 +1,42 @@ +// TODO: Implement auth routes +const express = require('express'); +const router = express.Router(); +const upload = require('../../middleware/multer'); +const { verifyToken } = require('../../middleware/auth') +// POST /api/auth/register — create account, assign role, return JWT +// POST /api/auth/login — verify credentials, return JWT + user object +// GET /api/auth/me — protected: return current user from token (session restore) + + +const { + register, + login, + getMe +} = require('./auth.controller'); + +// POST /api/auth/register +router.post('/register',upload.single('avatarUrl'), register); +// Example payload { +// "name": "Juan Dela Cruz", +// "email": "juan@example.com", +// "avatarUrl" : +// "phone": "09123456789", +// "password": "password123", +// "role": "seller" +// } + + + +// POST /api/auth/login +router.post('/login', login); +// Example Payload{ +// "email": "juan@example.com", +// "password": "password123" +// } + + +// GET /api/auth/me +router.get('/me',verifyToken, getMe); + + +module.exports = router; diff --git a/modules/orders/order.controller.js b/modules/orders/order.controller.js new file mode 100644 index 0000000..8593df3 --- /dev/null +++ b/modules/orders/order.controller.js @@ -0,0 +1,336 @@ +// TODO: Implement orders controller +// Planned handlers: +// placeOrder — buyer: create order from cart items (snapshot name+price), compute total +// getMyOrders — buyer: own order history +// getShopOrders — seller: list incoming orders for their shop +// confirmOrder — seller: pending → confirmed +// shipOrder — seller: confirmed → shipped (optionally save trackingNumber + courier) +// deliverOrder — seller: shipped → delivered (marks as complete) +// cancelOrder — buyer or seller: any pre-shipped status → cancelled + +const Order = require('./order.model'); +const Product = require('../products/product.model'); + + + + +const placeOrder = async (req, res, next) => { + try { + const { + shopId, + items, + deliveryAddress, + deliveryNotes, + shippingFee = 0, + paymentMethod = 'cod' + } = req.body; + + if (!shopId || !items || items.length === 0) { + return res.status(400).json({ + status: 'error', + message: 'Shop and items are required' + }); + } + + let total = 0; + const orderItems = []; + + for (const item of items) { + const product = await Product.findById(item.productId); + + if (!product) { + return res.status(404).json({ + status: 'error', + message: `Product not found: ${item.productId}` + }); + } + + const itemTotal = product.price * item.quantity; + total += itemTotal; + + orderItems.push({ + product: product._id, + name: product.name, + price: product.price, + quantity: item.quantity, + color: item.color, + size: item.size + }); + } + + total += shippingFee; + + const order = await Order.create({ + buyer: req.user.id, + shop: shopId, + items: orderItems, + total, + deliveryAddress, + deliveryNotes, + shippingFee, + paymentMethod + }); + + res.status(201).json({ + status: 'success', + message: 'Order created successfully', + data: order + }); + + } catch (error) { + next(error); + } +}; + + + + + +const getMyOrders = async (req, res, next) => { + try { + + const orders = await Order.find({ + buyer: req.user.id + }) + .populate('shop', 'name') + .sort({ createdAt: -1 }); + + res.status(200).json({ + status: 'success', + count: orders.length, + data: orders + }); + + } catch (error) { + next(error); + } +}; + + + + + +const getShopOrders = async (req, res, next) => { + try { + const { id } = req.params; + + const orders = await Order.find({ shop: id }) + .populate('buyer', 'name email') + .populate('items.product', 'name price imageUrl') + .sort({ createdAt: -1 }); + + res.status(200).json({ + status: 'success', + count: orders.length, + data: orders + }); + + } catch (error) { + next(error); + } + + +}; + + + + + +const confirmOrder = async (req, res, next) => { + try { + const { id } = req.params; + + const order = await Order.findById(id).populate('shop'); + + if (!order) { + return res.status(404).json({ + status: 'error', + message: 'Order not found' + }); + } + + // ensure seller owns the shop + if (order.shop.owner.toString() !== req.user.id) { + return res.status(403).json({ + status: 'error', + message: 'Not authorized to confirm this order' + }); + } + + // status validation + if (order.status !== 'pending') { + return res.status(400).json({ + status: 'error', + message: `Cannot confirm order with status: ${order.status}` + }); + } + + order.status = 'confirmed'; + await order.save(); + + res.status(200).json({ + status: 'success', + message: 'Order confirmed successfully', + data: order + }); + + } catch (error) { + next(error); + } +}; + + + + + +const shipOrder = async (req, res, next) => { + try { + const { id } = req.params; + const { trackingNumber, courier } = req.body; + + if (!trackingNumber || !courier) { + return res.status(400).json({ + status: 'error', + message: 'Tracking number and courier are required' + }); + } + + const order = await Order.findById(id).populate('shop'); + + if (!order) { + return res.status(404).json({ + status: 'error', + message: 'Order not found' + }); + } + + // check ownership + if (order.shop.owner.toString() !== req.user.id) { + return res.status(403).json({ + status: 'error', + message: 'Not authorized to ship this order' + }); + } + + // status validation + if (order.status !== 'confirmed') { + return res.status(400).json({ + status: 'error', + message: `Order must be confirmed before shipping (current: ${order.status})` + }); + } + + order.status = 'shipped'; + order.trackingNumber = trackingNumber; + order.courier = courier; + + await order.save(); + + res.status(200).json({ + status: 'success', + message: 'Order marked as shipped', + data: order + }); + + } catch (error) { + next(error); + } +}; + +const deliverOrder = async (req, res, next) => { + try { + const { id } = req.params; + + const order = await Order.findById(id).populate('shop'); + + if (!order) { + return res.status(404).json({ + status: 'error', + message: 'Order not found' + }); + } + + // check ownership (seller must own shop) + if (order.shop.owner.toString() !== req.user.id) { + return res.status(403).json({ + status: 'error', + message: 'Not authorized to deliver this order' + }); + } + + // validate current status + if (order.status !== 'shipped') { + return res.status(400).json({ + status: 'error', + message: `Order must be shipped before delivery (current: ${order.status})` + }); + } + + order.status = 'delivered'; + + await order.save(); + + res.status(200).json({ + status: 'success', + message: 'Order marked as delivered', + data: order + }); + + } catch (error) { + next(error); + } +}; +const cancelOrder = async (req, res, next) => { + try { + const { id } = req.params; + + const order = await Order.findById(id).populate('shop'); + + if (!order) { + return res.status(404).json({ + status: 'error', + message: 'Order not found' + }); + } + + const isBuyer = order.buyer.toString() === req.user.id; + const isSeller = order.shop.owner.toString() === req.user.id; + + if (!isBuyer && !isSeller) { + return res.status(403).json({ + status: 'error', + message: 'Not authorized to cancel this order' + }); + } + + // prevent cancel after shipping + if (order.status === 'shipped' || order.status === 'delivered') { + return res.status(400).json({ + status: 'error', + message: `Cannot cancel order that is already ${order.status}` + }); + } + + if (order.status === 'cancelled') { + return res.status(400).json({ + status: 'error', + message: 'Order is already cancelled' + }); + } + + order.status = 'cancelled'; + + await order.save(); + + res.status(200).json({ + status: 'success', + message: 'Order cancelled successfully', + data: order + }); + + } catch (error) { + next(error); + } +}; + +module.exports = { placeOrder,getMyOrders, getShopOrders,confirmOrder,shipOrder,deliverOrder,cancelOrder }; diff --git a/modules/orders/order.model.js b/modules/orders/order.model.js new file mode 100644 index 0000000..fe38f6d --- /dev/null +++ b/modules/orders/order.model.js @@ -0,0 +1,62 @@ +const mongoose = require('mongoose'); + +// ── Item snapshot — price locked at time of order ───────────────────────── +const orderItemSchema = new mongoose.Schema( + { + product: { type: mongoose.Schema.Types.ObjectId, ref: 'Product', required: true }, + name: { type: String, required: true }, // snapshot — safe against product edits + price: { type: Number, required: true }, // centavos snapshot + quantity: { type: Number, required: true, min: 1 }, + // ── Variant selections (optional — not all products have them) ─ + color: { type: String }, // e.g. 'Natural Ochre' + size: { type: String }, // e.g. 'M / 80×120cm' + }, + { _id: false } +); + +const orderSchema = new mongoose.Schema( + { + // ── Parties ─────────────────────────────────────────────── + buyer: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true, index: true }, + shop: { type: mongoose.Schema.Types.ObjectId, ref: 'Shop', required: true }, + + // ── Items & Total ───────────────────────────────────────── + items: [orderItemSchema], + total: { type: Number, required: true }, // centavos — sum of (price × qty) + shipping + fees + + // ── Delivery ────────────────────────────────────────────── + deliveryAddress: { type: String, required: true }, + deliveryNotes: { type: String }, // buyer's optional note to courier + shippingFee: { type: Number, default: 0 }, // centavos — locked at time of order + + // Optional — seller fills in when handing off to a courier + trackingNumber: { type: String }, + courier: { type: String }, // e.g. 'LBC', 'J&T', 'Ninja Van', '2GO' + + // ── Payment ─────────────────────────────────────────────── + paymentMethod: { + type: String, + // 'cod' → Cash on Delivery + // 'card' → Credit/Debit via PayMongo card intent + // 'gcash' → GCash via PayMongo e-wallet + // 'paymongo' → generic PayMongo link (legacy / fallback) + enum: ['cod', 'card', 'gcash', 'paymongo'], + default: 'cod', + }, + paymentId: { type: String }, // PayMongo payment/intent ID (null for COD) + + // ── Status Flow ─────────────────────────────────────────── + // COD: pending → confirmed → shipped → delivered | cancelled + // Online: pending → confirmed (via webhook) → shipped → delivered | cancelled + status: { + type: String, + enum: ['pending', 'confirmed', 'shipped', 'delivered', 'cancelled'], + default: 'pending', + }, + }, + { + timestamps: true, + } +); + +module.exports = mongoose.model('Order', orderSchema); diff --git a/modules/orders/order.routes.js b/modules/orders/order.routes.js new file mode 100644 index 0000000..16695e0 --- /dev/null +++ b/modules/orders/order.routes.js @@ -0,0 +1,74 @@ +// TODO: Implement orders routes +const express = require('express'); +const router = express.Router(); +const { verifyToken, requireRole,isShopOwner } = require('../../middleware/auth'); +const { placeOrder,getMyOrders,getShopOrders,confirmOrder,shipOrder,deliverOrder,cancelOrder } = require('./order.controller'); +// Middleware: verifyToken, requireRole, isOrderOwner + +// POST /api/orders — buyer: place order (snapshot items, compute total) +// GET /api/orders/my — buyer: own order history +// GET /api/shops/:id/orders — seller: incoming orders for their shop +// PUT /api/orders/:id/confirm — seller: pending → confirmed +// PUT /api/orders/:id/ship — seller: confirmed → shipped (body: { trackingNumber, courier }) +// PUT /api/orders/:id/deliver — seller: shipped → delivered +// PUT /api/orders/:id/cancel — buyer or seller: cancel (only pre-shipped) + + + + + +router.post('/', verifyToken , requireRole('buyer'), placeOrder); +// payload example +// { +// "shopId": "6a321e72c43e01b6215a55ad", +// "items": [ +// { +// "productId": "6a2f006e4a3ebb1aa8f276c7", +// "quantity": 2, +// "color": "Brown", +// "size": "M" +// } +// ], +// "deliveryAddress": "Quiapo, Manila", +// "deliveryNotes": "Call me before delivery", +// "shippingFee": 50, +// "paymentMethod": "gcash" +// } + +// GET /api/orders/my — buyer: own order history +router.get('/my',verifyToken,requireRole('buyer'),getMyOrders); +// http://localhost:5000/api/order/my nuyer only + + + + +// GET /api/shops/:id/orders — seller: incoming orders for their shop +router.get('/shops/:id/orders',verifyToken,requireRole('seller'),isShopOwner,getShopOrders); +// example http://localhost:5000/api/order/shops/6a3213b4152aaded1ac9a0ff/orders this should be seller + + + +// PUT /api/orders/:id/confirm — seller: pending → confirmed +router.put('/:id/confirm',verifyToken,requireRole('seller'),confirmOrder); +// GET http://localhost:5000/api/order/shops/6a3213b4152aaded1ac9a0ff/orders + + +// PUT /api/orders/:id/ship — seller: confirmed → shipped (body: { trackingNumber, courier }) +router.put('/:id/ship',verifyToken,requireRole('seller'),shipOrder); +// example payoad { +// "trackingNumber": "LBC123456789", +// "courier": "LBC" +// } + +// PUT /api/orders/:id/deliver — seller: shipped → delivered +router.put('/:id/deliver',verifyToken,requireRole('seller'),deliverOrder); + + + +// PUT /api/orders/:id/cancel — buyer or seller: cancel (only pre-shipped) +router.put('/:id/cancel', verifyToken, cancelOrder); +// exampel http://localhost:5000/api/orders/6a32c0167379408d95ee4e8f/cancel + + + +module.exports = router; \ No newline at end of file diff --git a/modules/payments/.gitkeep b/modules/payments/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/modules/payments/payment.controller.js b/modules/payments/payment.controller.js new file mode 100644 index 0000000..2761337 --- /dev/null +++ b/modules/payments/payment.controller.js @@ -0,0 +1,192 @@ +const Payment = require('./payment.model'); +const Order = require('../orders/order.model'); +const User = require('../users/user.model'); +const paymongo = require('../../services/paymongo'); + + +// ───────────────────────────────────────────── +// CREATE CHECKOUT SESSION (PayMongo) +// ───────────────────────────────────────────── +const createCheckout = async (req, res, next) => { + try { + const { orderId } = req.body; + + if (!orderId) { + return res.status(400).json({ + status: 'error', + message: 'orderId is required' + }); + } + + // 1. Find order + const order = await Order.findById(orderId).populate('items.product'); + + if (!order) { + return res.status(404).json({ + status: 'error', + message: 'Order not found' + }); + } + + // 2. Ensure buyer owns the order + if (order.buyer.toString() !== req.user.id) { + return res.status(403).json({ + status: 'error', + message: 'Not allowed to pay for this order' + }); + } + + // 3. Get user + const user = await User.findById(req.user.id); + + if (!user) { + return res.status(404).json({ + status: 'error', + message: 'User not found' + }); + } + + // 4. Create PayMongo checkout session + const response = await paymongo.post('/checkout_sessions', { + data: { + attributes: { + billing: { + name: user.name, + email: user.email + }, + + line_items: order.items.map(item => ({ + name: item.name, + quantity: item.quantity, + amount: Math.round(item.price), // already centavos + currency: 'PHP' + })), + + payment_method_types: ['gcash', 'card'], + + description: `Payment for Order ${order._id}`, + + success_url: 'http://localhost:3000/success', + cancel_url: 'http://localhost:3000/cancel' + } + } + }); + + const checkoutSession = response.data.data; + + const checkoutUrl = checkoutSession.attributes.checkout_url; + const linkId = checkoutSession.id; + + // 5. Save payment record + const payment = await Payment.create({ + order: order._id, + buyer: req.user.id, + paymongoLinkId: linkId, + checkoutUrl, + amount: order.total, + status: 'pending' + }); + + return res.status(201).json({ + status: 'success', + message: 'Checkout session created successfully', + data: { + checkoutUrl, + payment + } + }); + + } catch (error) { + next(error); + } +}; + + +// ───────────────────────────────────────────── +// GET PAYMENT STATUS +// ───────────────────────────────────────────── +const getPaymentStatus = async (req, res, next) => { + try { + const { orderId } = req.params; + + const payment = await Payment.findOne({ order: orderId }); + + if (!payment) { + return res.status(404).json({ + status: 'error', + message: 'Payment not found' + }); + } + + res.status(200).json({ + status: 'success', + data: payment + }); + + } catch (error) { + next(error); + } +}; + + +// ───────────────────────────────────────────── +// PAYMONGO WEBHOOK (SECURE + SAFE) +// ───────────────────────────────────────────── +const paymongoWebhook = async (req, res) => { + try { + const event = req.body; + + const eventType = event?.data?.attributes?.type; + + if (eventType === 'checkout_session.payment.paid') { + + const sessionData = event.data.attributes.data; + + const linkId = + sessionData?.id || + sessionData?.attributes?.id; + + if (!linkId) { + return res.sendStatus(400); + } + + const payment = await Payment.findOne({ + paymongoLinkId: linkId + }); + + if (!payment) return res.sendStatus(404); + + + if (payment.status === 'paid') { + return res.sendStatus(200); + } + + payment.status = 'paid'; + payment.paidAt = new Date(); + payment.webhookPayload = event; + + await payment.save(); + + // update order only once + await Order.findByIdAndUpdate(payment.order, { + status: 'confirmed' + }); + } + + return res.sendStatus(200); + + } catch (error) { + console.error(error); + return res.sendStatus(500); + } +}; + + +// ───────────────────────────────────────────── +// EXPORTS +// ───────────────────────────────────────────── +module.exports = { + createCheckout, + getPaymentStatus, + paymongoWebhook +}; \ No newline at end of file diff --git a/modules/payments/payment.model.js b/modules/payments/payment.model.js new file mode 100644 index 0000000..1b35cb4 --- /dev/null +++ b/modules/payments/payment.model.js @@ -0,0 +1,31 @@ +const mongoose = require('mongoose'); + +/** + * Tracks PayMongo payment links. + * COD orders do NOT create a Payment document — the order alone is sufficient. + */ +const paymentSchema = new mongoose.Schema( + { + order: { type: mongoose.Schema.Types.ObjectId, ref: 'Order', required: true, unique: true }, + buyer: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, + + // ── PayMongo fields ─────────────────────────────────────── + paymongoLinkId: { type: String }, // links.id from PayMongo API response + checkoutUrl: { type: String }, // redirect buyer here to pay + amount: { type: Number }, // centavos + status: { + type: String, + enum: ['pending', 'paid', 'failed'], + default: 'pending', + }, + paidAt: { type: Date }, + + // Raw webhook payload stored for audit / debugging + webhookPayload: { type: mongoose.Schema.Types.Mixed, select: false }, + }, + { + timestamps: true, + } +); + +module.exports = mongoose.model('Payment', paymentSchema); diff --git a/modules/payments/payment.routes.js b/modules/payments/payment.routes.js new file mode 100644 index 0000000..9f95713 --- /dev/null +++ b/modules/payments/payment.routes.js @@ -0,0 +1,33 @@ +// TODO: Implement payments routes +const express = require('express'); +const router = express.Router(); + +// POST /api/payments/create-link — buyer: create PayMongo payment link for an order +// returns { checkoutUrl } → frontend redirects buyer +// POST /api/payments/webhook — PayMongo webhook: payment.paid event → order status: confirmed +// requires verifyWebhookSignature middleware +// GET /api/payments/:orderId/status — buyer: check current payment status for an order + + + + + +const { + createCheckout, + getPaymentStatus, + paymongoWebhook +} = require('./payment.controller'); + +const { verifyToken } = require('../../middleware/auth'); + +// create payment link +router.post('/create-link', verifyToken, createCheckout); + +// check payment status +router.get('/:orderId/status', verifyToken, getPaymentStatus); + +// webhook (NO auth middleware) +router.post('/webhook', paymongoWebhook); + +module.exports = router; + diff --git a/modules/products/product.controller.js b/modules/products/product.controller.js new file mode 100644 index 0000000..ba8f311 --- /dev/null +++ b/modules/products/product.controller.js @@ -0,0 +1,211 @@ +// TODO: Implement products controller +// Planned handlers: +// getProducts — browse products (category, price, search, pagination) +// getProductBySlug — single product detail +// createProduct — seller: add product to catalog +// updateProduct — seller/owner: edit product +// deleteProduct — seller / admin: remove product +// moderateProduct — admin: approve or reject product listing + +const Product = require('./product.model'); +const Shop = require('../shops/shop.model'); // adjust path if needed + +const { uploadImage } = require('../../services/uploader'); + +const createProduct = async (req, res, next) => { + try { + const { shopId } = req.params; + + const { + name, + description, + price, + category, + colors, + sizes, + stockQuantity + } = req.body; + + if (!name || price === undefined) { + return res.status(400).json({ + status: 'error', + message: 'Name and price are required' + }); + } + + let imageUrl = ''; + + // catch by multer + if (req.file) { + const uploadedImage = await uploadImage(req.file.path, 'products'); + imageUrl = uploadedImage.secure_url; + } + + const product = await Product.create({ + shop: shopId, + name, + description, + price, + category, + imageUrl, + colors, + sizes, + stockQuantity: stockQuantity || 0, + inStock: (stockQuantity || 0) > 0 + }); + + res.status(201).json({ + status: 'success', + message: 'Product created successfully', + data: product + }); + + } catch (error) { + next(error); + } +}; +const getShopProducts = async (req, res, next) => { + try { + const { shopId } = req.params; + + const products = await Product.find({ shop: shopId }); + + res.status(200).json({ + status: 'success', + results: products.length, + data: products + }); + + } catch (error) { + next(error); + } +}; + +const getProductBySlug = async (req, res, next) => { + try { + const { id } = req.params; + + const product = await Product.findById(id).populate('shop'); + + if (!product) { + return res.status(404).json({ + status: 'error', + message: 'Product not found' + }); + } + + res.status(200).json({ + status: 'success', + data: product + }); + + } catch (error) { + next(error); + } +}; + +const updateProduct = async (req, res, next) => { + try { + const { id } = req.params; + + const product = await Product.findById(id); + + if (!product) { + return res.status(404).json({ + status: 'error', + message: 'Product not found' + }); + } + + + const shop = await Shop.findById(product.shop); + + if (!shop || shop.owner.toString() !== req.user.id) { + return res.status(403).json({ + status: 'error', + message: 'You are not allowed to edit this product' + }); + } + + const { + name, + description, + price, + category, + colors, + sizes, + stockQuantity + } = req.body; + + const updates = {}; + + if (name) updates.name = name; + if (description) updates.description = description; + if (price !== undefined) updates.price = price; + if (category) updates.category = category; + if (colors) updates.colors = colors; + if (sizes) updates.sizes = sizes; + if (stockQuantity !== undefined) { + updates.stockQuantity = stockQuantity; + updates.inStock = stockQuantity > 0; + } + + // 📸 Image update (Cloudinary) + if (req.file) { + const uploadedImage = await uploadImage(req.file.path, 'products'); + updates.imageUrl = uploadedImage.secure_url; + } + + const updatedProduct = await Product.findByIdAndUpdate( + id, + updates, + { + new: true, + runValidators: true + } + ); + + res.status(200).json({ + status: 'success', + message: 'Product updated successfully', + data: updatedProduct + }); + + } catch (error) { + next(error); + } +}; + +const deleteProduct = async (req, res, next) => { + try { + const { id } = req.params; + + const product = await Product.findById(id); + + if (!product) { + return res.status(404).json({ + status: 'error', + message: 'Product not found' + }); + } + + await Product.findByIdAndDelete(id); + + res.status(200).json({ + status: 'success', + message: 'Product deleted successfully' + }); + + } catch (error) { + next(error); + } +}; + +module.exports = { + createProduct, + getShopProducts, + getProductBySlug, + updateProduct, + deleteProduct +}; + diff --git a/modules/products/product.model.js b/modules/products/product.model.js new file mode 100644 index 0000000..8681879 --- /dev/null +++ b/modules/products/product.model.js @@ -0,0 +1,35 @@ +const mongoose = require('mongoose'); + +const productSchema = new mongoose.Schema( + { + // ── Ownership ───────────────────────────────────────────── + shop: { type: mongoose.Schema.Types.ObjectId, ref: 'Shop', required: true, index: true }, + + // ── Core Info ───────────────────────────────────────────── + name: { type: String, required: true, trim: true }, + description: { type: String }, + price: { type: Number, required: true, min: 0 }, // stored in centavos (integer) + category: { type: String }, // optional tag + + + imageUrl: { type: String }, + + // ── Variations & Customization ──────────────────────────── + colors: [{ type: String }], + sizes: [{ type: String }], + + // ── Ratings ────────────────────────────────────────────── + rating: { type: Number, default: 0, min: 0, max: 5 }, + reviewCount: { type: Number, default: 0, min: 0 }, + review: { type: String }, + + // ── Availability & Stock ───────────────────────────────── + inStock: { type: Boolean, default: true }, // seller toggles on/off + stockQuantity: { type: Number, default: 0, min: 0 }, + }, + { + timestamps: true, + } +); + +module.exports = mongoose.model('Product', productSchema); diff --git a/modules/products/product.routes.js b/modules/products/product.routes.js new file mode 100644 index 0000000..b21330f --- /dev/null +++ b/modules/products/product.routes.js @@ -0,0 +1,61 @@ +// TODO: Implement products routes +const express = require('express'); +const router = express.Router(); +const { isShopOwner,verifyToken,requireRole,isProductOwner } = require('../../middleware/auth'); +const upload = require('../../middleware/multer'); +// Middleware: verifyToken, requireRole('seller'), isShopOwner + + + + + + +// POST /api/products/:id/upload — seller: get pre-signed S3 URL for product image + + +const { + createProduct, + getShopProducts, + getProductBySlug, + updateProduct, + deleteProduct +} = require('./product.controller'); +const { verify } = require('jsonwebtoken'); + + +/* +POST /api/shops/:shopId/products +*/ + + +// POST /api/shops/:shopId/products — seller: add product to shop +router.post('/shops/:shopId/products',verifyToken ,requireRole('seller'), upload.single('image'), createProduct); +// Example payload { +// "name": "Wooden Chair", +// "description": "Handmade premium wooden chair made from solid mahogany wood", +// "price": 2500, +// "category": "Furniture", +// "image": "https://res.cloudinary.com/demo/image/upload/sample.jpg", (hindi gagana if raw json) +// "colors": ["Brown", "Dark Brown"], +// "sizes": ["Small", "Medium"], +// "stockQuantity": 10 +// } + +// GET /api/shops/:shopId/get_products — public: list products for a shop +// example request http://localhost:5000/api/shops/66a2d5e1568fe284d486a562/get_products +router.get('/shops/:shopId/get_products', getShopProducts); + + + +// GET /api/products/:id — public: single product detail +router.get('/products/:id', getProductBySlug); + + +// PUT /api/products/:id — seller (isShopOwner): edit product details +router.put('/products/:id/edit_product', verifyToken , requireRole('seller') , isProductOwner , upload.single('image') , updateProduct); + + +// DELETE /api/products/:id — seller (isShopOwner): remove product +router.delete('/products/:id/delete_product',verifyToken,requireRole('seller'),isProductOwner,deleteProduct); + +module.exports = router; diff --git a/modules/shops/review.model.js b/modules/shops/review.model.js new file mode 100644 index 0000000..7872854 --- /dev/null +++ b/modules/shops/review.model.js @@ -0,0 +1,14 @@ +const mongoose = require('mongoose'); + +const reviewSchema = new mongoose.Schema( + { + shop: { type: mongoose.Schema.Types.ObjectId, ref: 'Shop', required: true }, + user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, + + rating: { type: Number, min: 1, max: 5, required: true }, + comment: { type: String } + }, + { timestamps: true } +); + +module.exports = mongoose.model('Review', reviewSchema); \ No newline at end of file diff --git a/modules/shops/shop.controller.js b/modules/shops/shop.controller.js new file mode 100644 index 0000000..b15d713 --- /dev/null +++ b/modules/shops/shop.controller.js @@ -0,0 +1,348 @@ +const Shop = require('./shop.model') +const Review = require('./review.model'); +const { uploadImage } = require('../../services/uploader'); + +// TODO: Implement shops controller +// Planned handlers: +// +// ── Shop handlers ───────────────────────────────────────────────────────── +// getShops — list / search shops (category filter, isActive: true, pagination) +// getShopsNearby — geo-query: shops near [lng, lat] within radius ($near + 2dsphere) +// getShopById — single shop detail (includes product list) +// createShop — seller: create shop, set GeoJSON coordinates +// updateShop — seller/owner: update shop info +// getUploadUrl — return pre-signed S3 URL for cover/logo upload +// +// ── Review handlers (nested, accessed via /api/shops/:id/reviews) ────────── +// getReviews — public: list seeded reviews for a shop +// createReview — buyer: submit a 1–5 star review with optional comment + + +const getShops = async (req, res, next) => { + try { + const { category } = req.query; + + // Base filter + const filter = { + isActive: true + }; + + // Add category filter if provided + if (category) { + filter.category = category; + } + + const shops = await Shop.find(filter) + .populate('owner', 'name email') + .sort({ createdAt: -1 }); + + res.status(200).json({ + status: 'success', + count: shops.length, + data: shops + }); + + } catch (error) { + next(error); + } +}; +// getShopsNearby — geo-query: shops near [lng, lat] within radius ($near + 2dsphere) +const getNearbyShops = async (req, res, next) => { + try { + const { lat, lng, radius } = req.query; + + // Validation + if (!lat || !lng) { + return res.status(400).json({ + status: 'error', + message: 'Latitude and longitude are required' + }); + } + + const shops = await Shop.find({ + isActive: true, + location: { + $near: { + $geometry: { + type: 'Point', + coordinates: [ + parseFloat(lng), // longitude first + parseFloat(lat) // latitude second + ] + }, + $maxDistance: radius + ? parseInt(radius) + : 5000 // default: 5 km (change mo lang kung ano default) + } + } + }); + + res.status(200).json({ + status: 'success', + count: shops.length, + data: shops + }); + + } catch (error) { + next(error); + } +}; + +// getShopById — single shop detail (includes product list) +const getShopById = async (req, res, next) => { + try { + const { id } = req.params; + + const shop = await Shop.findById(id) + .populate('owner', 'name email phone avatarUrl'); + + if (!shop) { + return res.status(404).json({ + status: 'error', + message: 'Shop not found' + }); + } + + res.status(200).json({ + status: 'success', + data: shop + }); + + } catch (error) { + next(error); + } +}; + +const createShop = async (req, res, next) => { + try { + const { + name, + description, + category, + lat, + lng, + address + } = req.body; + + if (!name || !lat || !lng) { + return res.status(400).json({ + status: 'error', + message: 'Name, latitude, and longitude are required' + }); + } + + let coverUrl = ''; + let logoUrl = ''; + + + if (req.files?.cover) { + const cover = await uploadImage(req.files.cover[0].path, 'shops'); + coverUrl = cover.secure_url; + } + + + if (req.files?.logo) { + const logo = await uploadImage(req.files.logo[0].path, 'shops'); + logoUrl = logo.secure_url; + } + + const shop = await Shop.create({ + owner: req.user.id, + name, + description, + category, + address, + coverUrl, + logoUrl, + location: { + type: 'Point', + coordinates: [ + parseFloat(lng), + parseFloat(lat) + ] + } + }); + + res.status(201).json({ + status: 'success', + message: 'Shop created successfully', + data: shop + }); + + } catch (error) { + next(error); + } +}; + +const updateShop = async (req, res, next) => { + try { + const { id } = req.params; + + const { + name, + description, + category, + lat, + lng, + address + } = req.body; + + const shop = await Shop.findById(id); + + if (!shop) { + return res.status(404).json({ + status: 'error', + message: 'Shop not found' + }); + } + + // ownership check (extra safety) + if (shop.owner.toString() !== req.user.id) { + return res.status(403).json({ + status: 'error', + message: 'Not allowed to update this shop' + }); + } + + if (name) shop.name = name; + if (description) shop.description = description; + if (category) shop.category = category; + if (address) shop.address = address; + + + if (lat && lng) { + shop.location = { + type: 'Point', + coordinates: [ + parseFloat(lng), + parseFloat(lat) + ] + }; + } + + + if (req.files?.cover) { + const coverUpload = await uploadImage( + req.files.cover[0].path, + 'shops' + ); + shop.coverUrl = coverUpload.secure_url; + } + + // 🖼️ UPDATE LOGO IMAGE + if (req.files?.logo) { + const logoUpload = await uploadImage( + req.files.logo[0].path, + 'shops' + ); + shop.logoUrl = logoUpload.secure_url; + } + + const updatedShop = await shop.save(); + + res.status(200).json({ + status: 'success', + message: 'Shop updated successfully', + data: updatedShop + }); + + } catch (error) { + next(error); + } +}; + +const getReviews = async (req, res, next) => { + try { + const { id } = req.params; + + const reviews = await Review.find({ shop: id }) + .populate('user', 'name avatarUrl') + .sort({ createdAt: -1 }); + + res.status(200).json({ + status: 'success', + count: reviews.length, + data: reviews + }); + + } catch (error) { + next(error); + } +}; + +const createReview = async (req, res, next) => { + try { + console.log('BODY:', req.body); + console.log('HEADERS:', req.headers); + const { id } = req.params; + const { rating, comment } = req.body; + + + if (!rating) { + return res.status(400).json({ + status: 'error', + message: 'Rating is required' + }); + } + + + const shop = await Shop.findById(id); + + if (!shop) { + return res.status(404).json({ + status: 'error', + message: 'Shop not found' + }); + } + + + const existingReview = await Review.findOne({ + shop: id, + user: req.user.id + }); + + if (existingReview) { + return res.status(400).json({ + status: 'error', + message: 'You already reviewed this shop' + }); + } + + // create review + const review = await Review.create({ + shop: id, + user: req.user.id, + rating, + comment + }); + + // OPTIONAL: update shop rating (simple average) + const reviews = await Review.find({ shop: id }); + + const avgRating = + reviews.reduce((acc, r) => acc + r.rating, 0) / reviews.length; + + shop.rating = avgRating; + await shop.save(); + + res.status(201).json({ + status: 'success', + message: 'Review added successfully', + data: review + }); + + } catch (error) { + next(error); + } +}; + + +module.exports = { + getShops, + getNearbyShops, + getShopById, + createShop, + updateShop, + getReviews, + createReview, +}; diff --git a/modules/shops/shop.model.js b/modules/shops/shop.model.js new file mode 100644 index 0000000..31f7e5f --- /dev/null +++ b/modules/shops/shop.model.js @@ -0,0 +1,39 @@ +const mongoose = require('mongoose'); + +const shopSchema = new mongoose.Schema( + { + // ── Ownership ───────────────────────────────────────────── + owner: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, + + // ── Core Info ───────────────────────────────────────────── + name: { type: String, required: true, trim: true }, + description: { type: String }, + category: { type: String }, // used for map filter tabs + + + coverUrl: { type: String }, + logoUrl: { type: String }, + + // ── Location — GeoJSON Point (enables $near queries) ────── + location: { + type: { type: String, enum: ['Point'], default: 'Point' }, + coordinates: { type: [Number], default: undefined }, // [longitude, latitude] + }, + address: { type: String }, // human-readable display label + + // ── Visibility ──────────────────────────────────────────── + isActive: { type: Boolean, default: true }, // admin toggles to remove from map + + // ── Aggregated Rating (updated by reviews/orders) ───────── + rating: { type: Number, default: 0, min: 0, max: 5 }, + }, + { + timestamps: true, + } +); + +// 2dsphere index powers /api/shops/nearby?lat=&lng=&radius= +shopSchema.index({ location: '2dsphere' }); +shopSchema.index({ category: 1, isActive: 1 }); + +module.exports = mongoose.model('Shop', shopSchema); diff --git a/modules/shops/shop.routes.js b/modules/shops/shop.routes.js new file mode 100644 index 0000000..405d065 --- /dev/null +++ b/modules/shops/shop.routes.js @@ -0,0 +1,71 @@ + +const express = require('express'); +const router = express.Router(); +const { isShopOwner,verifyToken,requireRole } = require('../../middleware/auth'); +const { getShops, getNearbyShops,getShopById,createShop,updateShop,getReviews,createReview } = require('./shop.controller'); +const upload = require('../../middleware/multer'); + +// TODO: Implement shops routes +// Middleware: verifyToken, requireRole('seller'), isShopOwner + +// ── Shop routes ──────────────────────────────────────────────────────────── + + + + + +// POST /api/shops/:id/upload — seller: get pre-signed S3 URL for cover/logo upload + +// ── Review sub-routes (nested under shops) ───────────────────────────────── + + + + + + +// Public: List active shops (or add this query to filter category, isActive) +// GET /api/shops — public: list shops (filter by category, isActive: true) / +router.get('/', getShops); + +// GET /api/shops/nearby — public: geo-query ?lat=&lng=&radius= (uses 2dsphere $near) +// example http://localhost:5000/api/shops/nearby?lat=8.228&lng=124.246&radius=5000 +router.get('/nearby', getNearbyShops); + +// GET /api/shops/:id — public: shop detail + product list +// example http://localhost:5000/api/shops/6a3213b4152aaded1ac9a0ff +router.get('/:id', getShopById); + + + + + +// POST /api/shops — seller: create shop with coords → pin appears on map +router.post('/',verifyToken,requireRole('seller'),upload.fields([{ name: 'cover', maxCount: 1 },{ name: 'logo', maxCount: 1 }]),createShop); +// payload +// name: Juan's Crafts Shop +// description: Handmade wooden products +// category: Handicrafts +// lat: 8.228 +// lng: 124.246 +// address: Marawi City +// cover: (file upload) +// logo: (file upload) + + +// PUT /api/shops/:id — seller (isShopOwner): update shop info +// EXAMPLE http://localhost:5000/api/shops/6a321e72c43e01b6215a55ad +router.put('/:id',verifyToken,requireRole('seller'),isShopOwner,upload.fields([{ name: 'cover', maxCount: 1 },{ name: 'logo', maxCount: 1 }]),updateShop); + + +// GET /api/shops/:id/reviews — public: list reviews for a shop (seeded for demo) +router.get('/:id/reviews', getReviews); + +// POST /api/shops/:id/reviews — buyer (verifyToken): submit a review +router.post('/:id/add_reviews',verifyToken,requireRole('buyer'),createReview); +// example payload{ +// { +// "rating" : 5, +// "comment" : "wow" +// } +// } +module.exports = router; diff --git a/modules/users/user.controller.js b/modules/users/user.controller.js index e04d0a5..72bc7fc 100644 --- a/modules/users/user.controller.js +++ b/modules/users/user.controller.js @@ -1,21 +1,80 @@ + + const User = require('./user.model'); +const {uploadImage } = require('../../services/uploader'); + +// TODO: Implement users controller +// Planned handlers: +// getMyProfile — return current user's profile (name, email, avatarUrl, role) +// updateMyProfile — update own name, avatarUrl +// getUploadUrl — return pre-signed S3 URL for avatar upload + -// @desc Get all users -// @route GET /api/users -// @access Public -const getUsers = async (req, res, next) => { - try { - const users = await User.find().select('-password'); - res.status(200).json({ - status: 'success', - count: users.length, - data: users - }); - } catch (error) { - next(error); - } +const getProfile = async (req, res, next) => { + try { + const user = await User.findById(req.user.id); + + if (!user) { + return res.status(404).json({ + status: 'error', + message: 'User not found' + }); + } + + res.status(200).json({ + status: 'success', + data: user + }); + + } catch (error) { + next(error); + } }; +const updateProfile = async (req, res, next) => { + try { + const { name } = req.body; + + const updates = {}; + + + if (name) { + updates.name = name; + } + + // Upload new avatar if file exists + if (req.file) { + const uploadedImage = await uploadImage(req.file.path, 'users'); + updates.avatarUrl = uploadedImage.secure_url; + } + + const user = await User.findByIdAndUpdate( + req.user.id, + updates, + { + new: true, + runValidators: true, + } + ); + + if (!user) { + return res.status(404).json({ + status: 'error', + message: 'User not found', + }); + } + + res.status(200).json({ + status: 'success', + message: 'Profile updated successfully', + data: user, + }); + + } catch (error) { + next(error); + } +}; module.exports = { - getUsers +getProfile, +updateProfile, }; diff --git a/modules/users/user.model.js b/modules/users/user.model.js index ca3e8af..6d91263 100644 --- a/modules/users/user.model.js +++ b/modules/users/user.model.js @@ -2,23 +2,25 @@ const mongoose = require('mongoose'); const userSchema = new mongoose.Schema( { - name: { - type: String, - required: true - }, - email: { - type: String, - required: true, - unique: true - }, - password: { - type: String, - required: true - }, - }, - { - timestamps: true + name: { type: String, required: true, trim: true }, + email: { type: String, required: true, unique: true, lowercase: true, trim: true }, + phone: { type: String, trim: true }, // e.g. +63 9XX XXX XXXX + password: { type: String, required: true, select: false }, // bcrypt hash, hidden by default + role: { type: String, enum: ['buyer', 'seller', 'admin'], default: 'buyer' }, + avatarUrl: { type: String }, // S3 key: avatars/{userId} + isActive: { type: Boolean, default: true }, // admin can toggle to suspend + }, + { + timestamps: true, } ); +// Strip password from all JSON responses +userSchema.set('toJSON', { + transform: (_, ret) => { + delete ret.password; + return ret; + }, +}); + module.exports = mongoose.model('User', userSchema); diff --git a/modules/users/user.routes.js b/modules/users/user.routes.js index 7274185..57bc662 100644 --- a/modules/users/user.routes.js +++ b/modules/users/user.routes.js @@ -1,9 +1,24 @@ +// TODO: Implement user routes const express = require('express'); const router = express.Router(); -const { getUsers } = require('./user.controller'); +const { getProfile } = require('./user.controller'); +const { updateProfile } = require('./user.controller'); +const { verifyToken } = require('../../middleware/auth'); +const upload = require('../../middleware/multer'); -// Maps to /api/users/ -router.route('/') - .get(getUsers); + +// All routes require verifyToken middleware + +// GET /api/users/profile — get own profile / +// PUT /api/users/profile — update own name, avatarUrl / +// POST /api/users/profile/upload — get pre-signed S3 URL for avatar upload + +/* +GET /api/users/profile +*/ +router.get('/profile', verifyToken, getProfile); + +// PUT /api/users/profile (for update) +router.put('/profile', verifyToken, upload.single('image'), updateProfile); module.exports = router; diff --git a/package-lock.json b/package-lock.json index 3f68878..72e0ea1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,13 +10,16 @@ "license": "ISC", "dependencies": { "@aws-sdk/client-s3": "^3.1066.0", + "axios": "^1.17.0", "bcrypt": "^6.0.0", + "cloudinary": "^2.10.0", "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", "jsonwebtoken": "^9.0.3", "mongoose": "^9.7.0", "morgan": "^1.11.0", + "multer": "^2.1.1", "stripe": "^22.2.0" }, "devDependencies": { @@ -622,6 +625,18 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -648,6 +663,30 @@ ], "license": "MIT" }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -774,6 +813,23 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -837,6 +893,45 @@ "fsevents": "~2.3.2" } }, + "node_modules/cloudinary": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.10.0.tgz", + "integrity": "sha512-sY09kYg7wprkndAOjZBAYqFZqwL+SxnEGcAvksOvFA+5upnFn949UjkEkHKNSwkBtW/xRDd0p6NgbSXZcxkI3w==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.23" + }, + "engines": { + "node": ">=9" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -911,6 +1006,15 @@ } } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1000,6 +1104,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -1129,6 +1248,63 @@ "url": "https://opencollective.com/express" } }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1255,6 +1431,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -1287,6 +1478,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -1429,6 +1633,12 @@ "node": ">=18.0.0" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -1687,6 +1897,68 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -1866,6 +2138,15 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -1921,6 +2202,20 @@ "node": ">= 0.10" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -2148,6 +2443,23 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/stripe": { "version": "22.2.0", "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.2.0.tgz", @@ -2274,6 +2586,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -2290,6 +2608,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index f45ad9f..887fcd3 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,16 @@ "type": "commonjs", "dependencies": { "@aws-sdk/client-s3": "^3.1066.0", + "axios": "^1.17.0", "bcrypt": "^6.0.0", + "cloudinary": "^2.10.0", "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", "jsonwebtoken": "^9.0.3", "mongoose": "^9.7.0", "morgan": "^1.11.0", + "multer": "^2.1.1", "stripe": "^22.2.0" }, "devDependencies": { diff --git a/server.js b/server.js index e3c221b..0dff586 100644 --- a/server.js +++ b/server.js @@ -25,10 +25,27 @@ app.get('/api/health', (req, res) => { }); // TODO: Import and use your module routes here -// app.use('/api/auth', require('./modules/auth/routes')); -// app.use('/api/payments', require('./modules/payments/routes')); +app.use('/api/admin', require('./modules/admin/admin.routes')); +app.use('/api/payments', require('./modules/payments/payment.routes')); + +app.use('/api/orders', require('./modules/orders/order.routes')); +app.use('/api/shops', require('./modules/shops/shop.routes')); + app.use('/api/users', require('./modules/users/user.routes')); + + +app.use('/api/auth', require('./modules/auth/auth.routes')); + + + + +app.use('/api', require('./modules/products/product.routes')); + + + + + // Global Error Handler app.use((err, req, res, next) => { console.error(err.stack); diff --git a/services/paymongo.js b/services/paymongo.js new file mode 100644 index 0000000..21bd1af --- /dev/null +++ b/services/paymongo.js @@ -0,0 +1,17 @@ +const axios = require('axios'); + +const PAYMONGO_SECRET = process.env.PAYMONGO_SECRET_KEY; + +if (!PAYMONGO_SECRET) { + console.error('❌ PAYMONGO_SECRET_KEY is missing'); +} + +const paymongo = axios.create({ + baseURL: 'https://api.paymongo.com/v1', + headers: { + Authorization: `Basic ${Buffer.from(PAYMONGO_SECRET + ':').toString('base64')}`, + 'Content-Type': 'application/json' + } +}); + +module.exports = paymongo; \ No newline at end of file diff --git a/services/uploader.js b/services/uploader.js new file mode 100644 index 0000000..c6013c7 --- /dev/null +++ b/services/uploader.js @@ -0,0 +1,11 @@ +const cloudinary = require('../config/cloudinary'); + +const uploadImage = async (filePath, folder = 'artisan-hub') => { + const result = await cloudinary.uploader.upload(filePath, { + folder, + }); + + return result; +}; + +module.exports = { uploadImage }; \ No newline at end of file