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

+ + +

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}}
+ + + +

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
+ +
+ +
+
+ {success ? ( +
+
Thank you!
+

+ Your feedback has been submitted successfully! We appreciate + you making Community Rentals better! +

+
+ ) : ( + <> +

+ Share your thoughts, report bugs, or suggest improvements. + Your feedback helps us make RentAll better for everyone! +

+ + {error && ( +
+ {error} +
+ )} + +
+