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
+
+
+
+
+
+
+
+
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.
+
+
+
+
+
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 && (
+
+
+ Reason {reasonRequired && * }
+
+
+ )}
-
{cancelText}
-
{loading ? (
<>
diff --git a/frontend/src/pages/ItemDetail.tsx b/frontend/src/pages/ItemDetail.tsx
index b89fe79..1cbc033 100644
--- a/frontend/src/pages/ItemDetail.tsx
+++ b/frontend/src/pages/ItemDetail.tsx
@@ -5,6 +5,7 @@ import { useAuth } from "../contexts/AuthContext";
import { itemAPI, rentalAPI } from "../services/api";
import GoogleMapWithRadius from "../components/GoogleMapWithRadius";
import ItemReviews from "../components/ItemReviews";
+import ConfirmationModal from "../components/ConfirmationModal";
const ItemDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
@@ -24,6 +25,13 @@ const ItemDetail: React.FC = () => {
const [totalCost, setTotalCost] = useState(0);
const [costLoading, setCostLoading] = useState(false);
const [costError, setCostError] = useState(null);
+ const [deleteLoading, setDeleteLoading] = useState(false);
+ const [deleteError, setDeleteError] = useState(null);
+ const [showConfirmModal, setShowConfirmModal] = useState(false);
+ const [confirmAction, setConfirmAction] = useState<
+ "delete" | "restore" | null
+ >(null);
+ const [deletionReason, setDeletionReason] = useState("");
useEffect(() => {
fetchItem();
@@ -68,6 +76,50 @@ const ItemDetail: React.FC = () => {
navigate(`/items/${id}/edit`);
};
+ const handleAdminSoftDelete = () => {
+ setConfirmAction("delete");
+ setShowConfirmModal(true);
+ };
+
+ const handleAdminRestore = () => {
+ setConfirmAction("restore");
+ setShowConfirmModal(true);
+ };
+
+ const handleConfirmAction = async () => {
+ try {
+ setDeleteLoading(true);
+ setDeleteError(null);
+
+ if (confirmAction === "delete") {
+ await itemAPI.adminSoftDeleteItem(id!, deletionReason);
+ } else if (confirmAction === "restore") {
+ await itemAPI.adminRestoreItem(id!);
+ }
+
+ await fetchItem(); // Refresh the item to show updated status
+ setShowConfirmModal(false);
+ setConfirmAction(null);
+ setDeletionReason("");
+ } catch (err: any) {
+ const errorMessage =
+ err.response?.data?.error || `Failed to ${confirmAction} item`;
+ setDeleteError(errorMessage);
+ console.error(`Admin ${confirmAction} failed:`, err);
+ setShowConfirmModal(false);
+ setConfirmAction(null);
+ setDeletionReason("");
+ } finally {
+ setDeleteLoading(false);
+ }
+ };
+
+ const handleCancelConfirm = () => {
+ setShowConfirmModal(false);
+ setConfirmAction(null);
+ setDeletionReason("");
+ };
+
const handleRent = () => {
const params = new URLSearchParams({
startDate: rentalDates.startDate,
@@ -260,17 +312,101 @@ const ItemDetail: React.FC = () => {
}
const isOwner = user?.id === item.ownerId;
+ const isAdmin = user?.role === "admin";
return (
- {isOwner && (
-
-
-
- Edit Listing
-
+ {/* Deleted Status Indicator for Admins */}
+ {item.isDeleted && isAdmin && (
+
+
+
Item Soft Deleted - This item is hidden from
+ public listings.
+ {item.deleter && (
+
+ Deleted by {item.deleter.firstName} {item.deleter.lastName}
+
+ )}
+ {item.deletedAt && (
+
+ on {new Date(item.deletedAt).toLocaleDateString()}
+
+ )}
+ {item.deletionReason && (
+
+ Reason: {item.deletionReason}
+
+ )}
+
+ )}
+
+ {/* Delete Error Alert */}
+ {deleteError && (
+
+ {deleteError}
+
+ )}
+
+ {/* Action Buttons (Owner Edit + Admin Soft Delete/Restore) */}
+ {(isOwner || isAdmin) && (
+
+ {isOwner && (
+
+
+ Edit Listing
+
+ )}
+ {isAdmin && !item.isDeleted && (
+
+ {deleteLoading ? (
+ <>
+
+ Deleting...
+ >
+ ) : (
+ <>
+
+ Delete
+ >
+ )}
+
+ )}
+ {isAdmin && item.isDeleted && (
+
+ {deleteLoading ? (
+ <>
+
+ Restoring...
+ >
+ ) : (
+ <>
+
+ Restore
+ >
+ )}
+
+ )}
)}
@@ -582,8 +718,13 @@ const ItemDetail: React.FC = () => {
{rentalDates.startDate && rentalDates.endDate && (
{costLoading ? (
-
-
Calculating...
+
+
+ Calculating...
+
) : costError ? (
{costError}
@@ -627,6 +768,32 @@ const ItemDetail: React.FC = () => {
+
+ {/* Confirmation Modal */}
+
);
};
diff --git a/frontend/src/pages/Owning.tsx b/frontend/src/pages/Owning.tsx
index cd6a292..0d8df3c 100644
--- a/frontend/src/pages/Owning.tsx
+++ b/frontend/src/pages/Owning.tsx
@@ -533,7 +533,7 @@ const Owning: React.FC = () => {
{item.description}
-
+
{
>
{item.availability ? "Available" : "Not Available"}
+ {item.isDeleted && (
+
+
+ Deleted by Admin
+
+ )}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index de41027..f5713a7 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -202,6 +202,10 @@ export const itemAPI = {
deleteItem: (id: string) => api.delete(`/items/${id}`),
getRecommendations: () => api.get("/items/recommendations"),
getItemReviews: (id: string) => api.get(`/items/${id}/reviews`),
+ // Admin endpoints
+ adminSoftDeleteItem: (id: string, reason: string) =>
+ api.delete(`/items/admin/${id}`, { data: { reason } }),
+ adminRestoreItem: (id: string) => api.patch(`/items/admin/${id}/restore`),
};
export const rentalAPI = {
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index e9f1476..9c804ef 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -109,6 +109,11 @@ export interface Item {
};
ownerId: string;
owner?: User;
+ isDeleted?: boolean;
+ deletedBy?: string;
+ deletedAt?: string;
+ deletionReason?: string;
+ deleter?: User;
createdAt: string;
updatedAt: string;
}