From b2f18d77f6b72499b4eea096057c356b1dae19ca Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Thu, 20 Nov 2025 17:14:40 -0500 Subject: [PATCH] admin can soft delete listings --- backend/models/Item.js | 20 ++ backend/models/index.js | 1 + backend/routes/items.js | 175 +++++++++- .../services/email/core/TemplateManager.js | 1 + .../domain/UserEngagementEmailService.js | 46 +++ .../templates/emails/itemDeletionToOwner.html | 305 ++++++++++++++++++ frontend/src/components/ConfirmationModal.tsx | 47 ++- frontend/src/pages/ItemDetail.tsx | 183 ++++++++++- frontend/src/pages/Owning.tsx | 8 +- frontend/src/services/api.ts | 4 + frontend/src/types/index.ts | 5 + 11 files changed, 773 insertions(+), 22 deletions(-) create mode 100644 backend/templates/emails/itemDeletionToOwner.html diff --git a/backend/models/Item.js b/backend/models/Item.js index 0079ea9..9be19c3 100644 --- a/backend/models/Item.js +++ b/backend/models/Item.js @@ -141,6 +141,26 @@ const Item = sequelize.define("Item", { key: "id", }, }, + isDeleted: { + type: DataTypes.BOOLEAN, + defaultValue: false, + }, + deletedBy: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: "Users", + key: "id", + }, + }, + deletedAt: { + type: DataTypes.DATE, + allowNull: true, + }, + deletionReason: { + type: DataTypes.TEXT, + allowNull: true, + }, }); module.exports = Item; diff --git a/backend/models/index.js b/backend/models/index.js index b084485..de20370 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -13,6 +13,7 @@ const Feedback = require("./Feedback"); User.hasMany(Item, { as: "ownedItems", foreignKey: "ownerId" }); Item.belongsTo(User, { as: "owner", foreignKey: "ownerId" }); +Item.belongsTo(User, { as: "deleter", foreignKey: "deletedBy" }); User.hasMany(Rental, { as: "rentalsAsRenter", foreignKey: "renterId" }); User.hasMany(Rental, { as: "rentalsAsOwner", foreignKey: "ownerId" }); diff --git a/backend/routes/items.js b/backend/routes/items.js index 8ac0dc1..bb2a1d9 100644 --- a/backend/routes/items.js +++ b/backend/routes/items.js @@ -1,7 +1,7 @@ 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 } = require("../middleware/auth"); +const { authenticateToken, requireVerifiedEmail, requireAdmin, optionalAuth } = require("../middleware/auth"); const logger = require("../utils/logger"); const router = express.Router(); @@ -17,7 +17,9 @@ router.get("/", async (req, res) => { limit = 20, } = req.query; - const where = {}; + const where = { + isDeleted: false // Always exclude soft-deleted items from public browse + }; if (minPrice || maxPrice) { where.pricePerDay = {}; @@ -97,6 +99,7 @@ router.get("/recommendations", authenticateToken, async (req, res) => { const recommendations = await Item.findAll({ where: { availability: true, + isDeleted: false, }, limit: 10, order: [["createdAt", "DESC"]], @@ -170,7 +173,7 @@ router.get('/:id/reviews', async (req, res) => { } }); -router.get("/:id", async (req, res) => { +router.get("/:id", optionalAuth, async (req, res) => { try { const item = await Item.findByPk(req.params.id, { include: [ @@ -179,6 +182,11 @@ router.get("/:id", async (req, res) => { as: "owner", attributes: ["id", "username", "firstName", "lastName"], }, + { + model: User, + as: "deleter", + attributes: ["id", "username", "firstName", "lastName"], + }, ], }); @@ -186,6 +194,15 @@ router.get("/:id", async (req, res) => { 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) { @@ -347,4 +364,156 @@ router.delete("/:id", authenticateToken, async (req, res) => { } }); +// Admin endpoints +router.delete("/admin/:id", authenticateToken, requireAdmin, async (req, res) => { + 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", "username", "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", "username", "firstName", "lastName"], + }, + { + model: User, + as: "deleter", + attributes: ["id", "username", "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 + }); + res.status(500).json({ error: error.message }); + } +}); + +router.patch("/admin/:id/restore", authenticateToken, requireAdmin, async (req, res) => { + 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 + }); + + const updatedItem = await Item.findByPk(item.id, { + include: [ + { + model: User, + as: "owner", + attributes: ["id", "username", "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 + }); + res.status(500).json({ error: error.message }); + } +}); + module.exports = router; diff --git a/backend/services/email/core/TemplateManager.js b/backend/services/email/core/TemplateManager.js index d534c4c..e6a1083 100644 --- a/backend/services/email/core/TemplateManager.js +++ b/backend/services/email/core/TemplateManager.js @@ -54,6 +54,7 @@ class TemplateManager { "rentalCompletionCongratsToOwner.html", "payoutReceivedToOwner.html", "firstListingCelebrationToOwner.html", + "itemDeletionToOwner.html", "alphaInvitationToUser.html", "feedbackConfirmationToUser.html", "feedbackNotificationToAdmin.html", diff --git a/backend/services/email/domain/UserEngagementEmailService.js b/backend/services/email/domain/UserEngagementEmailService.js index a0c5425..2783f05 100644 --- a/backend/services/email/domain/UserEngagementEmailService.js +++ b/backend/services/email/domain/UserEngagementEmailService.js @@ -72,6 +72,52 @@ class UserEngagementEmailService { return { success: false, error: error.message }; } } + + /** + * Send item deletion notification email to owner + * @param {Object} owner - Owner user object + * @param {string} owner.firstName - Owner's first name + * @param {string} owner.email - Owner's email address + * @param {Object} item - Item object + * @param {number} item.id - Item ID + * @param {string} item.name - Item name + * @param {string} deletionReason - Reason for deletion + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendItemDeletionNotificationToOwner(owner, item, deletionReason) { + if (!this.initialized) { + await this.initialize(); + } + + try { + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const supportEmail = process.env.SUPPORT_EMAIL; + + const variables = { + ownerName: owner.firstName || "there", + itemName: item.name, + deletionReason, + supportEmail, + dashboardUrl: `${frontendUrl}/owning`, + }; + + const htmlContent = await this.templateManager.renderTemplate( + "itemDeletionToOwner", + variables + ); + + const subject = `Important: Your listing "${item.name}" has been removed`; + + return await this.emailClient.sendEmail( + owner.email, + subject, + htmlContent + ); + } catch (error) { + console.error("Failed to send item deletion notification email:", error); + return { success: false, error: error.message }; + } + } } module.exports = UserEngagementEmailService; diff --git a/backend/templates/emails/itemDeletionToOwner.html b/backend/templates/emails/itemDeletionToOwner.html new file mode 100644 index 0000000..215d856 --- /dev/null +++ b/backend/templates/emails/itemDeletionToOwner.html @@ -0,0 +1,305 @@ + + + + + + + Your Listing Has Been Removed + + + +
+
+ +
⚠️ Important: Listing Removal Notice
+
+ +
+

Hi {{ownerName}},

+ +

Your Listing Has Been Removed

+ +

We're writing to inform you that your listing has been removed from RentAll by our moderation team.

+ +
+
{{itemName}}
+
+ +
+

Reason for Removal:

+

{{deletionReason}}

+
+ +
+

What this means:

+
    +
  • Your listing is no longer visible to renters
  • +
  • You can still view it in your dashboard
  • +
  • No new rentals can be requested
  • +
  • Existing active rentals are not affected
  • +
+
+ +

Need Help or Have Questions?

+

If you believe this removal was made in error or if you have questions about our policies, please don't hesitate to contact our support team:

+ +

+ Contact Support +

+ +
+

Review Our Policies:

+

To prevent future removals, please familiarize yourself with our community guidelines and listing standards. Our team is happy to help you understand what makes a great RentAll listing.

+
+ +

You can view your listings anytime from your dashboard.

+ +

Thank you for your understanding.

+ +

Best regards,
+ The RentAll Team

+
+ + +
+ + diff --git a/frontend/src/components/ConfirmationModal.tsx b/frontend/src/components/ConfirmationModal.tsx index ecb6b34..696b0b3 100644 --- a/frontend/src/components/ConfirmationModal.tsx +++ b/frontend/src/components/ConfirmationModal.tsx @@ -10,6 +10,11 @@ interface ConfirmationModalProps { cancelText?: string; confirmButtonClass?: string; loading?: boolean; + showReasonInput?: boolean; + reason?: string; + onReasonChange?: (reason: string) => void; + reasonPlaceholder?: string; + reasonRequired?: boolean; } const ConfirmationModal: React.FC = ({ @@ -21,40 +26,62 @@ const ConfirmationModal: React.FC = ({ confirmText = 'Confirm', cancelText = 'Cancel', confirmButtonClass = 'btn-danger', - loading = false + loading = false, + showReasonInput = false, + reason = '', + onReasonChange, + reasonPlaceholder = 'Enter reason...', + reasonRequired = false }) => { if (!show) return null; + const isConfirmDisabled = loading || (reasonRequired && showReasonInput && !reason.trim()); + return (
{title}
-

{message}

+ {showReasonInput && ( +
+ +