const express = require("express"); const { Op } = require("sequelize"); const { Item, User, Rental } = require("../models"); // Import from models/index.js to get models with associations const { authenticateToken, requireVerifiedEmail, requireAdmin, optionalAuth } = require("../middleware/auth"); 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("/", async (req, res, next) => { try { const { minPrice, maxPrice, city, zipCode, search, 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; } 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"], }, ], 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 }, 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, async (req, res, next) => { try { // Extract only allowed fields (prevents mass assignment) const allowedData = extractAllowedFields(req.body); // Validate imageFilenames if provided if (allowedData.imageFilenames) { const imageFilenames = Array.isArray(allowedData.imageFilenames) ? allowedData.imageFilenames : []; 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 ); console.log(`First listing celebration email sent to owner ${req.user.id}`); } catch (emailError) { // Log but don't fail the item creation console.error('Failed to send first listing celebration email:', emailError.message); } } 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, 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 : []; 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; } 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() ); console.log(`Item deletion notification email sent to owner ${item.ownerId}`); } catch (emailError) { // Log but don't fail the deletion console.error('Failed to send item deletion notification email:', emailError.message); } 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;