email verification flow updated
This commit is contained in:
@@ -34,7 +34,6 @@ const dbConfig = {
|
||||
// Configuration for Sequelize CLI (supports multiple environments)
|
||||
// All environments use the same configuration from environment variables
|
||||
const cliConfig = {
|
||||
development: dbConfig,
|
||||
dev: dbConfig,
|
||||
test: dbConfig,
|
||||
qa: dbConfig,
|
||||
|
||||
@@ -162,6 +162,18 @@ const authRateLimiters = {
|
||||
legacyHeaders: false,
|
||||
}),
|
||||
|
||||
// Email verification rate limiter - protect against brute force on 6-digit codes
|
||||
emailVerification: rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 10, // 10 verification attempts per 15 minutes per IP
|
||||
message: {
|
||||
error: "Too many verification attempts. Please try again later.",
|
||||
retryAfter: 900,
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
}),
|
||||
|
||||
// General API rate limiter
|
||||
general: rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
@@ -186,6 +198,7 @@ module.exports = {
|
||||
registerLimiter: authRateLimiters.register,
|
||||
passwordResetLimiter: authRateLimiters.passwordReset,
|
||||
alphaCodeValidationLimiter: authRateLimiters.alphaCodeValidation,
|
||||
emailVerificationLimiter: authRateLimiters.emailVerification,
|
||||
generalLimiter: authRateLimiters.general,
|
||||
|
||||
// Burst protection
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.addColumn("Users", "verificationAttempts", {
|
||||
type: Sequelize.INTEGER,
|
||||
defaultValue: 0,
|
||||
allowNull: true,
|
||||
});
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeColumn("Users", "verificationAttempts");
|
||||
},
|
||||
};
|
||||
@@ -146,6 +146,11 @@ const User = sequelize.define(
|
||||
max: 100,
|
||||
},
|
||||
},
|
||||
verificationAttempts: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0,
|
||||
allowNull: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
hooks: {
|
||||
@@ -208,31 +213,64 @@ User.prototype.resetLoginAttempts = async function () {
|
||||
};
|
||||
|
||||
// Email verification methods
|
||||
// Maximum verification attempts before requiring a new code
|
||||
const MAX_VERIFICATION_ATTEMPTS = 5;
|
||||
|
||||
User.prototype.generateVerificationToken = async function () {
|
||||
const crypto = require("crypto");
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
// Generate 6-digit numeric code (100000-999999)
|
||||
const code = crypto.randomInt(100000, 999999).toString();
|
||||
const expiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
||||
|
||||
return this.update({
|
||||
verificationToken: token,
|
||||
verificationToken: code,
|
||||
verificationTokenExpiry: expiry,
|
||||
verificationAttempts: 0, // Reset attempts on new code
|
||||
});
|
||||
};
|
||||
|
||||
User.prototype.isVerificationTokenValid = function (token) {
|
||||
const crypto = require("crypto");
|
||||
|
||||
if (!this.verificationToken || !this.verificationTokenExpiry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.verificationToken !== token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if (new Date() > new Date(this.verificationTokenExpiry)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
// Validate 6-digit format
|
||||
if (!/^\d{6}$/.test(token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use timing-safe comparison to prevent timing attacks
|
||||
try {
|
||||
const inputBuffer = Buffer.from(token);
|
||||
const storedBuffer = Buffer.from(this.verificationToken);
|
||||
|
||||
if (inputBuffer.length !== storedBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return crypto.timingSafeEqual(inputBuffer, storedBuffer);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if too many verification attempts
|
||||
User.prototype.isVerificationLocked = function () {
|
||||
return (this.verificationAttempts || 0) >= MAX_VERIFICATION_ATTEMPTS;
|
||||
};
|
||||
|
||||
// Increment verification attempts
|
||||
User.prototype.incrementVerificationAttempts = async function () {
|
||||
const newAttempts = (this.verificationAttempts || 0) + 1;
|
||||
await this.update({ verificationAttempts: newAttempts });
|
||||
return newAttempts;
|
||||
};
|
||||
|
||||
User.prototype.verifyEmail = async function () {
|
||||
@@ -241,6 +279,7 @@ User.prototype.verifyEmail = async function () {
|
||||
verifiedAt: new Date(),
|
||||
verificationToken: null,
|
||||
verificationTokenExpiry: null,
|
||||
verificationAttempts: 0,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@ const {
|
||||
loginLimiter,
|
||||
registerLimiter,
|
||||
passwordResetLimiter,
|
||||
emailVerificationLimiter,
|
||||
} = require("../middleware/rateLimiter");
|
||||
const { authenticateToken } = require("../middleware/auth");
|
||||
const router = express.Router();
|
||||
|
||||
const googleClient = new OAuth2Client(
|
||||
@@ -43,8 +45,7 @@ router.post(
|
||||
validateRegistration,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { email, password, firstName, lastName, phone } =
|
||||
req.body;
|
||||
const { email, password, firstName, lastName, phone } = req.body;
|
||||
|
||||
const existingUser = await User.findOne({
|
||||
where: { email },
|
||||
@@ -64,7 +65,7 @@ router.post(
|
||||
|
||||
// Alpha access validation
|
||||
let alphaInvitation = null;
|
||||
if (process.env.ALPHA_TESTING_ENABLED === 'true') {
|
||||
if (process.env.ALPHA_TESTING_ENABLED === "true") {
|
||||
if (req.cookies && req.cookies.alphaAccessCode) {
|
||||
const { code } = req.cookies.alphaAccessCode;
|
||||
if (code) {
|
||||
@@ -88,7 +89,8 @@ router.post(
|
||||
|
||||
if (!alphaInvitation) {
|
||||
return res.status(403).json({
|
||||
error: "Alpha access required. Please enter your invitation code first.",
|
||||
error:
|
||||
"Alpha access required. Please enter your invitation code first.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -116,7 +118,10 @@ router.post(
|
||||
// Send verification email (don't block registration if email fails)
|
||||
let verificationEmailSent = false;
|
||||
try {
|
||||
await emailServices.auth.sendVerificationEmail(user, user.verificationToken);
|
||||
await emailServices.auth.sendVerificationEmail(
|
||||
user,
|
||||
user.verificationToken
|
||||
);
|
||||
verificationEmailSent = true;
|
||||
} catch (emailError) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
@@ -200,7 +205,10 @@ router.post(
|
||||
const user = await User.findOne({ where: { email } });
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: "Invalid credentials" });
|
||||
return res.status(401).json({
|
||||
error:
|
||||
"Unable to log in. Please check your email and password, or create an account.",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if account is locked
|
||||
@@ -217,7 +225,10 @@ router.post(
|
||||
if (!isPasswordValid) {
|
||||
// Increment login attempts
|
||||
await user.incLoginAttempts();
|
||||
return res.status(401).json({ error: "Invalid credentials" });
|
||||
return res.status(401).json({
|
||||
error:
|
||||
"Unable to log in. Please check your email and password, or create an account.",
|
||||
});
|
||||
}
|
||||
|
||||
// Reset login attempts on successful login
|
||||
@@ -322,7 +333,8 @@ router.post(
|
||||
|
||||
if (!email) {
|
||||
return res.status(400).json({
|
||||
error: "Email permission is required to continue. Please grant email access when signing in with Google and try again."
|
||||
error:
|
||||
"Email permission is required to continue. Please grant email access when signing in with Google and try again.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -332,18 +344,22 @@ router.post(
|
||||
let lastName = familyName;
|
||||
|
||||
if (!firstName || !lastName) {
|
||||
const emailUsername = email.split('@')[0];
|
||||
const emailUsername = email.split("@")[0];
|
||||
// Try to split email username by common separators
|
||||
const nameParts = emailUsername.split(/[._-]/);
|
||||
|
||||
if (!firstName) {
|
||||
firstName = nameParts[0] ? nameParts[0].charAt(0).toUpperCase() + nameParts[0].slice(1) : 'Google';
|
||||
firstName = nameParts[0]
|
||||
? nameParts[0].charAt(0).toUpperCase() + nameParts[0].slice(1)
|
||||
: "Google";
|
||||
}
|
||||
|
||||
if (!lastName) {
|
||||
lastName = nameParts.length > 1
|
||||
? nameParts[nameParts.length - 1].charAt(0).toUpperCase() + nameParts[nameParts.length - 1].slice(1)
|
||||
: 'User';
|
||||
lastName =
|
||||
nameParts.length > 1
|
||||
? nameParts[nameParts.length - 1].charAt(0).toUpperCase() +
|
||||
nameParts[nameParts.length - 1].slice(1)
|
||||
: "User";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,7 +391,7 @@ router.post(
|
||||
});
|
||||
|
||||
// Check if there's an alpha invitation for this email
|
||||
if (process.env.ALPHA_TESTING_ENABLED === 'true') {
|
||||
if (process.env.ALPHA_TESTING_ENABLED === "true") {
|
||||
const alphaInvitation = await AlphaInvitation.findOne({
|
||||
where: { email: email.toLowerCase().trim() },
|
||||
});
|
||||
@@ -467,73 +483,121 @@ router.post(
|
||||
);
|
||||
|
||||
// Email verification endpoint
|
||||
router.post("/verify-email", sanitizeInput, async (req, res) => {
|
||||
try {
|
||||
const { token } = req.body;
|
||||
router.post(
|
||||
"/verify-email",
|
||||
emailVerificationLimiter,
|
||||
authenticateToken,
|
||||
sanitizeInput,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { code } = req.body;
|
||||
|
||||
if (!token) {
|
||||
return res.status(400).json({
|
||||
error: "Verification token required",
|
||||
code: "TOKEN_REQUIRED",
|
||||
});
|
||||
}
|
||||
if (!code) {
|
||||
return res.status(400).json({
|
||||
error: "Verification code required",
|
||||
code: "CODE_REQUIRED",
|
||||
});
|
||||
}
|
||||
|
||||
// Find user with this verification token
|
||||
const user = await User.findOne({
|
||||
where: { verificationToken: token },
|
||||
});
|
||||
// Validate 6-digit format
|
||||
if (!/^\d{6}$/.test(code)) {
|
||||
return res.status(400).json({
|
||||
error: "Verification code must be 6 digits",
|
||||
code: "INVALID_CODE_FORMAT",
|
||||
});
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid verification token",
|
||||
code: "VERIFICATION_TOKEN_INVALID",
|
||||
});
|
||||
}
|
||||
// Get the authenticated user
|
||||
const user = await User.findByPk(req.user.id);
|
||||
|
||||
// Check if already verified
|
||||
if (user.isVerified) {
|
||||
return res.status(400).json({
|
||||
error: "Email already verified",
|
||||
code: "ALREADY_VERIFIED",
|
||||
});
|
||||
}
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
error: "User not found",
|
||||
code: "USER_NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if token is valid (not expired)
|
||||
if (!user.isVerificationTokenValid(token)) {
|
||||
return res.status(400).json({
|
||||
error: "Verification token has expired. Please request a new one.",
|
||||
code: "VERIFICATION_TOKEN_EXPIRED",
|
||||
});
|
||||
}
|
||||
// Check if already verified
|
||||
if (user.isVerified) {
|
||||
return res.status(400).json({
|
||||
error: "Email already verified",
|
||||
code: "ALREADY_VERIFIED",
|
||||
});
|
||||
}
|
||||
|
||||
// Verify the email
|
||||
await user.verifyEmail();
|
||||
// Check if too many failed attempts
|
||||
if (user.isVerificationLocked()) {
|
||||
return res.status(429).json({
|
||||
error: "Too many verification attempts. Please request a new code.",
|
||||
code: "TOO_MANY_ATTEMPTS",
|
||||
});
|
||||
}
|
||||
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Email verified successfully", {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
});
|
||||
// Check if user has a verification token
|
||||
if (!user.verificationToken) {
|
||||
return res.status(400).json({
|
||||
error: "No verification code found. Please request a new one.",
|
||||
code: "NO_CODE",
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: "Email verified successfully",
|
||||
user: {
|
||||
id: user.id,
|
||||
// Check if code is expired
|
||||
if (
|
||||
user.verificationTokenExpiry &&
|
||||
new Date() > new Date(user.verificationTokenExpiry)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: "Verification code has expired. Please request a new one.",
|
||||
code: "VERIFICATION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
// Validate the code
|
||||
if (!user.isVerificationTokenValid(input)) {
|
||||
// Increment failed attempts
|
||||
await user.incrementVerificationAttempts();
|
||||
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.warn("Invalid verification code attempt", {
|
||||
userId: user.id,
|
||||
attempts: user.verificationAttempts + 1,
|
||||
});
|
||||
|
||||
return res.status(400).json({
|
||||
error: "Invalid verification code",
|
||||
code: "VERIFICATION_INVALID",
|
||||
});
|
||||
}
|
||||
|
||||
// Verify the email
|
||||
await user.verifyEmail();
|
||||
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Email verified successfully", {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
isVerified: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Email verification error", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Email verification failed. Please try again.",
|
||||
});
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: "Email verified successfully",
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
isVerified: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Email verification error", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Email verification failed. Please try again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// Resend verification email endpoint
|
||||
router.post(
|
||||
@@ -575,7 +639,10 @@ router.post(
|
||||
|
||||
// Send verification email
|
||||
try {
|
||||
await emailServices.auth.sendVerificationEmail(user, user.verificationToken);
|
||||
await emailServices.auth.sendVerificationEmail(
|
||||
user,
|
||||
user.verificationToken
|
||||
);
|
||||
} catch (emailError) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Failed to resend verification email", {
|
||||
|
||||
@@ -241,6 +241,14 @@ router.get('/posts/:id', optionalAuth, async (req, res, next) => {
|
||||
// POST /api/forum/posts - Create new post
|
||||
router.post('/posts', authenticateToken, async (req, res, next) => {
|
||||
try {
|
||||
// Require email verification
|
||||
if (!req.user.isVerified) {
|
||||
return res.status(403).json({
|
||||
error: "Please verify your email address before creating forum posts.",
|
||||
code: "EMAIL_NOT_VERIFIED"
|
||||
});
|
||||
}
|
||||
|
||||
let { title, content, category, tags, zipCode, latitude: providedLat, longitude: providedLng, imageFilenames: rawImageFilenames } = req.body;
|
||||
|
||||
// Ensure imageFilenames is an array and validate S3 keys
|
||||
@@ -490,6 +498,14 @@ router.post('/posts', authenticateToken, async (req, res, next) => {
|
||||
// PUT /api/forum/posts/:id - Update post
|
||||
router.put('/posts/:id', authenticateToken, async (req, res, next) => {
|
||||
try {
|
||||
// Require email verification
|
||||
if (!req.user.isVerified) {
|
||||
return res.status(403).json({
|
||||
error: "Please verify your email address before editing forum posts.",
|
||||
code: "EMAIL_NOT_VERIFIED"
|
||||
});
|
||||
}
|
||||
|
||||
const post = await ForumPost.findByPk(req.params.id);
|
||||
|
||||
if (!post) {
|
||||
@@ -934,6 +950,14 @@ router.patch('/posts/:id/accept-answer', authenticateToken, async (req, res, nex
|
||||
// POST /api/forum/posts/:id/comments - Add comment/reply
|
||||
router.post('/posts/:id/comments', authenticateToken, async (req, res, next) => {
|
||||
try {
|
||||
// Require email verification
|
||||
if (!req.user.isVerified) {
|
||||
return res.status(403).json({
|
||||
error: "Please verify your email address before commenting.",
|
||||
code: "EMAIL_NOT_VERIFIED"
|
||||
});
|
||||
}
|
||||
|
||||
// Support both parentId (new) and parentCommentId (legacy) for backwards compatibility
|
||||
const { content, parentId, parentCommentId, imageFilenames: rawImageFilenames } = req.body;
|
||||
const parentIdResolved = parentId || parentCommentId;
|
||||
@@ -1111,6 +1135,14 @@ router.post('/posts/:id/comments', authenticateToken, async (req, res, next) =>
|
||||
// PUT /api/forum/comments/:id - Edit comment
|
||||
router.put('/comments/:id', authenticateToken, async (req, res, next) => {
|
||||
try {
|
||||
// Require email verification
|
||||
if (!req.user.isVerified) {
|
||||
return res.status(403).json({
|
||||
error: "Please verify your email address before editing comments.",
|
||||
code: "EMAIL_NOT_VERIFIED"
|
||||
});
|
||||
}
|
||||
|
||||
const { content, imageFilenames: rawImageFilenames } = req.body;
|
||||
const comment = await ForumComment.findByPk(req.params.id);
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
const express = require("express");
|
||||
const { Op } = require("sequelize");
|
||||
const { Rental, Item, User } = require("../models"); // Import from models/index.js to get models with associations
|
||||
const { authenticateToken, requireVerifiedEmail } = require("../middleware/auth");
|
||||
const {
|
||||
authenticateToken,
|
||||
requireVerifiedEmail,
|
||||
} = require("../middleware/auth");
|
||||
const FeeCalculator = require("../utils/feeCalculator");
|
||||
const RentalDurationCalculator = require("../utils/rentalDurationCalculator");
|
||||
const RefundService = require("../services/refundService");
|
||||
@@ -304,7 +307,11 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
|
||||
|
||||
// Send rental request notification to owner
|
||||
try {
|
||||
await emailServices.rentalFlow.sendRentalRequestEmail(rentalWithDetails.owner, rentalWithDetails.renter, rentalWithDetails);
|
||||
await emailServices.rentalFlow.sendRentalRequestEmail(
|
||||
rentalWithDetails.owner,
|
||||
rentalWithDetails.renter,
|
||||
rentalWithDetails
|
||||
);
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Rental request notification sent to owner", {
|
||||
rentalId: rental.id,
|
||||
@@ -322,7 +329,10 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
|
||||
|
||||
// Send rental request confirmation to renter
|
||||
try {
|
||||
await emailServices.rentalFlow.sendRentalRequestConfirmationEmail(rentalWithDetails.renter, rentalWithDetails);
|
||||
await emailServices.rentalFlow.sendRentalRequestConfirmationEmail(
|
||||
rentalWithDetails.renter,
|
||||
rentalWithDetails
|
||||
);
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Rental request confirmation sent to renter", {
|
||||
rentalId: rental.id,
|
||||
@@ -358,12 +368,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
||||
{
|
||||
model: User,
|
||||
as: "renter",
|
||||
attributes: [
|
||||
"id",
|
||||
"firstName",
|
||||
"lastName",
|
||||
"stripeCustomerId",
|
||||
],
|
||||
attributes: ["id", "firstName", "lastName", "stripeCustomerId"],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -445,7 +450,11 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
||||
// Send confirmation emails
|
||||
// Send approval confirmation to owner with Stripe reminder
|
||||
try {
|
||||
await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(updatedRental.owner, updatedRental.renter, updatedRental);
|
||||
await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(
|
||||
updatedRental.owner,
|
||||
updatedRental.renter,
|
||||
updatedRental
|
||||
);
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Rental approval confirmation sent to owner", {
|
||||
rentalId: updatedRental.id,
|
||||
@@ -453,11 +462,14 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
||||
});
|
||||
} catch (emailError) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Failed to send rental approval confirmation email to owner", {
|
||||
error: emailError.message,
|
||||
rentalId: updatedRental.id,
|
||||
ownerId: updatedRental.ownerId,
|
||||
});
|
||||
reqLogger.error(
|
||||
"Failed to send rental approval confirmation email to owner",
|
||||
{
|
||||
error: emailError.message,
|
||||
rentalId: updatedRental.id,
|
||||
ownerId: updatedRental.ownerId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Send rental confirmation to renter with payment receipt
|
||||
@@ -489,11 +501,14 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
||||
}
|
||||
} catch (emailError) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Failed to send rental confirmation email to renter", {
|
||||
error: emailError.message,
|
||||
rentalId: updatedRental.id,
|
||||
renterId: updatedRental.renterId,
|
||||
});
|
||||
reqLogger.error(
|
||||
"Failed to send rental confirmation email to renter",
|
||||
{
|
||||
error: emailError.message,
|
||||
rentalId: updatedRental.id,
|
||||
renterId: updatedRental.renterId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
res.json(updatedRental);
|
||||
@@ -537,7 +552,11 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
||||
// Send confirmation emails
|
||||
// Send approval confirmation to owner (for free rentals, no Stripe reminder shown)
|
||||
try {
|
||||
await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(updatedRental.owner, updatedRental.renter, updatedRental);
|
||||
await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(
|
||||
updatedRental.owner,
|
||||
updatedRental.renter,
|
||||
updatedRental
|
||||
);
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Rental approval confirmation sent to owner", {
|
||||
rentalId: updatedRental.id,
|
||||
@@ -545,11 +564,14 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
||||
});
|
||||
} catch (emailError) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Failed to send rental approval confirmation email to owner", {
|
||||
error: emailError.message,
|
||||
rentalId: updatedRental.id,
|
||||
ownerId: updatedRental.ownerId,
|
||||
});
|
||||
reqLogger.error(
|
||||
"Failed to send rental approval confirmation email to owner",
|
||||
{
|
||||
error: emailError.message,
|
||||
rentalId: updatedRental.id,
|
||||
ownerId: updatedRental.ownerId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Send rental confirmation to renter
|
||||
@@ -581,11 +603,14 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
||||
}
|
||||
} catch (emailError) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Failed to send rental confirmation email to renter", {
|
||||
error: emailError.message,
|
||||
rentalId: updatedRental.id,
|
||||
renterId: updatedRental.renterId,
|
||||
});
|
||||
reqLogger.error(
|
||||
"Failed to send rental confirmation email to renter",
|
||||
{
|
||||
error: emailError.message,
|
||||
rentalId: updatedRental.id,
|
||||
renterId: updatedRental.renterId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
res.json(updatedRental);
|
||||
@@ -687,7 +712,11 @@ router.put("/:id/decline", authenticateToken, async (req, res) => {
|
||||
|
||||
// Send decline notification email to renter
|
||||
try {
|
||||
await emailServices.rentalFlow.sendRentalDeclinedEmail(updatedRental.renter, updatedRental, reason);
|
||||
await emailServices.rentalFlow.sendRentalDeclinedEmail(
|
||||
updatedRental.renter,
|
||||
updatedRental,
|
||||
reason
|
||||
);
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Rental decline notification sent to renter", {
|
||||
rentalId: rental.id,
|
||||
@@ -904,7 +933,7 @@ router.post("/cost-preview", authenticateToken, async (req, res) => {
|
||||
// Validate date range
|
||||
if (rentalEndDateTime <= rentalStartDateTime) {
|
||||
return res.status(400).json({
|
||||
error: "End date/time must be after start date/time",
|
||||
error: "End must be after start",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -987,44 +1016,50 @@ router.get("/:id/refund-preview", authenticateToken, async (req, res, next) => {
|
||||
});
|
||||
|
||||
// Get late fee preview
|
||||
router.get("/:id/late-fee-preview", authenticateToken, async (req, res, next) => {
|
||||
try {
|
||||
const { actualReturnDateTime } = req.query;
|
||||
router.get(
|
||||
"/:id/late-fee-preview",
|
||||
authenticateToken,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { actualReturnDateTime } = req.query;
|
||||
|
||||
if (!actualReturnDateTime) {
|
||||
return res.status(400).json({ error: "actualReturnDateTime is required" });
|
||||
if (!actualReturnDateTime) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "actualReturnDateTime is required" });
|
||||
}
|
||||
|
||||
const rental = await Rental.findByPk(req.params.id, {
|
||||
include: [{ model: Item, as: "item" }],
|
||||
});
|
||||
|
||||
if (!rental) {
|
||||
return res.status(404).json({ error: "Rental not found" });
|
||||
}
|
||||
|
||||
// Check authorization
|
||||
if (rental.ownerId !== req.user.id && rental.renterId !== req.user.id) {
|
||||
return res.status(403).json({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const lateCalculation = LateReturnService.calculateLateFee(
|
||||
rental,
|
||||
actualReturnDateTime
|
||||
);
|
||||
|
||||
res.json(lateCalculation);
|
||||
} catch (error) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Error getting late fee preview", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
rentalId: req.params.id,
|
||||
userId: req.user.id,
|
||||
});
|
||||
next(error);
|
||||
}
|
||||
|
||||
const rental = await Rental.findByPk(req.params.id, {
|
||||
include: [{ model: Item, as: "item" }],
|
||||
});
|
||||
|
||||
if (!rental) {
|
||||
return res.status(404).json({ error: "Rental not found" });
|
||||
}
|
||||
|
||||
// Check authorization
|
||||
if (rental.ownerId !== req.user.id && rental.renterId !== req.user.id) {
|
||||
return res.status(403).json({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const lateCalculation = LateReturnService.calculateLateFee(
|
||||
rental,
|
||||
actualReturnDateTime
|
||||
);
|
||||
|
||||
res.json(lateCalculation);
|
||||
} catch (error) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Error getting late fee preview", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
rentalId: req.params.id,
|
||||
userId: req.user.id,
|
||||
});
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// Cancel rental with refund processing
|
||||
router.post("/:id/cancel", authenticateToken, async (req, res, next) => {
|
||||
@@ -1156,7 +1191,11 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
|
||||
|
||||
// Send completion emails to both renter and owner
|
||||
try {
|
||||
await emailServices.rentalFlow.sendRentalCompletionEmails(rentalWithDetails.owner, rentalWithDetails.renter, rentalWithDetails);
|
||||
await emailServices.rentalFlow.sendRentalCompletionEmails(
|
||||
rentalWithDetails.owner,
|
||||
rentalWithDetails.renter,
|
||||
rentalWithDetails
|
||||
);
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Rental completion emails sent", {
|
||||
rentalId,
|
||||
@@ -1224,7 +1263,11 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
|
||||
// Send notification to customer service
|
||||
const owner = await User.findByPk(rental.ownerId);
|
||||
const renter = await User.findByPk(rental.renterId);
|
||||
await emailServices.customerService.sendLostItemToCustomerService(updatedRental, owner, renter);
|
||||
await emailServices.customerService.sendLostItemToCustomerService(
|
||||
updatedRental,
|
||||
owner,
|
||||
renter
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -1261,14 +1304,14 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
|
||||
|
||||
// Allowed fields for damage report (prevents mass assignment)
|
||||
const ALLOWED_DAMAGE_REPORT_FIELDS = [
|
||||
'description',
|
||||
'canBeFixed',
|
||||
'repairCost',
|
||||
'needsReplacement',
|
||||
'replacementCost',
|
||||
'proofOfOwnership',
|
||||
'actualReturnDateTime',
|
||||
'imageFilenames',
|
||||
"description",
|
||||
"canBeFixed",
|
||||
"repairCost",
|
||||
"needsReplacement",
|
||||
"replacementCost",
|
||||
"proofOfOwnership",
|
||||
"actualReturnDateTime",
|
||||
"imageFilenames",
|
||||
];
|
||||
|
||||
function extractAllowedDamageFields(body) {
|
||||
@@ -1295,9 +1338,13 @@ router.post("/:id/report-damage", authenticateToken, async (req, res, next) => {
|
||||
? damageInfo.imageFilenames
|
||||
: [];
|
||||
|
||||
const keyValidation = validateS3Keys(imageFilenamesArray, 'damage-reports', {
|
||||
maxKeys: IMAGE_LIMITS.damageReports,
|
||||
});
|
||||
const keyValidation = validateS3Keys(
|
||||
imageFilenamesArray,
|
||||
"damage-reports",
|
||||
{
|
||||
maxKeys: IMAGE_LIMITS.damageReports,
|
||||
}
|
||||
);
|
||||
if (!keyValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: keyValidation.error,
|
||||
|
||||
@@ -50,6 +50,7 @@ class AuthEmailService {
|
||||
const variables = {
|
||||
recipientName: user.firstName || "there",
|
||||
verificationUrl: verificationUrl,
|
||||
verificationCode: verificationToken, // 6-digit code for display in email
|
||||
};
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
|
||||
@@ -196,9 +196,29 @@
|
||||
|
||||
<h1>Verify Your Email Address</h1>
|
||||
|
||||
<p>Thank you for registering with RentAll! To complete your account setup and start renting items, please verify your email address by clicking the button below.</p>
|
||||
<p>Thank you for registering with RentAll! Use the verification code below to complete your account setup.</p>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<!-- Verification Code Display -->
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<p style="margin-bottom: 10px; color: #6c757d; font-size: 14px;">Your verification code is:</p>
|
||||
<div style="background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border-radius: 12px;
|
||||
padding: 20px 40px;
|
||||
display: inline-block;
|
||||
border: 2px dashed #28a745;">
|
||||
<span style="font-size: 36px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 8px;
|
||||
color: #28a745;
|
||||
font-family: 'Courier New', monospace;">{{verificationCode}}</span>
|
||||
</div>
|
||||
<p style="margin-top: 10px; font-size: 14px; color: #6c757d;">
|
||||
Enter this code in the app to verify your email
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin: 20px 0;">
|
||||
<p style="color: #6c757d; margin-bottom: 10px; font-size: 14px;">Or click the button below:</p>
|
||||
<a href="{{verificationUrl}}" class="button">Verify Email Address</a>
|
||||
</div>
|
||||
|
||||
@@ -210,7 +230,7 @@
|
||||
<p style="word-break: break-all; color: #667eea;">{{verificationUrl}}</p>
|
||||
|
||||
<div class="warning-box">
|
||||
<p><strong>This link will expire in 24 hours.</strong> If you need a new verification link, you can request one from your account settings.</p>
|
||||
<p><strong>This code will expire in 24 hours.</strong> If you need a new verification code, you can request one from your account settings.</p>
|
||||
</div>
|
||||
|
||||
<p><strong>Didn't create an account?</strong> If you didn't register for a RentAll account, you can safely ignore this email.</p>
|
||||
|
||||
Reference in New Issue
Block a user