admin can soft delete listings

This commit is contained in:
jackiettran
2025-11-20 17:14:40 -05:00
parent 88c831419c
commit b2f18d77f6
11 changed files with 773 additions and 22 deletions

View File

@@ -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;

View File

@@ -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" });

View File

@@ -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;

View File

@@ -54,6 +54,7 @@ class TemplateManager {
"rentalCompletionCongratsToOwner.html",
"payoutReceivedToOwner.html",
"firstListingCelebrationToOwner.html",
"itemDeletionToOwner.html",
"alphaInvitationToUser.html",
"feedbackConfirmationToUser.html",
"feedbackNotificationToAdmin.html",

View File

@@ -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;

View File

@@ -0,0 +1,305 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Your Listing Has Been Removed</title>
<style>
/* Reset styles */
body, table, td, p, a, li, blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
}
/* Base styles */
body {
margin: 0;
padding: 0;
width: 100% !important;
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
/* Container */
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Header - Warning red gradient */
.header {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #f8d7da;
font-size: 16px;
margin-top: 8px;
font-weight: 600;
}
/* Content */
.content {
padding: 40px 30px;
}
.content h1 {
font-size: 28px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content h2 {
font-size: 22px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Warning box */
.warning-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.warning-box p {
margin: 0 0 10px 0;
color: #856404;
}
.warning-box p:last-child {
margin-bottom: 0;
}
/* Alert box */
.alert-box {
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.alert-box p {
margin: 0 0 10px 0;
color: #721c24;
}
.alert-box p:last-child {
margin-bottom: 0;
}
/* Item highlight */
.item-highlight {
background-color: #f8f9fa;
border-radius: 6px;
padding: 20px;
margin: 20px 0;
text-align: center;
}
.item-highlight .item-name {
font-size: 20px;
font-weight: 600;
color: #dc3545;
margin-bottom: 10px;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff !important;
text-decoration: none;
padding: 16px 32px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
text-align: center;
transition: all 0.3s ease;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #667eea;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0 0 10px 0;
color: #004085;
}
.info-box p:last-child {
margin-bottom: 0;
}
/* Footer */
.footer {
background-color: #f8f9fa;
padding: 30px;
text-align: center;
border-top: 1px solid #e9ecef;
}
.footer p {
margin: 0 0 10px 0;
font-size: 14px;
color: #6c757d;
}
.footer a {
color: #667eea;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
/* Responsive */
@media only screen and (max-width: 600px) {
.email-container {
margin: 0;
border-radius: 0;
}
.header, .content, .footer {
padding: 20px;
}
.logo {
font-size: 28px;
}
.content h1 {
font-size: 24px;
}
.content h2 {
font-size: 20px;
}
.button {
display: block;
width: 100%;
box-sizing: border-box;
}
.item-highlight .item-name {
font-size: 18px;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="tagline">⚠️ Important: Listing Removal Notice</div>
</div>
<div class="content">
<p>Hi {{ownerName}},</p>
<h1>Your Listing Has Been Removed</h1>
<p>We're writing to inform you that your listing has been removed from RentAll by our moderation team.</p>
<div class="item-highlight">
<div class="item-name">{{itemName}}</div>
</div>
<div class="alert-box">
<p><strong>Reason for Removal:</strong></p>
<p>{{deletionReason}}</p>
</div>
<div class="info-box">
<p><strong>What this means:</strong></p>
<ul style="margin: 10px 0; padding-left: 20px; color: #004085;">
<li>Your listing is no longer visible to renters</li>
<li>You can still view it in your dashboard</li>
<li>No new rentals can be requested</li>
<li>Existing active rentals are not affected</li>
</ul>
</div>
<h2>Need Help or Have Questions?</h2>
<p>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:</p>
<p style="text-align: center;">
<a href="mailto:{{supportEmail}}" class="button">Contact Support</a>
</p>
<div class="warning-box">
<p><strong>Review Our Policies:</strong></p>
<p>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.</p>
</div>
<p>You can view your listings anytime from your <a href="{{dashboardUrl}}" style="color: #667eea;">dashboard</a>.</p>
<p>Thank you for your understanding.</p>
<p><strong>Best regards,</strong><br>
The RentAll Team</p>
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p>Building a community of sharing and trust</p>
<p>This email was sent because your listing was removed by our moderation team.</p>
<p>If you have questions, please contact <a href="mailto:{{supportEmail}}">{{supportEmail}}</a></p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
</div>
</div>
</body>
</html>