Files
rentall-app/backend/routes/items.js

652 lines
18 KiB
JavaScript

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 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,
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"],
},
],
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, 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;