text changes

This commit is contained in:
jackiettran
2026-01-21 19:20:07 -05:00
parent 420e0efeb4
commit 5d3c124d3e
31 changed files with 16387 additions and 4053 deletions

View File

@@ -10,7 +10,7 @@ module.exports = {
},
down: async (queryInterface, Sequelize) => {
// Revert to original VARCHAR(255)[] - note: this may fail if data exceeds 255 chars
// Revert to original VARCHAR(255)[]
await queryInterface.changeColumn("Items", "images", {
type: Sequelize.ARRAY(Sequelize.STRING),
defaultValue: [],

View File

@@ -20,7 +20,7 @@ module.exports = {
},
down: async (queryInterface, Sequelize) => {
// Revert to original VARCHAR(255) - note: this may fail if data exceeds 255 chars
// Revert to original VARCHAR(255)
await Promise.all([
queryInterface.changeColumn("Users", "profileImage", {
type: Sequelize.STRING,

View File

@@ -11,7 +11,7 @@ module.exports = {
down: async (queryInterface, Sequelize) => {
console.log(
"Note: PostgreSQL does not support removing ENUM values. " +
"PostgreSQL does not support removing ENUM values. " +
"'requires_action' will remain in the enum but will not be used.",
);
},

View File

@@ -265,7 +265,7 @@ const User = sequelize.define(
}
},
},
}
},
);
User.prototype.comparePassword = async function (password) {
@@ -457,7 +457,7 @@ User.prototype.unbanUser = async function () {
bannedAt: null,
bannedBy: null,
banReason: null,
// Note: We don't increment jwtVersion on unban - user will need to log in fresh
// We don't increment jwtVersion on unban - user will need to log in fresh
});
};
@@ -467,7 +467,7 @@ const TwoFactorService = require("../services/TwoFactorService");
// Store pending TOTP secret during setup
User.prototype.storePendingTotpSecret = async function (
encryptedSecret,
encryptedSecretIv
encryptedSecretIv,
) {
return this.update({
twoFactorSetupPendingSecret: encryptedSecret,
@@ -478,7 +478,7 @@ User.prototype.storePendingTotpSecret = async function (
// Enable TOTP 2FA after verification
User.prototype.enableTotp = async function (recoveryCodes) {
const hashedCodes = await Promise.all(
recoveryCodes.map((code) => bcrypt.hash(code, 12))
recoveryCodes.map((code) => bcrypt.hash(code, 12)),
);
// Store in structured format
@@ -506,7 +506,7 @@ User.prototype.enableTotp = async function (recoveryCodes) {
// Enable Email 2FA
User.prototype.enableEmailTwoFactor = async function (recoveryCodes) {
const hashedCodes = await Promise.all(
recoveryCodes.map((code) => bcrypt.hash(code, 12))
recoveryCodes.map((code) => bcrypt.hash(code, 12)),
);
// Store in structured format
@@ -563,7 +563,7 @@ User.prototype.verifyEmailOtp = function (inputCode) {
return TwoFactorService.verifyEmailOtp(
inputCode,
this.emailOtpCode,
this.emailOtpExpiry
this.emailOtpExpiry,
);
};
@@ -603,7 +603,9 @@ User.prototype.markTotpCodeUsed = async function (code) {
const codeHash = crypto.createHash("sha256").update(code).digest("hex");
recentCodes.unshift(codeHash);
// Keep only last 5 codes (covers about 2.5 minutes of 30-second windows)
await this.update({ recentTotpCodes: JSON.stringify(recentCodes.slice(0, 5)) });
await this.update({
recentTotpCodes: JSON.stringify(recentCodes.slice(0, 5)),
});
};
// Verify TOTP code with replay protection
@@ -615,18 +617,25 @@ User.prototype.verifyTotpCode = function (code) {
if (this.hasUsedTotpCode(code)) {
return false;
}
return TwoFactorService.verifyTotpCode(this.totpSecret, this.totpSecretIv, code);
return TwoFactorService.verifyTotpCode(
this.totpSecret,
this.totpSecretIv,
code,
);
};
// Verify pending TOTP code (during setup)
User.prototype.verifyPendingTotpCode = function (code) {
if (!this.twoFactorSetupPendingSecret || !this.twoFactorSetupPendingSecretIv) {
if (
!this.twoFactorSetupPendingSecret ||
!this.twoFactorSetupPendingSecretIv
) {
return false;
}
return TwoFactorService.verifyTotpCode(
this.twoFactorSetupPendingSecret,
this.twoFactorSetupPendingSecretIv,
code
code,
);
};
@@ -639,7 +648,7 @@ User.prototype.useRecoveryCode = async function (inputCode) {
const recoveryData = JSON.parse(this.recoveryCodesHash);
const { valid, index } = await TwoFactorService.verifyRecoveryCode(
inputCode,
recoveryData
recoveryData,
);
if (valid) {
@@ -661,7 +670,8 @@ User.prototype.useRecoveryCode = async function (inputCode) {
return {
valid,
remainingCodes: TwoFactorService.getRemainingRecoveryCodesCount(recoveryData),
remainingCodes:
TwoFactorService.getRemainingRecoveryCodesCount(recoveryData),
};
};

View File

@@ -269,11 +269,11 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
totalAmount = RentalDurationCalculator.calculateRentalCost(
rentalStartDateTime,
rentalEndDateTime,
item
item,
);
// Check for overlapping rentals using datetime ranges
// Note: "active" rentals are stored as "confirmed" with startDateTime in the past
// "active" rentals are stored as "confirmed" with startDateTime in the past
// Two ranges [A,B] and [C,D] overlap if and only if A < D AND C < B
// Here: existing rental [existingStart, existingEnd], new rental [rentalStartDateTime, rentalEndDateTime]
// Overlap: existingStart < rentalEndDateTime AND rentalStartDateTime < existingEnd
@@ -352,7 +352,7 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
await emailServices.rentalFlow.sendRentalRequestEmail(
rentalWithDetails.owner,
rentalWithDetails.renter,
rentalWithDetails
rentalWithDetails,
);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental request notification sent to owner", {
@@ -374,7 +374,7 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
try {
await emailServices.rentalFlow.sendRentalRequestConfirmationEmail(
rentalWithDetails.renter,
rentalWithDetails
rentalWithDetails,
);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental request confirmation sent to renter", {
@@ -474,7 +474,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
itemName: rental.item.name,
renterId: rental.renterId,
ownerId: rental.ownerId,
}
},
);
// Check if 3DS authentication is required
@@ -494,7 +494,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
itemName: rental.item.name,
ownerName: rental.owner.firstName,
amount: rental.totalAmount,
}
},
);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Authentication required email sent to renter", {
@@ -503,15 +503,12 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
});
} catch (emailError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error(
"Failed to send authentication required email",
{
error: emailError.message,
stack: emailError.stack,
rentalId: rental.id,
renterId: rental.renterId,
}
);
reqLogger.error("Failed to send authentication required email", {
error: emailError.message,
stack: emailError.stack,
rentalId: rental.id,
renterId: rental.renterId,
});
}
return res.status(402).json({
@@ -557,17 +554,14 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
// Create condition check reminder schedules
try {
await EventBridgeSchedulerService.createConditionCheckSchedules(
updatedRental
updatedRental,
);
} catch (schedulerError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error(
"Failed to create condition check schedules",
{
error: schedulerError.message,
rentalId: updatedRental.id,
}
);
reqLogger.error("Failed to create condition check schedules", {
error: schedulerError.message,
rentalId: updatedRental.id,
});
// Don't fail the confirmation - schedules are non-critical
}
@@ -577,7 +571,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(
updatedRental.owner,
updatedRental.renter,
updatedRental
updatedRental,
);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental approval confirmation sent to owner", {
@@ -593,7 +587,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
stack: emailError.stack,
rentalId: updatedRental.id,
ownerId: updatedRental.ownerId,
}
},
);
}
@@ -616,7 +610,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
renterNotification,
updatedRental,
renter.firstName,
true // isRenter = true to show payment receipt
true, // isRenter = true to show payment receipt
);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental confirmation sent to renter", {
@@ -633,7 +627,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
stack: emailError.stack,
rentalId: updatedRental.id,
renterId: updatedRental.renterId,
}
},
);
}
@@ -670,7 +664,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
itemName: rental.item.name,
declineReason: renterMessage,
rentalId: rental.id,
}
},
);
reqLogger.info("Payment declined email auto-sent to renter", {
rentalId: rental.id,
@@ -728,17 +722,14 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
// Create condition check reminder schedules
try {
await EventBridgeSchedulerService.createConditionCheckSchedules(
updatedRental
updatedRental,
);
} catch (schedulerError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error(
"Failed to create condition check schedules",
{
error: schedulerError.message,
rentalId: updatedRental.id,
}
);
reqLogger.error("Failed to create condition check schedules", {
error: schedulerError.message,
rentalId: updatedRental.id,
});
// Don't fail the confirmation - schedules are non-critical
}
@@ -748,7 +739,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(
updatedRental.owner,
updatedRental.renter,
updatedRental
updatedRental,
);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental approval confirmation sent to owner", {
@@ -764,7 +755,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
stack: emailError.stack,
rentalId: updatedRental.id,
ownerId: updatedRental.ownerId,
}
},
);
}
@@ -787,7 +778,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
renterNotification,
updatedRental,
renter.firstName,
true // isRenter = true (for free rentals, shows "no payment required")
true, // isRenter = true (for free rentals, shows "no payment required")
);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental confirmation sent to renter", {
@@ -804,7 +795,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
stack: emailError.stack,
rentalId: updatedRental.id,
renterId: updatedRental.renterId,
}
},
);
}
@@ -910,7 +901,7 @@ router.put("/:id/decline", authenticateToken, async (req, res) => {
await emailServices.rentalFlow.sendRentalDeclinedEmail(
updatedRental.renter,
updatedRental,
reason
reason,
);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental decline notification sent to renter", {
@@ -1130,7 +1121,7 @@ router.post("/cost-preview", authenticateToken, async (req, res) => {
const totalAmount = RentalDurationCalculator.calculateRentalCost(
rentalStartDateTime,
rentalEndDateTime,
item
item,
);
// Calculate fees
@@ -1202,7 +1193,7 @@ router.get("/:id/refund-preview", authenticateToken, async (req, res, next) => {
try {
const preview = await RefundService.getRefundPreview(
req.params.id,
req.user.id
req.user.id,
);
res.json(preview);
} catch (error) {
@@ -1246,7 +1237,7 @@ router.get(
const lateCalculation = LateReturnService.calculateLateFee(
rental,
actualReturnDateTime
actualReturnDateTime,
);
res.json(lateCalculation);
@@ -1260,7 +1251,7 @@ router.get(
});
next(error);
}
}
},
);
// Cancel rental with refund processing
@@ -1276,7 +1267,7 @@ router.post("/:id/cancel", authenticateToken, async (req, res, next) => {
const result = await RefundService.processCancellation(
req.params.id,
req.user.id,
reason.trim()
reason.trim(),
);
// Return the updated rental with refund information
@@ -1302,7 +1293,7 @@ router.post("/:id/cancel", authenticateToken, async (req, res, next) => {
updatedRental.owner,
updatedRental.renter,
updatedRental,
result.refund
result.refund,
);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Cancellation emails sent", {
@@ -1403,7 +1394,7 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
await emailServices.rentalFlow.sendRentalCompletionEmails(
rentalWithDetails.owner,
rentalWithDetails.renter,
rentalWithDetails
rentalWithDetails,
);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental completion emails sent", {
@@ -1441,7 +1432,7 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
if (statusOptions?.returned_late && actualReturnDateTime) {
const lateReturnDamaged = await LateReturnService.processLateReturn(
rentalId,
actualReturnDateTime
actualReturnDateTime,
);
damageUpdates.status = "returned_late_and_damaged";
damageUpdates.lateFees = lateReturnDamaged.lateCalculation.lateFee;
@@ -1463,7 +1454,7 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
const lateReturn = await LateReturnService.processLateReturn(
rentalId,
actualReturnDateTime
actualReturnDateTime,
);
updatedRental = lateReturn.rental;
@@ -1484,7 +1475,7 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
await emailServices.customerService.sendLostItemToCustomerService(
updatedRental,
owner,
renter
renter,
);
break;
@@ -1562,7 +1553,7 @@ router.post("/:id/report-damage", authenticateToken, async (req, res, next) => {
"damage-reports",
{
maxKeys: IMAGE_LIMITS.damageReports,
}
},
);
if (!keyValidation.valid) {
return res.status(400).json({
@@ -1576,7 +1567,7 @@ router.post("/:id/report-damage", authenticateToken, async (req, res, next) => {
const result = await DamageAssessmentService.processDamageAssessment(
rentalId,
damageInfo,
userId
userId,
);
const reqLogger = logger.withRequestId(req.id);
@@ -1654,7 +1645,7 @@ router.put("/:id/payment-method", authenticateToken, async (req, res, next) => {
let paymentMethod;
try {
paymentMethod = await StripeService.getPaymentMethod(
stripePaymentMethodId
stripePaymentMethodId,
);
} catch {
return res.status(400).json({ error: "Invalid payment method" });
@@ -1699,7 +1690,7 @@ router.put("/:id/payment-method", authenticateToken, async (req, res, next) => {
status: "pending",
paymentStatus: "pending",
},
}
},
);
if (updateCount === 0) {
@@ -1725,7 +1716,7 @@ router.put("/:id/payment-method", authenticateToken, async (req, res, next) => {
itemName: rental.item.name,
rentalId: rental.id,
approvalUrl: `${process.env.FRONTEND_URL}/rentals/${rentalId}`,
}
},
);
} catch (emailError) {
// Don't fail the request if email fails
@@ -1781,7 +1772,7 @@ router.get(
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
const paymentIntent = await stripe.paymentIntents.retrieve(
rental.stripePaymentIntentId
rental.stripePaymentIntentId,
);
return res.json({
@@ -1798,7 +1789,7 @@ router.get(
});
next(error);
}
}
},
);
/**
@@ -1812,8 +1803,29 @@ router.post(
try {
const rental = await Rental.findByPk(req.params.id, {
include: [
{ model: User, as: "renter", attributes: ["id", "firstName", "lastName", "email", "stripeCustomerId"] },
{ model: User, as: "owner", attributes: ["id", "firstName", "lastName", "email", "stripeConnectedAccountId", "stripePayoutsEnabled"] },
{
model: User,
as: "renter",
attributes: [
"id",
"firstName",
"lastName",
"email",
"stripeCustomerId",
],
},
{
model: User,
as: "owner",
attributes: [
"id",
"firstName",
"lastName",
"email",
"stripeConnectedAccountId",
"stripePayoutsEnabled",
],
},
{ model: Item, as: "item" },
],
});
@@ -1837,7 +1849,7 @@ router.post(
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
const paymentIntent = await stripe.paymentIntents.retrieve(
rental.stripePaymentIntentId,
{ expand: ['latest_charge.payment_method_details'] }
{ expand: ["latest_charge.payment_method_details"] },
);
if (paymentIntent.status !== "succeeded") {
@@ -1864,7 +1876,8 @@ router.post(
paymentMethodLast4 = paymentMethodDetails.card?.last4 || null;
} else if (type === "us_bank_account") {
paymentMethodBrand = "bank_account";
paymentMethodLast4 = paymentMethodDetails.us_bank_account?.last4 || null;
paymentMethodLast4 =
paymentMethodDetails.us_bank_account?.last4 || null;
}
}
@@ -1882,13 +1895,10 @@ router.post(
await EventBridgeSchedulerService.createConditionCheckSchedules(rental);
} catch (schedulerError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error(
"Failed to create condition check schedules",
{
error: schedulerError.message,
rentalId: rental.id,
}
);
reqLogger.error("Failed to create condition check schedules", {
error: schedulerError.message,
rentalId: rental.id,
});
// Don't fail the confirmation - schedules are non-critical
}
@@ -1897,13 +1907,16 @@ router.post(
await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(
rental.owner,
rental.renter,
rental
rental,
);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental approval confirmation sent to owner (after 3DS)", {
rentalId: rental.id,
ownerId: rental.ownerId,
});
reqLogger.info(
"Rental approval confirmation sent to owner (after 3DS)",
{
rentalId: rental.id,
ownerId: rental.ownerId,
},
);
} catch (emailError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error(
@@ -1911,7 +1924,7 @@ router.post(
{
error: emailError.message,
rentalId: rental.id,
}
},
);
}
@@ -1929,7 +1942,7 @@ router.post(
renterNotification,
rental,
rental.renter.firstName,
true
true,
);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental confirmation sent to renter (after 3DS)", {
@@ -1938,17 +1951,17 @@ router.post(
});
} catch (emailError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error(
"Failed to send rental confirmation email after 3DS",
{
error: emailError.message,
rentalId: rental.id,
}
);
reqLogger.error("Failed to send rental confirmation email after 3DS", {
error: emailError.message,
rentalId: rental.id,
});
}
// Trigger payout if owner has payouts enabled
if (rental.owner.stripePayoutsEnabled && rental.owner.stripeConnectedAccountId) {
if (
rental.owner.stripePayoutsEnabled &&
rental.owner.stripeConnectedAccountId
) {
try {
await PayoutService.processRentalPayout(rental);
const reqLogger = logger.withRequestId(req.id);
@@ -1983,7 +1996,7 @@ router.post(
});
next(error);
}
}
},
);
module.exports = router;

View File

@@ -26,7 +26,7 @@ class LocationService {
// distance = 3959 * acos(cos(radians(lat1)) * cos(radians(lat2))
// * cos(radians(lng2) - radians(lng1))
// + sin(radians(lat1)) * sin(radians(lat2)))
// Note: 3959 is Earth's radius in miles
// 3959 is Earth's radius in miles
const query = `
SELECT * FROM (
SELECT

View File

@@ -1,283 +1,322 @@
<!DOCTYPE html>
<!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">
<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 Alpha Access Code - Village Share</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;
}
/* 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;
}
/* 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 */
/* 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 */
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #e0e7ff;
font-size: 14px;
margin-top: 8px;
}
/* Content */
.content {
padding: 40px 30px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Code box */
.code-box {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px;
padding: 30px;
margin: 30px 0;
text-align: center;
}
.code-label {
color: #e0e7ff;
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 12px;
}
.code {
font-family: "Courier New", Courier, monospace;
font-size: 32px;
font-weight: 700;
color: #ffffff;
letter-spacing: 4px;
margin: 10px 0;
user-select: all;
}
/* 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;
font-size: 14px;
}
.info-box p:last-child {
margin-bottom: 0;
}
.info-box ul {
margin: 10px 0 0 0;
padding-left: 20px;
color: #004085;
font-size: 14px;
}
.info-box li {
margin-bottom: 6px;
}
/* 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 {
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);
margin: 0;
border-radius: 0;
}
/* Header */
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px 30px;
text-align: center;
.header,
.content,
.footer {
padding: 20px;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #e0e7ff;
font-size: 14px;
margin-top: 8px;
}
/* Content */
.content {
padding: 40px 30px;
font-size: 28px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Code box */
.code-box {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px;
padding: 30px;
margin: 30px 0;
text-align: center;
}
.code-label {
color: #e0e7ff;
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 12px;
font-size: 22px;
}
.code {
font-family: 'Courier New', Courier, monospace;
font-size: 32px;
font-weight: 700;
color: #ffffff;
letter-spacing: 4px;
margin: 10px 0;
user-select: all;
font-size: 24px;
letter-spacing: 2px;
}
/* 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;
font-size: 14px;
}
.info-box p:last-child {
margin-bottom: 0;
}
.info-box ul {
margin: 10px 0 0 0;
padding-left: 20px;
color: #004085;
font-size: 14px;
}
.info-box li {
margin-bottom: 6px;
}
/* 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: 22px;
}
.code {
font-size: 24px;
letter-spacing: 2px;
}
.button {
display: block;
width: 100%;
box-sizing: border-box;
}
display: block;
width: 100%;
box-sizing: border-box;
}
}
</style>
</head>
<body>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Alpha Access Invitation</div>
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Alpha Access Invitation</div>
</div>
<div class="content">
<h1>Welcome to Alpha Testing!</h1>
<p>
Congratulations! You've been selected to participate in the exclusive
alpha testing program for Village Share, the community-powered rental
marketplace.
</p>
<p>
Your unique alpha access code is:
<strong style="font-family: monospace">{{code}}</strong>
</p>
<p>To get started:</p>
<div class="info-box">
<p><strong>Steps to Access:</strong></p>
<ul>
<li>
Visit
<a href="{{frontendUrl}}" style="color: #667eea; font-weight: 600"
>{{frontendUrl}}</a
>
</li>
<li>Enter your alpha access code when prompted</li>
<li>
Register with <strong>this email address</strong> ({{email}})
</li>
<li>Start exploring the platform!</li>
</ul>
</div>
<div class="content">
<h1>Welcome to Alpha Testing!</h1>
<p>Congratulations! You've been selected to participate in the exclusive alpha testing program for Village Share, the community-powered rental marketplace.</p>
<p>Your unique alpha access code is: <strong style="font-family: monospace;">{{code}}</strong></p>
<p>To get started:</p>
<div class="info-box">
<p><strong>Steps to Access:</strong></p>
<ul>
<li>Visit <a href="{{frontendUrl}}" style="color: #667eea; font-weight: 600;">{{frontendUrl}}</a></li>
<li>Enter your alpha access code when prompted</li>
<li>Register with <strong>this email address</strong> ({{email}})</li>
<li>Start exploring the platform!</li>
</ul>
</div>
<div style="text-align: center;">
<a href="{{frontendUrl}}" class="button">Access Village Share Alpha</a>
</div>
<p><strong>What to expect as an alpha tester:</strong></p>
<div class="info-box">
<ul>
<li>Early access to new features before public launch</li>
<li>Opportunity to shape the product with your feedback</li>
<li>Direct communication with the development team</li>
<li>Special recognition as an early supporter</li>
</ul>
</div>
<p><strong>Important notes:</strong></p>
<ul style="color: #6c757d; font-size: 14px;">
<li>Your code is tied to this email address only</li>
<li>This is a permanent access code (no expiration)</li>
<li>Please keep your code confidential</li>
<li>We value your feedback - let us know what you think!</li>
</ul>
<p>We're excited to have you as part of our alpha testing community. Your feedback will be invaluable in making Village Share the best it can be.</p>
<p>If you have any questions or encounter any issues, please don't hesitate to reach out to us.</p>
<p>Happy renting!</p>
<div style="text-align: center">
<a href="{{frontendUrl}}" class="button"
>Access Village Share Alpha</a
>
</div>
<div class="footer">
<p><strong>Village Share Alpha Testing Program</strong></p>
<p>Need help? Contact us at <a href="mailto:support@villageshare.app">support@villageshare.app</a></p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
<p><strong>What to expect as an alpha tester:</strong></p>
<div class="info-box">
<ul>
<li>Early access to new features before public launch</li>
<li>Opportunity to shape the product with your feedback</li>
<li>Direct communication with the development team</li>
<li>Special recognition as an early supporter</li>
</ul>
</div>
<p><strong>Important notes:</strong></p>
<ul style="color: #6c757d; font-size: 14px">
<li>Your code is tied to this email address only</li>
<li>This is a permanent access code (no expiration)</li>
<li>Please keep your code confidential</li>
<li>We value your feedback - let us know what you think!</li>
</ul>
<p>
We're excited to have you as part of our alpha testing community. Your
feedback will be invaluable in making Village Share the best it can
be.
</p>
<p>
If you have any questions or encounter any issues, please don't
hesitate to reach out to us.
</p>
<p>Happy renting!</p>
</div>
<div class="footer">
<p><strong>Village Share Alpha Testing Program</strong></p>
<p>
Need help? Contact us at
<a href="mailto:community-support@village-share.com"
>community-support@village-share.com</a
>
</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</body>
</html>

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
@@ -34,8 +34,9 @@
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
@@ -260,7 +261,8 @@
</p>
<p>
If you have any questions, please
<a href="mailto:support@villageshare.app">contact our support team</a
<a href="mailto:community-support@village-share.com"
>contact our support team</a
>.
</p>
</div>

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
@@ -34,8 +34,9 @@
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
@@ -246,8 +247,8 @@
<p>
<strong>Didn't change your password?</strong> If you did not make
this change, your account may be compromised. Please contact our
support team immediately at support@villageshare.app to secure your
account.
support team immediately at community-support@village-share.com to
secure your account.
</p>
</div>

View File

@@ -1,246 +1,279 @@
<!DOCTYPE html>
<!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">
<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>Personal Information Updated - Village Share</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;
}
/* 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;
}
/* 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 */
/* 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 */
.header {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #d4edda;
font-size: 14px;
margin-top: 8px;
}
/* Content */
.content {
padding: 40px 30px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Success box */
.success-box {
background-color: #d4edda;
border-left: 4px solid #28a745;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.success-box p {
margin: 0;
color: #155724;
font-size: 14px;
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0;
color: #004085;
font-size: 14px;
}
/* Security box */
.security-box {
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 15px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.security-box p {
margin: 0;
color: #721c24;
font-size: 14px;
}
/* Details table */
.details-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
.details-table td {
padding: 12px;
border-bottom: 1px solid #e9ecef;
}
.details-table td:first-child {
font-weight: 600;
color: #495057;
width: 40%;
}
.details-table td:last-child {
color: #6c757d;
}
/* 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 {
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);
margin: 0;
border-radius: 0;
}
/* Header */
.header {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
padding: 40px 30px;
text-align: center;
.header,
.content,
.footer {
padding: 20px;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #d4edda;
font-size: 14px;
margin-top: 8px;
}
/* Content */
.content {
padding: 40px 30px;
font-size: 28px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Success box */
.success-box {
background-color: #d4edda;
border-left: 4px solid #28a745;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.success-box p {
margin: 0;
color: #155724;
font-size: 14px;
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0;
color: #004085;
font-size: 14px;
}
/* Security box */
.security-box {
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 15px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.security-box p {
margin: 0;
color: #721c24;
font-size: 14px;
}
/* Details table */
.details-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
.details-table td {
padding: 12px;
border-bottom: 1px solid #e9ecef;
}
.details-table td:first-child {
font-weight: 600;
color: #495057;
width: 40%;
}
.details-table td:last-child {
color: #6c757d;
}
/* 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: 22px;
}
font-size: 22px;
}
}
</style>
</head>
<body>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Personal Information Updated</div>
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Personal Information Updated</div>
</div>
<div class="content">
<p>Hi {{recipientName}},</p>
<h1>Your Personal Information Has Been Updated</h1>
<div class="info-box">
<p>
<strong>Your account information was recently updated.</strong> This
email is to notify you that changes were made to your personal
information on your Village Share account.
</p>
</div>
<div class="content">
<p>Hi {{recipientName}},</p>
<p>
We're sending you this notification as part of our commitment to
keeping your account secure. If you made these changes, no further
action is required.
</p>
<h1>Your Personal Information Has Been Updated</h1>
<table class="details-table">
<tr>
<td>Date & Time:</td>
<td>{{timestamp}}</td>
</tr>
<tr>
<td>Account Email:</td>
<td>{{email}}</td>
</tr>
</table>
<div class="info-box">
<p><strong>Your account information was recently updated.</strong> This email is to notify you that changes were made to your personal information on your Village Share account.</p>
</div>
<p>We're sending you this notification as part of our commitment to keeping your account secure. If you made these changes, no further action is required.</p>
<table class="details-table">
<tr>
<td>Date & Time:</td>
<td>{{timestamp}}</td>
</tr>
<tr>
<td>Account Email:</td>
<td>{{email}}</td>
</tr>
</table>
<div class="security-box">
<p><strong>Didn't make these changes?</strong> If you did not update your personal information, your account may be compromised. Please contact our support team immediately at support@villageshare.app and consider changing your password.</p>
</div>
<div class="info-box">
<p><strong>Security tip:</strong> Regularly review your account information to ensure it's accurate and up to date. If you notice any suspicious activity, contact our support team right away.</p>
</div>
<p>Thanks for using Village Share!</p>
<div class="security-box">
<p>
<strong>Didn't make these changes?</strong> If you did not update
your personal information, your account may be compromised. Please
contact our support team immediately at
community-support@village-share.com and consider changing your
password.
</p>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>This is a security notification sent to confirm changes to your account. If you have any concerns about your account security, please contact our support team immediately.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
<div class="info-box">
<p>
<strong>Security tip:</strong> Regularly review your account
information to ensure it's accurate and up to date. If you notice
any suspicious activity, contact our support team right away.
</p>
</div>
<p>Thanks for using Village Share!</p>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>
This is a security notification sent to confirm changes to your
account. If you have any concerns about your account security, please
contact our support team immediately.
</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</body>
</html>

View File

@@ -6,12 +6,12 @@
* cancellation flows.
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const { sequelize, User, Item, Rental } = require('../../models');
const rentalRoutes = require('../../routes/rentals');
const request = require("supertest");
const express = require("express");
const cookieParser = require("cookie-parser");
const jwt = require("jsonwebtoken");
const { sequelize, User, Item, Rental } = require("../../models");
const rentalRoutes = require("../../routes/rentals");
// Test app setup
const createTestApp = () => {
@@ -21,11 +21,11 @@ const createTestApp = () => {
// Add request ID middleware
app.use((req, res, next) => {
req.id = 'test-request-id';
req.id = "test-request-id";
next();
});
app.use('/rentals', rentalRoutes);
app.use("/rentals", rentalRoutes);
return app;
};
@@ -34,7 +34,7 @@ const generateAuthToken = (user) => {
return jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion || 0 },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: '15m' }
{ expiresIn: "15m" },
);
};
@@ -42,11 +42,11 @@ const generateAuthToken = (user) => {
const createTestUser = async (overrides = {}) => {
const defaultData = {
email: `user-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`,
password: 'TestPassword123!',
firstName: 'Test',
lastName: 'User',
password: "TestPassword123!",
firstName: "Test",
lastName: "User",
isVerified: true,
authProvider: 'local',
authProvider: "local",
};
return User.create({ ...defaultData, ...overrides });
@@ -54,17 +54,17 @@ const createTestUser = async (overrides = {}) => {
const createTestItem = async (ownerId, overrides = {}) => {
const defaultData = {
name: 'Test Item',
description: 'A test item for rental',
pricePerDay: 25.00,
pricePerHour: 5.00,
replacementCost: 500.00,
condition: 'excellent',
name: "Test Item",
description: "A test item for rental",
pricePerDay: 25.0,
pricePerHour: 5.0,
replacementCost: 500.0,
condition: "excellent",
isAvailable: true,
pickUpAvailable: true,
ownerId,
city: 'Test City',
state: 'California',
city: "Test City",
state: "California",
};
return Item.create({ ...defaultData, ...overrides });
@@ -84,15 +84,15 @@ const createTestRental = async (itemId, renterId, ownerId, overrides = {}) => {
totalAmount: 0,
platformFee: 0,
payoutAmount: 0,
status: 'pending',
paymentStatus: 'pending',
deliveryMethod: 'pickup',
status: "pending",
paymentStatus: "pending",
deliveryMethod: "pickup",
};
return Rental.create({ ...defaultData, ...overrides });
};
describe('Rental Integration Tests', () => {
describe("Rental Integration Tests", () => {
let app;
let owner;
let renter;
@@ -100,9 +100,9 @@ describe('Rental Integration Tests', () => {
beforeAll(async () => {
// Set test environment variables
process.env.NODE_ENV = 'test';
process.env.JWT_ACCESS_SECRET = 'test-access-secret';
process.env.JWT_REFRESH_SECRET = 'test-refresh-secret';
process.env.NODE_ENV = "test";
process.env.JWT_ACCESS_SECRET = "test-access-secret";
process.env.JWT_REFRESH_SECRET = "test-refresh-secret";
// Sync database
await sequelize.sync({ force: true });
@@ -122,32 +122,32 @@ describe('Rental Integration Tests', () => {
// Create test users
owner = await createTestUser({
email: 'owner@example.com',
firstName: 'Item',
lastName: 'Owner',
stripeConnectedAccountId: 'acct_test_owner',
email: "owner@example.com",
firstName: "Item",
lastName: "Owner",
stripeConnectedAccountId: "acct_test_owner",
});
renter = await createTestUser({
email: 'renter@example.com',
firstName: 'Item',
lastName: 'Renter',
email: "renter@example.com",
firstName: "Item",
lastName: "Renter",
});
// Create test item
item = await createTestItem(owner.id);
});
describe('GET /rentals/renting', () => {
it('should return rentals where user is the renter', async () => {
describe("GET /rentals/renting", () => {
it("should return rentals where user is the renter", async () => {
// Create a rental where renter is the renter
await createTestRental(item.id, renter.id, owner.id);
const token = generateAuthToken(renter);
const response = await request(app)
.get('/rentals/renting')
.set('Cookie', [`accessToken=${token}`])
.get("/rentals/renting")
.set("Cookie", [`accessToken=${token}`])
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
@@ -155,37 +155,35 @@ describe('Rental Integration Tests', () => {
expect(response.body[0].renterId).toBe(renter.id);
});
it('should return empty array for user with no rentals', async () => {
it("should return empty array for user with no rentals", async () => {
const token = generateAuthToken(renter);
const response = await request(app)
.get('/rentals/renting')
.set('Cookie', [`accessToken=${token}`])
.get("/rentals/renting")
.set("Cookie", [`accessToken=${token}`])
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBe(0);
});
it('should require authentication', async () => {
const response = await request(app)
.get('/rentals/renting')
.expect(401);
it("should require authentication", async () => {
const response = await request(app).get("/rentals/renting").expect(401);
expect(response.body.code).toBeDefined();
});
});
describe('GET /rentals/owning', () => {
it('should return rentals where user is the owner', async () => {
describe("GET /rentals/owning", () => {
it("should return rentals where user is the owner", async () => {
// Create a rental where owner is the item owner
await createTestRental(item.id, renter.id, owner.id);
const token = generateAuthToken(owner);
const response = await request(app)
.get('/rentals/owning')
.set('Cookie', [`accessToken=${token}`])
.get("/rentals/owning")
.set("Cookie", [`accessToken=${token}`])
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
@@ -194,208 +192,213 @@ describe('Rental Integration Tests', () => {
});
});
describe('PUT /rentals/:id/status', () => {
describe("PUT /rentals/:id/status", () => {
let rental;
beforeEach(async () => {
rental = await createTestRental(item.id, renter.id, owner.id);
});
it('should allow owner to confirm a pending rental', async () => {
it("should allow owner to confirm a pending rental", async () => {
const token = generateAuthToken(owner);
const response = await request(app)
.put(`/rentals/${rental.id}/status`)
.set('Cookie', [`accessToken=${token}`])
.send({ status: 'confirmed' })
.set("Cookie", [`accessToken=${token}`])
.send({ status: "confirmed" })
.expect(200);
expect(response.body.status).toBe('confirmed');
expect(response.body.status).toBe("confirmed");
// Verify in database
await rental.reload();
expect(rental.status).toBe('confirmed');
expect(rental.status).toBe("confirmed");
});
it('should allow renter to update status (no owner-only restriction)', async () => {
it("should allow renter to update status (no owner-only restriction)", async () => {
const token = generateAuthToken(renter);
const response = await request(app)
.put(`/rentals/${rental.id}/status`)
.set('Cookie', [`accessToken=${token}`])
.send({ status: 'confirmed' })
.set("Cookie", [`accessToken=${token}`])
.send({ status: "confirmed" })
.expect(200);
// Note: API currently allows both owner and renter to update status
// Owner-specific logic (payment processing) only runs for owner
await rental.reload();
expect(rental.status).toBe('confirmed');
expect(rental.status).toBe("confirmed");
});
it('should handle confirming already confirmed rental (idempotent)', async () => {
it("should handle confirming already confirmed rental (idempotent)", async () => {
// First confirm it
await rental.update({ status: 'confirmed' });
await rental.update({ status: "confirmed" });
const token = generateAuthToken(owner);
// API allows re-confirming (idempotent operation)
const response = await request(app)
.put(`/rentals/${rental.id}/status`)
.set('Cookie', [`accessToken=${token}`])
.send({ status: 'confirmed' })
.set("Cookie", [`accessToken=${token}`])
.send({ status: "confirmed" })
.expect(200);
// Status should remain confirmed
await rental.reload();
expect(rental.status).toBe('confirmed');
expect(rental.status).toBe("confirmed");
});
});
describe('PUT /rentals/:id/decline', () => {
describe("PUT /rentals/:id/decline", () => {
let rental;
beforeEach(async () => {
rental = await createTestRental(item.id, renter.id, owner.id);
});
it('should allow owner to decline a pending rental', async () => {
it("should allow owner to decline a pending rental", async () => {
const token = generateAuthToken(owner);
const response = await request(app)
.put(`/rentals/${rental.id}/decline`)
.set('Cookie', [`accessToken=${token}`])
.send({ reason: 'Item not available for those dates' })
.set("Cookie", [`accessToken=${token}`])
.send({ reason: "Item not available for those dates" })
.expect(200);
expect(response.body.status).toBe('declined');
expect(response.body.status).toBe("declined");
// Verify in database
await rental.reload();
expect(rental.status).toBe('declined');
expect(rental.declineReason).toBe('Item not available for those dates');
expect(rental.status).toBe("declined");
expect(rental.declineReason).toBe("Item not available for those dates");
});
it('should not allow declining already declined rental', async () => {
await rental.update({ status: 'declined' });
it("should not allow declining already declined rental", async () => {
await rental.update({ status: "declined" });
const token = generateAuthToken(owner);
const response = await request(app)
.put(`/rentals/${rental.id}/decline`)
.set('Cookie', [`accessToken=${token}`])
.send({ reason: 'Already declined' })
.set("Cookie", [`accessToken=${token}`])
.send({ reason: "Already declined" })
.expect(400);
expect(response.body.error).toBeDefined();
});
});
describe('POST /rentals/:id/cancel', () => {
describe("POST /rentals/:id/cancel", () => {
let rental;
beforeEach(async () => {
rental = await createTestRental(item.id, renter.id, owner.id, {
status: 'confirmed',
paymentStatus: 'paid',
status: "confirmed",
paymentStatus: "paid",
});
});
it('should allow renter to cancel their rental', async () => {
it("should allow renter to cancel their rental", async () => {
const token = generateAuthToken(renter);
const response = await request(app)
.post(`/rentals/${rental.id}/cancel`)
.set('Cookie', [`accessToken=${token}`])
.send({ reason: 'Change of plans' })
.set("Cookie", [`accessToken=${token}`])
.send({ reason: "Change of plans" })
.expect(200);
// Response format is { rental: {...}, refund: {...} }
expect(response.body.rental.status).toBe('cancelled');
expect(response.body.rental.cancelledBy).toBe('renter');
expect(response.body.rental.status).toBe("cancelled");
expect(response.body.rental.cancelledBy).toBe("renter");
// Verify in database
await rental.reload();
expect(rental.status).toBe('cancelled');
expect(rental.cancelledBy).toBe('renter');
expect(rental.status).toBe("cancelled");
expect(rental.cancelledBy).toBe("renter");
expect(rental.cancelledAt).toBeDefined();
});
it('should allow owner to cancel their rental', async () => {
it("should allow owner to cancel their rental", async () => {
const token = generateAuthToken(owner);
const response = await request(app)
.post(`/rentals/${rental.id}/cancel`)
.set('Cookie', [`accessToken=${token}`])
.send({ reason: 'Item broken' })
.set("Cookie", [`accessToken=${token}`])
.send({ reason: "Item broken" })
.expect(200);
expect(response.body.rental.status).toBe('cancelled');
expect(response.body.rental.cancelledBy).toBe('owner');
expect(response.body.rental.status).toBe("cancelled");
expect(response.body.rental.cancelledBy).toBe("owner");
});
it('should not allow cancelling completed rental', async () => {
await rental.update({ status: 'completed', paymentStatus: 'paid' });
it("should not allow cancelling completed rental", async () => {
await rental.update({ status: "completed", paymentStatus: "paid" });
const token = generateAuthToken(renter);
// RefundService throws error which becomes 500 via next(error)
const response = await request(app)
.post(`/rentals/${rental.id}/cancel`)
.set('Cookie', [`accessToken=${token}`])
.send({ reason: 'Too late' });
.set("Cookie", [`accessToken=${token}`])
.send({ reason: "Too late" });
// Expect error (could be 400 or 500 depending on error middleware)
expect(response.status).toBeGreaterThanOrEqual(400);
});
it('should not allow unauthorized user to cancel rental', async () => {
const otherUser = await createTestUser({ email: 'other@example.com' });
it("should not allow unauthorized user to cancel rental", async () => {
const otherUser = await createTestUser({ email: "other@example.com" });
const token = generateAuthToken(otherUser);
const response = await request(app)
.post(`/rentals/${rental.id}/cancel`)
.set('Cookie', [`accessToken=${token}`])
.send({ reason: 'Not my rental' });
.set("Cookie", [`accessToken=${token}`])
.send({ reason: "Not my rental" });
// Expect error (could be 403 or 500 depending on error middleware)
expect(response.status).toBeGreaterThanOrEqual(400);
});
});
describe('GET /rentals/pending-requests-count', () => {
it('should return count of pending rental requests for owner', async () => {
describe("GET /rentals/pending-requests-count", () => {
it("should return count of pending rental requests for owner", async () => {
// Create multiple pending rentals
await createTestRental(item.id, renter.id, owner.id, { status: 'pending' });
await createTestRental(item.id, renter.id, owner.id, { status: 'pending' });
await createTestRental(item.id, renter.id, owner.id, { status: 'confirmed' });
await createTestRental(item.id, renter.id, owner.id, {
status: "pending",
});
await createTestRental(item.id, renter.id, owner.id, {
status: "pending",
});
await createTestRental(item.id, renter.id, owner.id, {
status: "confirmed",
});
const token = generateAuthToken(owner);
const response = await request(app)
.get('/rentals/pending-requests-count')
.set('Cookie', [`accessToken=${token}`])
.get("/rentals/pending-requests-count")
.set("Cookie", [`accessToken=${token}`])
.expect(200);
expect(response.body.count).toBe(2);
});
it('should return 0 for user with no pending requests', async () => {
it("should return 0 for user with no pending requests", async () => {
const token = generateAuthToken(renter);
const response = await request(app)
.get('/rentals/pending-requests-count')
.set('Cookie', [`accessToken=${token}`])
.get("/rentals/pending-requests-count")
.set("Cookie", [`accessToken=${token}`])
.expect(200);
expect(response.body.count).toBe(0);
});
});
describe('Rental Lifecycle', () => {
it('should complete full rental lifecycle: pending -> confirmed -> active -> completed', async () => {
describe("Rental Lifecycle", () => {
it("should complete full rental lifecycle: pending -> confirmed -> active -> completed", async () => {
// Create pending free rental (totalAmount: 0 is default)
const rental = await createTestRental(item.id, renter.id, owner.id, {
status: 'pending',
status: "pending",
startDateTime: new Date(Date.now() - 60 * 60 * 1000), // Started 1 hour ago
endDateTime: new Date(Date.now() + 60 * 60 * 1000), // Ends in 1 hour
});
@@ -405,52 +408,52 @@ describe('Rental Integration Tests', () => {
// Step 1: Owner confirms rental (works for free rentals)
let response = await request(app)
.put(`/rentals/${rental.id}/status`)
.set('Cookie', [`accessToken=${ownerToken}`])
.send({ status: 'confirmed' })
.set("Cookie", [`accessToken=${ownerToken}`])
.send({ status: "confirmed" })
.expect(200);
expect(response.body.status).toBe('confirmed');
expect(response.body.status).toBe("confirmed");
// Step 2: Rental is now "active" because status is confirmed and startDateTime has passed
// Note: "active" is a computed status, not stored. The stored status remains "confirmed"
// Step 2: Rental is now "active" because status is confirmed and startDateTime has passed.
// "active" is a computed status, not stored. The stored status remains "confirmed"
await rental.reload();
expect(rental.status).toBe('confirmed'); // Stored status is still 'confirmed'
expect(rental.status).toBe("confirmed"); // Stored status is still 'confirmed'
// isActive() returns true because status='confirmed' and startDateTime is in the past
// Step 3: Owner marks rental as completed (via mark-return with status='returned')
response = await request(app)
.post(`/rentals/${rental.id}/mark-return`)
.set('Cookie', [`accessToken=${ownerToken}`])
.send({ status: 'returned' })
.set("Cookie", [`accessToken=${ownerToken}`])
.send({ status: "returned" })
.expect(200);
expect(response.body.rental.status).toBe('completed');
expect(response.body.rental.status).toBe("completed");
// Verify final state
await rental.reload();
expect(rental.status).toBe('completed');
expect(rental.status).toBe("completed");
});
});
describe('Review System', () => {
describe("Review System", () => {
let completedRental;
beforeEach(async () => {
completedRental = await createTestRental(item.id, renter.id, owner.id, {
status: 'completed',
paymentStatus: 'paid',
status: "completed",
paymentStatus: "paid",
});
});
it('should allow renter to review item', async () => {
it("should allow renter to review item", async () => {
const token = generateAuthToken(renter);
const response = await request(app)
.post(`/rentals/${completedRental.id}/review-item`)
.set('Cookie', [`accessToken=${token}`])
.set("Cookie", [`accessToken=${token}`])
.send({
rating: 5,
review: 'Great item, worked perfectly!',
review: "Great item, worked perfectly!",
})
.expect(200);
@@ -459,19 +462,19 @@ describe('Rental Integration Tests', () => {
// Verify in database
await completedRental.reload();
expect(completedRental.itemRating).toBe(5);
expect(completedRental.itemReview).toBe('Great item, worked perfectly!');
expect(completedRental.itemReview).toBe("Great item, worked perfectly!");
expect(completedRental.itemReviewSubmittedAt).toBeDefined();
});
it('should allow owner to review renter', async () => {
it("should allow owner to review renter", async () => {
const token = generateAuthToken(owner);
const response = await request(app)
.post(`/rentals/${completedRental.id}/review-renter`)
.set('Cookie', [`accessToken=${token}`])
.set("Cookie", [`accessToken=${token}`])
.send({
rating: 4,
review: 'Good renter, returned on time.',
review: "Good renter, returned on time.",
})
.expect(200);
@@ -480,33 +483,40 @@ describe('Rental Integration Tests', () => {
// Verify in database
await completedRental.reload();
expect(completedRental.renterRating).toBe(4);
expect(completedRental.renterReview).toBe('Good renter, returned on time.');
expect(completedRental.renterReview).toBe(
"Good renter, returned on time.",
);
});
it('should not allow review of non-completed rental', async () => {
const pendingRental = await createTestRental(item.id, renter.id, owner.id, {
status: 'pending',
});
it("should not allow review of non-completed rental", async () => {
const pendingRental = await createTestRental(
item.id,
renter.id,
owner.id,
{
status: "pending",
},
);
const token = generateAuthToken(renter);
const response = await request(app)
.post(`/rentals/${pendingRental.id}/review-item`)
.set('Cookie', [`accessToken=${token}`])
.set("Cookie", [`accessToken=${token}`])
.send({
rating: 5,
review: 'Cannot review yet',
review: "Cannot review yet",
})
.expect(400);
expect(response.body.error).toBeDefined();
});
it('should not allow duplicate reviews', async () => {
it("should not allow duplicate reviews", async () => {
// First review
await completedRental.update({
itemRating: 5,
itemReview: 'First review',
itemReview: "First review",
itemReviewSubmittedAt: new Date(),
});
@@ -514,31 +524,39 @@ describe('Rental Integration Tests', () => {
const response = await request(app)
.post(`/rentals/${completedRental.id}/review-item`)
.set('Cookie', [`accessToken=${token}`])
.set("Cookie", [`accessToken=${token}`])
.send({
rating: 3,
review: 'Second review attempt',
review: "Second review attempt",
})
.expect(400);
expect(response.body.error).toContain('already');
expect(response.body.error).toContain("already");
});
});
describe('Database Constraints', () => {
it('should not allow rental with invalid item ID', async () => {
describe("Database Constraints", () => {
it("should not allow rental with invalid item ID", async () => {
await expect(
createTestRental('00000000-0000-0000-0000-000000000000', renter.id, owner.id)
createTestRental(
"00000000-0000-0000-0000-000000000000",
renter.id,
owner.id,
),
).rejects.toThrow();
});
it('should not allow rental with invalid user IDs', async () => {
it("should not allow rental with invalid user IDs", async () => {
await expect(
createTestRental(item.id, '00000000-0000-0000-0000-000000000000', owner.id)
createTestRental(
item.id,
"00000000-0000-0000-0000-000000000000",
owner.id,
),
).rejects.toThrow();
});
it('should cascade delete rentals when item is deleted', async () => {
it("should cascade delete rentals when item is deleted", async () => {
const rental = await createTestRental(item.id, renter.id, owner.id);
// Delete the item
@@ -550,10 +568,10 @@ describe('Rental Integration Tests', () => {
});
});
describe('Concurrent Operations', () => {
it('should handle concurrent status updates (last write wins)', async () => {
describe("Concurrent Operations", () => {
it("should handle concurrent status updates (last write wins)", async () => {
const rental = await createTestRental(item.id, renter.id, owner.id, {
status: 'pending',
status: "pending",
});
const ownerToken = generateAuthToken(owner);
@@ -562,22 +580,22 @@ describe('Rental Integration Tests', () => {
const [confirmResult, declineResult] = await Promise.allSettled([
request(app)
.put(`/rentals/${rental.id}/status`)
.set('Cookie', [`accessToken=${ownerToken}`])
.send({ status: 'confirmed' }),
.set("Cookie", [`accessToken=${ownerToken}`])
.send({ status: "confirmed" }),
request(app)
.put(`/rentals/${rental.id}/decline`)
.set('Cookie', [`accessToken=${ownerToken}`])
.send({ reason: 'Declining instead' }),
.set("Cookie", [`accessToken=${ownerToken}`])
.send({ reason: "Declining instead" }),
]);
// Both requests may succeed (no optimistic locking)
// Verify rental ends up in a valid state
await rental.reload();
expect(['confirmed', 'declined']).toContain(rental.status);
expect(["confirmed", "declined"]).toContain(rental.status);
// At least one should have succeeded
const successes = [confirmResult, declineResult].filter(
r => r.status === 'fulfilled' && r.value.status === 200
(r) => r.status === "fulfilled" && r.value.status === 200,
);
expect(successes.length).toBeGreaterThanOrEqual(1);
});

View File

@@ -98,7 +98,7 @@ describe("Stripe Routes", () => {
StripeService.getCheckoutSession.mockResolvedValue(mockSession);
const response = await request(app).get(
"/stripe/checkout-session/cs_123456789"
"/stripe/checkout-session/cs_123456789",
);
expect(response.status).toBe(200);
@@ -116,7 +116,7 @@ describe("Stripe Routes", () => {
});
expect(StripeService.getCheckoutSession).toHaveBeenCalledWith(
"cs_123456789"
"cs_123456789",
);
});
@@ -132,7 +132,7 @@ describe("Stripe Routes", () => {
StripeService.getCheckoutSession.mockResolvedValue(mockSession);
const response = await request(app).get(
"/stripe/checkout-session/cs_123456789"
"/stripe/checkout-session/cs_123456789",
);
expect(response.status).toBe(200);
@@ -150,7 +150,7 @@ describe("Stripe Routes", () => {
StripeService.getCheckoutSession.mockRejectedValue(error);
const response = await request(app).get(
"/stripe/checkout-session/invalid_session"
"/stripe/checkout-session/invalid_session",
);
expect(response.status).toBe(500);
@@ -261,7 +261,6 @@ describe("Stripe Routes", () => {
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: "Invalid email address" });
// Note: route uses logger instead of console.error
});
it("should handle database update errors", async () => {
@@ -313,7 +312,7 @@ describe("Stripe Routes", () => {
expect(StripeService.createAccountLink).toHaveBeenCalledWith(
"acct_123456789",
"http://localhost:3000/refresh",
"http://localhost:3000/return"
"http://localhost:3000/return",
);
});
@@ -413,7 +412,6 @@ describe("Stripe Routes", () => {
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: "Account not found" });
// Note: route uses logger instead of console.error
});
});
@@ -466,7 +464,7 @@ describe("Stripe Routes", () => {
});
expect(StripeService.getAccountStatus).toHaveBeenCalledWith(
"acct_123456789"
"acct_123456789",
);
});
@@ -516,7 +514,6 @@ describe("Stripe Routes", () => {
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: "Account not found" });
// Note: route uses logger instead of console.error
});
});
@@ -682,7 +679,6 @@ describe("Stripe Routes", () => {
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: "Invalid email address" });
// Note: route uses logger.withRequestId().error() instead of console.error
});
it("should handle database update errors", async () => {
@@ -785,7 +781,7 @@ describe("Stripe Routes", () => {
StripeService.getCheckoutSession.mockRejectedValue(error);
const response = await request(app).get(
`/stripe/checkout-session/${longSessionId}`
`/stripe/checkout-session/${longSessionId}`,
);
expect(response.status).toBe(500);

View File

@@ -159,7 +159,7 @@ describe("EmailClient", () => {
);
expect(SendEmailCommand).toHaveBeenCalledWith({
Source: "Village Share <noreply@villageshare.app>",
Source: "Village Share <noreply@email.com>",
Destination: {
ToAddresses: ["test@example.com"],
},
@@ -253,7 +253,7 @@ describe("EmailClient", () => {
expect(SendEmailCommand).toHaveBeenCalledWith(
expect.objectContaining({
ReplyToAddresses: ["support@villageshare.app"],
ReplyToAddresses: ["support@email.com"],
}),
);
});

View File

@@ -123,7 +123,7 @@ describe("UserEngagementEmailService", () => {
ownerName: "John",
itemName: "Power Drill",
deletionReason: "Violated community guidelines",
supportEmail: "support@villageshare.com",
supportEmail: "support@email.com",
dashboardUrl: "http://localhost:3000/owning",
}),
);
@@ -183,7 +183,7 @@ describe("UserEngagementEmailService", () => {
expect.objectContaining({
userName: "John",
banReason: "Multiple policy violations",
supportEmail: "support@villageshare.com",
supportEmail: "support@email.com",
}),
);
expect(service.emailClient.sendEmail).toHaveBeenCalledWith(