const express = require("express"); const { Op, Sequelize } = require("sequelize"); const { Item, User, Rental, sequelize } = require("../models"); // Import from models/index.js to get models with associations const { authenticateToken, requireVerifiedEmail, requireAdmin, optionalAuth } = require("../middleware/auth"); const { validateCoordinatesQuery, validateCoordinatesBody, handleValidationErrors } = require("../middleware/validation"); const logger = require("../utils/logger"); const { validateS3Keys } = require("../utils/s3KeyValidator"); const { IMAGE_LIMITS } = require("../config/imageLimits"); const router = express.Router(); // Allowed fields for item create/update (prevents mass assignment) const ALLOWED_ITEM_FIELDS = [ 'name', 'description', 'pickUpAvailable', 'localDeliveryAvailable', 'localDeliveryRadius', 'shippingAvailable', 'inPlaceUseAvailable', 'pricePerHour', 'pricePerDay', 'pricePerWeek', 'pricePerMonth', 'replacementCost', 'address1', 'address2', 'city', 'state', 'zipCode', 'country', 'latitude', 'longitude', 'imageFilenames', 'isAvailable', 'rules', 'availableAfter', 'availableBefore', 'specifyTimesPerDay', 'weeklyTimes', ]; /** * Extract only allowed fields from request body * @param {Object} body - Request body * @returns {Object} - Object with only allowed fields */ function extractAllowedFields(body) { const result = {}; for (const field of ALLOWED_ITEM_FIELDS) { if (body[field] !== undefined) { result[field] = body[field]; } } return result; } router.get("/", validateCoordinatesQuery, async (req, res, next) => { try { const { minPrice, maxPrice, city, zipCode, search, lat, lng, radius = 25, page = 1, limit = 20, } = req.query; const where = { isDeleted: false // Always exclude soft-deleted items from public browse }; if (minPrice || maxPrice) { where.pricePerDay = {}; if (minPrice) where.pricePerDay[Op.gte] = minPrice; if (maxPrice) where.pricePerDay[Op.lte] = maxPrice; } // Location filtering: Radius search OR city/ZIP fallback if (lat && lng) { // Parse and validate coordinates const latNum = parseFloat(lat); const lngNum = parseFloat(lng); const radiusNum = parseFloat(radius); if (!isNaN(latNum) && !isNaN(lngNum) && !isNaN(radiusNum)) { // Bounding box pre-filter (fast, uses indexes) // ~69 miles per degree latitude, longitude varies by latitude const latDelta = radiusNum / 69; const lngDelta = radiusNum / (69 * Math.cos(latNum * Math.PI / 180)); where.latitude = { [Op.and]: [ { [Op.gte]: latNum - latDelta }, { [Op.lte]: latNum + latDelta }, { [Op.ne]: null } ] }; where.longitude = { [Op.and]: [ { [Op.gte]: lngNum - lngDelta }, { [Op.lte]: lngNum + lngDelta }, { [Op.ne]: null } ] }; // Haversine formula for exact distance (applied after bounding box) // 3959 = Earth's radius in miles where[Op.and] = sequelize.literal(` (3959 * acos( cos(radians(${latNum})) * cos(radians("Item"."latitude")) * cos(radians("Item"."longitude") - radians(${lngNum})) + sin(radians(${latNum})) * sin(radians("Item"."latitude")) )) <= ${radiusNum} `); } } else { // Fallback to city/ZIP string matching if (city) where.city = { [Op.iLike]: `%${city}%` }; if (zipCode) where.zipCode = { [Op.iLike]: `%${zipCode}%` }; } if (search) { where[Op.or] = [ { name: { [Op.iLike]: `%${search}%` } }, { description: { [Op.iLike]: `%${search}%` } }, ]; } const offset = (page - 1) * limit; const { count, rows } = await Item.findAndCountAll({ where, include: [ { model: User, as: "owner", attributes: ["id", "firstName", "lastName", "imageFilename"], where: { isBanned: { [Op.ne]: true } }, required: true, }, ], limit: parseInt(limit), offset: parseInt(offset), order: [["createdAt", "DESC"]], }); // Round coordinates to 2 decimal places for map display while keeping precise values in database const itemsWithRoundedCoords = rows.map(item => { const itemData = item.toJSON(); if (itemData.latitude !== null && itemData.latitude !== undefined) { itemData.latitude = Math.round(parseFloat(itemData.latitude) * 100) / 100; } if (itemData.longitude !== null && itemData.longitude !== undefined) { itemData.longitude = Math.round(parseFloat(itemData.longitude) * 100) / 100; } return itemData; }); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Items search completed", { filters: { minPrice, maxPrice, city, zipCode, search, lat, lng, radius }, resultsCount: count, page: parseInt(page), limit: parseInt(limit) }); res.json({ items: itemsWithRoundedCoords, totalPages: Math.ceil(count / limit), currentPage: parseInt(page), totalItems: count, }); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Items search failed", { error: error.message, stack: error.stack, query: req.query }); next(error); } }); router.get("/recommendations", authenticateToken, async (req, res, next) => { try { const userRentals = await Rental.findAll({ where: { renterId: req.user.id }, include: [{ model: Item, as: "item" }], }); // For now, just return random available items as recommendations const recommendations = await Item.findAll({ where: { isAvailable: true, isDeleted: false, }, limit: 10, order: [["createdAt", "DESC"]], }); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Recommendations fetched", { userId: req.user.id, recommendationsCount: recommendations.length }); res.json(recommendations); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Recommendations fetch failed", { error: error.message, stack: error.stack, userId: req.user.id }); next(error); } }); // Public endpoint to get reviews for a specific item (must come before /:id route) router.get('/:id/reviews', async (req, res, next) => { try { const { Rental, User } = require('../models'); const reviews = await Rental.findAll({ where: { itemId: req.params.id, status: 'completed', itemRating: { [Op.not]: null }, itemReview: { [Op.not]: null }, itemReviewVisible: true }, include: [ { model: User, as: 'renter', attributes: ['id', 'firstName', 'lastName', 'imageFilename'] } ], order: [['createdAt', 'DESC']] }); const averageRating = reviews.length > 0 ? reviews.reduce((sum, review) => sum + review.itemRating, 0) / reviews.length : 0; const reqLogger = logger.withRequestId(req.id); reqLogger.info("Item reviews fetched", { itemId: req.params.id, reviewsCount: reviews.length, averageRating }); res.json({ reviews, averageRating, totalReviews: reviews.length }); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Item reviews fetch failed", { error: error.message, stack: error.stack, itemId: req.params.id }); next(error); } }); router.get("/:id", optionalAuth, async (req, res, next) => { try { const item = await Item.findByPk(req.params.id, { include: [ { model: User, as: "owner", attributes: ["id", "firstName", "lastName", "imageFilename"], }, { model: User, as: "deleter", attributes: ["id", "firstName", "lastName"], }, ], }); if (!item) { return res.status(404).json({ error: "Item not found" }); } // Check if item is deleted - only allow admins to view if (item.isDeleted) { const isAdmin = req.user?.role === 'admin'; if (!isAdmin) { return res.status(404).json({ error: "Item not found" }); } } // Round coordinates to 2 decimal places for map display while keeping precise values in database const itemResponse = item.toJSON(); if (itemResponse.latitude !== null && itemResponse.latitude !== undefined) { itemResponse.latitude = Math.round(parseFloat(itemResponse.latitude) * 100) / 100; } if (itemResponse.longitude !== null && itemResponse.longitude !== undefined) { itemResponse.longitude = Math.round(parseFloat(itemResponse.longitude) * 100) / 100; } const reqLogger = logger.withRequestId(req.id); reqLogger.info("Item fetched", { itemId: req.params.id, ownerId: item.ownerId }); res.json(itemResponse); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Item fetch failed", { error: error.message, stack: error.stack, itemId: req.params.id }); next(error); } }); router.post("/", authenticateToken, requireVerifiedEmail, ...validateCoordinatesBody, handleValidationErrors, async (req, res, next) => { try { // Extract only allowed fields (prevents mass assignment) const allowedData = extractAllowedFields(req.body); // Validate imageFilenames - at least one image is required const imageFilenames = Array.isArray(allowedData.imageFilenames) ? allowedData.imageFilenames : []; if (imageFilenames.length === 0) { return res.status(400).json({ error: "At least one image is required to create a listing" }); } // Validate required fields if (!allowedData.name || !allowedData.name.trim()) { return res.status(400).json({ error: "Item name is required" }); } if (!allowedData.address1 || !allowedData.address1.trim()) { return res.status(400).json({ error: "Address is required" }); } if (!allowedData.city || !allowedData.city.trim()) { return res.status(400).json({ error: "City is required" }); } if (!allowedData.state || !allowedData.state.trim()) { return res.status(400).json({ error: "State is required" }); } if (!allowedData.zipCode || !allowedData.zipCode.trim()) { return res.status(400).json({ error: "ZIP code is required" }); } if (!allowedData.replacementCost || Number(allowedData.replacementCost) <= 0) { return res.status(400).json({ error: "Replacement cost is required" }); } const keyValidation = validateS3Keys(imageFilenames, 'items', { maxKeys: IMAGE_LIMITS.items }); if (!keyValidation.valid) { return res.status(400).json({ error: keyValidation.error, details: keyValidation.invalidKeys }); } allowedData.imageFilenames = imageFilenames; const item = await Item.create({ ...allowedData, ownerId: req.user.id, }); const itemWithOwner = await Item.findByPk(item.id, { include: [ { model: User, as: "owner", attributes: ["id", "firstName", "lastName", "email", "stripeConnectedAccountId"], }, ], }); // Check if this is the owner's first listing const ownerItemCount = await Item.count({ where: { ownerId: req.user.id } }); // If first listing, send celebration email if (ownerItemCount === 1) { try { const emailServices = require("../services/email"); await emailServices.userEngagement.sendFirstListingCelebrationEmail( itemWithOwner.owner, itemWithOwner ); const reqLogger = logger.withRequestId(req.id); reqLogger.info("First listing celebration email sent", { ownerId: req.user.id }); } catch (emailError) { // Log but don't fail the item creation const reqLogger = logger.withRequestId(req.id); reqLogger.error('Failed to send first listing celebration email', { error: emailError.message, stack: emailError.stack, ownerId: req.user.id, itemId: item.id }); } } const reqLogger = logger.withRequestId(req.id); reqLogger.info("Item created", { itemId: item.id, ownerId: req.user.id, itemName: req.body.name, isFirstListing: ownerItemCount === 1 }); res.status(201).json(itemWithOwner); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Item creation failed", { error: error.message, stack: error.stack, ownerId: req.user.id, itemData: logger.sanitize(req.body) }); next(error); } }); router.put("/:id", authenticateToken, ...validateCoordinatesBody, handleValidationErrors, async (req, res, next) => { try { const item = await Item.findByPk(req.params.id); if (!item) { return res.status(404).json({ error: "Item not found" }); } if (item.ownerId !== req.user.id) { return res.status(403).json({ error: "Unauthorized" }); } // Extract only allowed fields (prevents mass assignment) const allowedData = extractAllowedFields(req.body); // Validate imageFilenames if provided if (allowedData.imageFilenames !== undefined) { const imageFilenames = Array.isArray(allowedData.imageFilenames) ? allowedData.imageFilenames : []; // Require at least one image if (imageFilenames.length === 0) { return res.status(400).json({ error: "At least one image is required for a listing" }); } const keyValidation = validateS3Keys(imageFilenames, 'items', { maxKeys: IMAGE_LIMITS.items }); if (!keyValidation.valid) { return res.status(400).json({ error: keyValidation.error, details: keyValidation.invalidKeys }); } allowedData.imageFilenames = imageFilenames; } // Validate required fields if they are being updated if (allowedData.name !== undefined && (!allowedData.name || !allowedData.name.trim())) { return res.status(400).json({ error: "Item name is required" }); } if (allowedData.address1 !== undefined && (!allowedData.address1 || !allowedData.address1.trim())) { return res.status(400).json({ error: "Address is required" }); } if (allowedData.city !== undefined && (!allowedData.city || !allowedData.city.trim())) { return res.status(400).json({ error: "City is required" }); } if (allowedData.state !== undefined && (!allowedData.state || !allowedData.state.trim())) { return res.status(400).json({ error: "State is required" }); } if (allowedData.zipCode !== undefined && (!allowedData.zipCode || !allowedData.zipCode.trim())) { return res.status(400).json({ error: "ZIP code is required" }); } if (allowedData.replacementCost !== undefined && (!allowedData.replacementCost || Number(allowedData.replacementCost) <= 0)) { return res.status(400).json({ error: "Replacement cost is required" }); } await item.update(allowedData); const updatedItem = await Item.findByPk(item.id, { include: [ { model: User, as: "owner", attributes: ["id", "firstName", "lastName"], }, ], }); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Item updated", { itemId: req.params.id, ownerId: req.user.id }); res.json(updatedItem); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Item update failed", { error: error.message, stack: error.stack, itemId: req.params.id, ownerId: req.user.id }); next(error); } }); router.delete("/:id", authenticateToken, async (req, res, next) => { try { const item = await Item.findByPk(req.params.id); if (!item) { return res.status(404).json({ error: "Item not found" }); } if (item.ownerId !== req.user.id) { return res.status(403).json({ error: "Unauthorized" }); } await item.destroy(); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Item deleted", { itemId: req.params.id, ownerId: req.user.id }); res.status(204).send(); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Item deletion failed", { error: error.message, stack: error.stack, itemId: req.params.id, ownerId: req.user.id }); next(error); } }); // Admin endpoints router.delete("/admin/:id", authenticateToken, requireAdmin, async (req, res, next) => { try { const { reason } = req.body; if (!reason || !reason.trim()) { return res.status(400).json({ error: "Deletion reason is required" }); } const item = await Item.findByPk(req.params.id, { include: [ { model: User, as: "owner", attributes: ["id", "firstName", "lastName", "email"], }, ], }); if (!item) { return res.status(404).json({ error: "Item not found" }); } if (item.isDeleted) { return res.status(400).json({ error: "Item is already deleted" }); } // Check for active or upcoming rentals const activeRentals = await Rental.count({ where: { itemId: req.params.id, status: { [Op.in]: ['pending', 'confirmed', 'active'] } } }); if (activeRentals > 0) { return res.status(400).json({ error: "Cannot delete item with active or upcoming rentals", code: "ACTIVE_RENTALS_EXIST", activeRentalsCount: activeRentals }); } // Soft delete the item await item.update({ isDeleted: true, deletedBy: req.user.id, deletedAt: new Date(), deletionReason: reason.trim() }); const updatedItem = await Item.findByPk(item.id, { include: [ { model: User, as: "owner", attributes: ["id", "firstName", "lastName"], }, { model: User, as: "deleter", attributes: ["id", "firstName", "lastName"], } ], }); // Send email notification to owner try { const emailServices = require("../services/email"); await emailServices.userEngagement.sendItemDeletionNotificationToOwner( item.owner, item, reason.trim() ); logger.info("Item deletion notification email sent", { ownerId: item.ownerId, itemId: item.id }); } catch (emailError) { // Log but don't fail the deletion logger.error('Failed to send item deletion notification email', { error: emailError.message, stack: emailError.stack, ownerId: item.ownerId, itemId: item.id }); } const reqLogger = logger.withRequestId(req.id); reqLogger.info("Item soft deleted by admin", { itemId: req.params.id, deletedBy: req.user.id, ownerId: item.ownerId, reason: reason.trim() }); res.json(updatedItem); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Admin item soft delete failed", { error: error.message, stack: error.stack, itemId: req.params.id, adminId: req.user.id }); next(error); } }); router.patch("/admin/:id/restore", authenticateToken, requireAdmin, async (req, res, next) => { try { const item = await Item.findByPk(req.params.id); if (!item) { return res.status(404).json({ error: "Item not found" }); } if (!item.isDeleted) { return res.status(400).json({ error: "Item is not deleted" }); } // Restore the item await item.update({ isDeleted: false, deletedBy: null, deletedAt: null, deletionReason: null }); const updatedItem = await Item.findByPk(item.id, { include: [ { model: User, as: "owner", attributes: ["id", "firstName", "lastName"], } ], }); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Item restored by admin", { itemId: req.params.id, restoredBy: req.user.id, ownerId: item.ownerId }); res.json(updatedItem); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Admin item restore failed", { error: error.message, stack: error.stack, itemId: req.params.id, adminId: req.user.id }); next(error); } }); module.exports = router;