From 16272ba373537bd302525759d8913b008063d468 Mon Sep 17 00:00:00 2001
From: jackiettran <41605212+jackiettran@users.noreply.github.com>
Date: Fri, 31 Oct 2025 16:48:18 -0400
Subject: [PATCH] feedback tab
---
backend/middleware/validation.js | 17 ++
backend/models/Feedback.js | 34 +++
backend/models/index.js | 6 +
backend/routes/feedback.js | 66 ++++++
backend/server.js | 2 +
backend/services/emailService.js | 99 ++++++++
.../emails/feedbackConfirmationToUser.html | 117 ++++++++++
.../emails/feedbackNotificationToAdmin.html | 159 +++++++++++++
frontend/src/App.tsx | 6 +-
frontend/src/components/FeedbackButton.tsx | 75 ++++++
frontend/src/components/FeedbackModal.tsx | 217 ++++++++++++++++++
frontend/src/services/api.ts | 5 +
frontend/src/types/index.ts | 10 +
13 files changed, 812 insertions(+), 1 deletion(-)
create mode 100644 backend/models/Feedback.js
create mode 100644 backend/routes/feedback.js
create mode 100644 backend/templates/emails/feedbackConfirmationToUser.html
create mode 100644 backend/templates/emails/feedbackNotificationToAdmin.html
create mode 100644 frontend/src/components/FeedbackButton.tsx
create mode 100644 frontend/src/components/FeedbackModal.tsx
diff --git a/backend/middleware/validation.js b/backend/middleware/validation.js
index 5d499fb..ebf3ab9 100644
--- a/backend/middleware/validation.js
+++ b/backend/middleware/validation.js
@@ -310,6 +310,22 @@ const validateVerifyResetToken = [
handleValidationErrors,
];
+// Feedback validation
+const validateFeedback = [
+ body("feedbackText")
+ .trim()
+ .isLength({ min: 5, max: 5000 })
+ .withMessage("Feedback must be between 5 and 5000 characters"),
+
+ body("url")
+ .optional()
+ .trim()
+ .isLength({ max: 500 })
+ .withMessage("URL must be less than 500 characters"),
+
+ handleValidationErrors,
+];
+
module.exports = {
sanitizeInput,
handleValidationErrors,
@@ -321,4 +337,5 @@ module.exports = {
validateForgotPassword,
validateResetPassword,
validateVerifyResetToken,
+ validateFeedback,
};
diff --git a/backend/models/Feedback.js b/backend/models/Feedback.js
new file mode 100644
index 0000000..f3320d8
--- /dev/null
+++ b/backend/models/Feedback.js
@@ -0,0 +1,34 @@
+const { DataTypes } = require('sequelize');
+const sequelize = require('../config/database');
+
+const Feedback = sequelize.define('Feedback', {
+ id: {
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4,
+ primaryKey: true
+ },
+ userId: {
+ type: DataTypes.UUID,
+ allowNull: false,
+ references: {
+ model: 'Users',
+ key: 'id'
+ }
+ },
+ feedbackText: {
+ type: DataTypes.TEXT,
+ allowNull: false
+ },
+ userAgent: {
+ type: DataTypes.STRING,
+ allowNull: true
+ },
+ url: {
+ type: DataTypes.STRING(500),
+ allowNull: true
+ }
+}, {
+ timestamps: true
+});
+
+module.exports = Feedback;
diff --git a/backend/models/index.js b/backend/models/index.js
index 356b7da..feb2bd5 100644
--- a/backend/models/index.js
+++ b/backend/models/index.js
@@ -8,6 +8,7 @@ const ItemRequestResponse = require("./ItemRequestResponse");
const UserAddress = require("./UserAddress");
const ConditionCheck = require("./ConditionCheck");
const AlphaInvitation = require("./AlphaInvitation");
+const Feedback = require("./Feedback");
User.hasMany(Item, { as: "ownedItems", foreignKey: "ownerId" });
Item.belongsTo(User, { as: "owner", foreignKey: "ownerId" });
@@ -82,6 +83,10 @@ User.hasMany(AlphaInvitation, {
foreignKey: "usedBy",
});
+// Feedback associations
+User.hasMany(Feedback, { as: "feedbacks", foreignKey: "userId" });
+Feedback.belongsTo(User, { as: "user", foreignKey: "userId" });
+
module.exports = {
sequelize,
User,
@@ -93,4 +98,5 @@ module.exports = {
UserAddress,
ConditionCheck,
AlphaInvitation,
+ Feedback,
};
diff --git a/backend/routes/feedback.js b/backend/routes/feedback.js
new file mode 100644
index 0000000..fb36200
--- /dev/null
+++ b/backend/routes/feedback.js
@@ -0,0 +1,66 @@
+const express = require('express');
+const { Feedback, User } = require('../models');
+const { authenticateToken } = require('../middleware/auth');
+const { validateFeedback, sanitizeInput } = require('../middleware/validation');
+const logger = require('../utils/logger');
+const emailService = require('../services/emailService');
+const router = express.Router();
+
+// Submit new feedback
+router.post('/', authenticateToken, sanitizeInput, validateFeedback, async (req, res) => {
+ try {
+ const { feedbackText, url } = req.body;
+
+ // Capture user agent from request headers
+ const userAgent = req.headers['user-agent'];
+
+ const feedback = await Feedback.create({
+ userId: req.user.id,
+ feedbackText,
+ url: url || null,
+ userAgent: userAgent || null
+ });
+
+ const reqLogger = logger.withRequestId(req.id);
+ reqLogger.info("Feedback submitted", {
+ userId: req.user.id,
+ feedbackId: feedback.id
+ });
+
+ // Send confirmation email to user
+ try {
+ await emailService.sendFeedbackConfirmation(req.user, feedback);
+ } catch (emailError) {
+ reqLogger.error("Failed to send feedback confirmation email", {
+ error: emailError.message,
+ userId: req.user.id,
+ feedbackId: feedback.id
+ });
+ // Don't fail the request if email fails
+ }
+
+ // Send notification email to admin
+ try {
+ await emailService.sendFeedbackNotificationToAdmin(req.user, feedback);
+ } catch (emailError) {
+ reqLogger.error("Failed to send feedback notification to admin", {
+ error: emailError.message,
+ userId: req.user.id,
+ feedbackId: feedback.id
+ });
+ // Don't fail the request if email fails
+ }
+
+ res.status(201).json(feedback);
+ } catch (error) {
+ const reqLogger = logger.withRequestId(req.id);
+ reqLogger.error("Feedback submission failed", {
+ error: error.message,
+ stack: error.stack,
+ userId: req.user.id
+ });
+ res.status(500).json({ error: error.message });
+ }
+});
+
+module.exports = router;
diff --git a/backend/server.js b/backend/server.js
index a830d66..4ae1706 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -25,6 +25,7 @@ const itemRequestRoutes = require("./routes/itemRequests");
const stripeRoutes = require("./routes/stripe");
const mapsRoutes = require("./routes/maps");
const conditionCheckRoutes = require("./routes/conditionChecks");
+const feedbackRoutes = require("./routes/feedback");
const PayoutProcessor = require("./jobs/payoutProcessor");
const RentalStatusJob = require("./jobs/rentalStatusJob");
@@ -126,6 +127,7 @@ app.use("/api/item-requests", requireAlphaAccess, itemRequestRoutes);
app.use("/api/stripe", requireAlphaAccess, stripeRoutes);
app.use("/api/maps", requireAlphaAccess, mapsRoutes);
app.use("/api/condition-checks", requireAlphaAccess, conditionCheckRoutes);
+app.use("/api/feedback", requireAlphaAccess, feedbackRoutes);
// Error handling middleware (must be last)
app.use(errorLogger);
diff --git a/backend/services/emailService.js b/backend/services/emailService.js
index efbd765..79dfe0f 100644
--- a/backend/services/emailService.js
+++ b/backend/services/emailService.js
@@ -52,6 +52,8 @@ class EmailService {
"payoutReceivedToOwner.html",
"firstListingCelebrationToOwner.html",
"alphaInvitationToUser.html",
+ "feedbackConfirmationToUser.html",
+ "feedbackNotificationToAdmin.html",
];
for (const templateFile of templateFiles) {
@@ -443,6 +445,40 @@ class EmailService {
View My Listings
`
),
+
+ feedbackConfirmationToUser: baseTemplate.replace(
+ "{{content}}",
+ `
+ Hi {{userName}},
+ Thank You for Your Feedback!
+ We've received your feedback and our team will review it carefully.
+
+ {{feedbackText}}
+
+ Submitted: {{submittedAt}}
+ Your input helps us improve RentAll for everyone. We take all feedback seriously and use it to make the platform better.
+ If your feedback requires a response, our team will reach out to you directly.
+ `
+ ),
+
+ feedbackNotificationToAdmin: baseTemplate.replace(
+ "{{content}}",
+ `
+ New Feedback Received
+ From: {{userName}} ({{userEmail}})
+ User ID: {{userId}}
+ Submitted: {{submittedAt}}
+ Feedback Content
+
+ {{feedbackText}}
+
+ Technical Context
+ Feedback ID: {{feedbackId}}
+ Page URL: {{url}}
+ User Agent: {{userAgent}}
+ Please review this feedback and take appropriate action if needed.
+ `
+ ),
};
return (
@@ -1750,6 +1786,69 @@ class EmailService {
return results;
}
+
+ async sendFeedbackConfirmation(user, feedback) {
+ const submittedAt = new Date(feedback.createdAt).toLocaleString("en-US", {
+ dateStyle: "long",
+ timeStyle: "short",
+ });
+
+ const variables = {
+ userName: user.firstName || "there",
+ userEmail: user.email,
+ feedbackText: feedback.feedbackText,
+ submittedAt: submittedAt,
+ year: new Date().getFullYear(),
+ };
+
+ const htmlContent = this.renderTemplate(
+ "feedbackConfirmationToUser",
+ variables
+ );
+
+ return await this.sendEmail(
+ user.email,
+ "Thank You for Your Feedback - RentAll",
+ htmlContent
+ );
+ }
+
+ async sendFeedbackNotificationToAdmin(user, feedback) {
+ const adminEmail = process.env.FEEDBACK_EMAIL || process.env.CUSTOMER_SUPPORT_EMAIL;
+
+ if (!adminEmail) {
+ console.warn("No admin email configured for feedback notifications");
+ return { success: false, error: "No admin email configured" };
+ }
+
+ const submittedAt = new Date(feedback.createdAt).toLocaleString("en-US", {
+ dateStyle: "long",
+ timeStyle: "short",
+ });
+
+ const variables = {
+ userName: `${user.firstName} ${user.lastName}`.trim() || "Unknown User",
+ userEmail: user.email,
+ userId: user.id,
+ feedbackText: feedback.feedbackText,
+ feedbackId: feedback.id,
+ url: feedback.url || "Not provided",
+ userAgent: feedback.userAgent || "Not provided",
+ submittedAt: submittedAt,
+ year: new Date().getFullYear(),
+ };
+
+ const htmlContent = this.renderTemplate(
+ "feedbackNotificationToAdmin",
+ variables
+ );
+
+ return await this.sendEmail(
+ adminEmail,
+ `New Feedback from ${user.firstName} ${user.lastName}`,
+ htmlContent
+ );
+ }
}
module.exports = new EmailService();
diff --git a/backend/templates/emails/feedbackConfirmationToUser.html b/backend/templates/emails/feedbackConfirmationToUser.html
new file mode 100644
index 0000000..d8c47c0
--- /dev/null
+++ b/backend/templates/emails/feedbackConfirmationToUser.html
@@ -0,0 +1,117 @@
+
+
+
+
+
+ Feedback Received - RentAll
+
+
+
+
+
+
+
Hi {{userName}},
+
+
+
✓
+
Thank You for Your Feedback!
+
We've received your feedback and our team will review it carefully.
+
+
+
Your Feedback
+
+ {{feedbackText}}
+
+
+
Submitted: {{submittedAt}}
+
+
Your input helps us improve RentAll for everyone. We take all feedback seriously and use it to make the platform better.
+
+
If your feedback requires a response, our team will reach out to you directly at {{userEmail}}.
+
+
Want to share more thoughts? Feel free to send us additional feedback anytime through the app.
+
+
+
+
+
diff --git a/backend/templates/emails/feedbackNotificationToAdmin.html b/backend/templates/emails/feedbackNotificationToAdmin.html
new file mode 100644
index 0000000..ff1916e
--- /dev/null
+++ b/backend/templates/emails/feedbackNotificationToAdmin.html
@@ -0,0 +1,159 @@
+
+
+
+
+
+ New Feedback Received - RentAll
+
+
+
+
+
+
+
+
New Feedback Received
+
Submitted at: {{submittedAt}}
+
+
+
User Information
+
+
+ | Name |
+ {{userName}} |
+
+
+ | Email |
+ {{userEmail}} |
+
+
+ | User ID |
+ {{userId}} |
+
+
+
+
+
Feedback Content
+
{{feedbackText}}
+
+
+
Technical Context
+
+
+ | Feedback ID |
+ {{feedbackId}} |
+
+
+ | Page URL |
+ {{url}} |
+
+
+ | User Agent |
+ {{userAgent}} |
+
+
+ | Submitted At |
+ {{submittedAt}} |
+
+
+
+
Action Required: Please review this feedback and take appropriate action. If a response is needed, contact the user directly at {{userEmail}}.
+
+
+
+
+
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 7db69f2..7aacfb0 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -5,6 +5,7 @@ import Navbar from './components/Navbar';
import Footer from './components/Footer';
import AuthModal from './components/AuthModal';
import AlphaGate from './components/AlphaGate';
+import FeedbackButton from './components/FeedbackButton';
import Home from './pages/Home';
import GoogleCallback from './pages/GoogleCallback';
import VerifyEmail from './pages/VerifyEmail';
@@ -32,7 +33,7 @@ import './App.css';
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:5001';
const AppContent: React.FC = () => {
- const { showAuthModal, authModalMode, closeAuthModal } = useAuth();
+ const { showAuthModal, authModalMode, closeAuthModal, user } = useAuth();
const [hasAlphaAccess, setHasAlphaAccess] = useState(null);
const [checkingAccess, setCheckingAccess] = useState(true);
@@ -192,6 +193,9 @@ const AppContent: React.FC = () => {
onHide={closeAuthModal}
initialMode={authModalMode}
/>
+
+ {/* Show feedback button for authenticated users */}
+ {user && }
>
);
};
diff --git a/frontend/src/components/FeedbackButton.tsx b/frontend/src/components/FeedbackButton.tsx
new file mode 100644
index 0000000..23f7758
--- /dev/null
+++ b/frontend/src/components/FeedbackButton.tsx
@@ -0,0 +1,75 @@
+import React, { useState } from 'react';
+import FeedbackModal from './FeedbackModal';
+
+const FeedbackButton: React.FC = () => {
+ const [showPanel, setShowPanel] = useState(false);
+
+ return (
+ <>
+
+
+ setShowPanel(false)} />
+
+
+ >
+ );
+};
+
+export default FeedbackButton;
diff --git a/frontend/src/components/FeedbackModal.tsx b/frontend/src/components/FeedbackModal.tsx
new file mode 100644
index 0000000..ad585e9
--- /dev/null
+++ b/frontend/src/components/FeedbackModal.tsx
@@ -0,0 +1,217 @@
+import React, { useState, useEffect } from "react";
+import { feedbackAPI } from "../services/api";
+
+interface FeedbackModalProps {
+ show: boolean;
+ onClose: () => void;
+}
+
+const FeedbackModal: React.FC = ({ show, onClose }) => {
+ const [feedbackText, setFeedbackText] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const [success, setSuccess] = useState(false);
+
+ const MIN_LENGTH = 5;
+ const MAX_LENGTH = 5000;
+ const charCount = feedbackText.length;
+ const isValid = charCount >= MIN_LENGTH && charCount <= MAX_LENGTH;
+
+ useEffect(() => {
+ if (!show) {
+ // Reset form when modal closes
+ setTimeout(() => {
+ setFeedbackText("");
+ setError("");
+ setSuccess(false);
+ }, 300);
+ }
+ }, [show]);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!isValid) {
+ setError(
+ `Feedback must be between ${MIN_LENGTH} and ${MAX_LENGTH} characters`
+ );
+ return;
+ }
+
+ setLoading(true);
+ setError("");
+
+ try {
+ // Capture the current URL
+ const currentUrl = window.location.href;
+
+ await feedbackAPI.submitFeedback({
+ feedbackText: feedbackText.trim(),
+ url: currentUrl,
+ });
+
+ setSuccess(true);
+ setLoading(false);
+ } catch (err: any) {
+ setError(
+ err.response?.data?.error ||
+ "Failed to submit feedback. Please try again."
+ );
+ setLoading(false);
+ }
+ };
+
+ if (!show) return null;
+
+ return (
+ <>
+
+
+
+
Share Feedback
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default FeedbackModal;
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 6909b30..d8c7fe1 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -293,4 +293,9 @@ export const conditionCheckAPI = {
getAvailableChecks: () => api.get("/condition-checks"),
};
+export const feedbackAPI = {
+ submitFeedback: (data: { feedbackText: string; url?: string }) =>
+ api.post("/feedback", data),
+};
+
export default api;
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index f64f7ff..230c0f5 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -293,3 +293,13 @@ export interface RefundPreview {
reason: string;
totalAmount: number;
}
+
+export interface Feedback {
+ id: string;
+ userId: string;
+ feedbackText: string;
+ userAgent?: string;
+ url?: string;
+ createdAt: string;
+ updatedAt: string;
+}