Compare commits

...

50 Commits

Author SHA1 Message Date
jackiettran
5d3c124d3e text changes 2026-01-21 19:20:07 -05:00
jackiettran
420e0efeb4 text changes and remove infra folder 2026-01-21 19:00:55 -05:00
jackiettran
23ca97cea9 text clean up 2026-01-21 17:48:50 -05:00
jackiettran
b5755109a7 Merge branch 'feature/aws-deployment'
merge infrastructure aws cdk
2026-01-21 14:19:04 -05:00
jackiettran
0136b74ee0 infrastructure with aws cdk 2026-01-21 14:18:07 -05:00
jackiettran
cae9e7e473 more frontend tests 2026-01-20 22:31:57 -05:00
jackiettran
fcce10e664 More frontend tests 2026-01-20 14:19:22 -05:00
jackiettran
28554acc2d Migrated to react router v7 2026-01-19 22:50:53 -05:00
jackiettran
1923ffc251 backend unit test coverage to 80% 2026-01-19 19:22:01 -05:00
jackiettran
d4362074f5 more unit tests 2026-01-19 00:29:28 -05:00
jackiettran
75ddb2908f fixed skipped tests 2026-01-18 19:29:28 -05:00
jackiettran
41d8cf4c04 more backend unit test coverage 2026-01-18 19:18:35 -05:00
jackiettran
e6c56ae90f fixed integration tests 2026-01-18 17:44:26 -05:00
jackiettran
d570f607d3 migration to vite and cleaned up /uploads 2026-01-18 16:55:19 -05:00
jackiettran
f9c2057e64 fixed csrf test and a bug 2026-01-18 14:02:56 -05:00
jackiettran
f58178a253 fixed tests and package vulnerabilities 2026-01-17 11:12:40 -05:00
jackiettran
cf97dffbfb MFA 2026-01-16 18:04:39 -05:00
jackiettran
63385e049c updated tests 2026-01-15 18:47:43 -05:00
jackiettran
35d5050286 removed dead code 2026-01-15 17:32:44 -05:00
jackiettran
826e4f2ed5 infrastructure updates 2026-01-15 17:17:06 -05:00
jackiettran
a3ef343326 generic response without specific error message 2026-01-15 16:37:01 -05:00
jackiettran
1b6f782648 query parameter token could be leaked 2026-01-15 16:26:53 -05:00
jackiettran
18a37e2996 lat lon validation 2026-01-15 16:11:57 -05:00
jackiettran
7b12e59f0c sanitization to all api routes 2026-01-15 15:42:30 -05:00
jackiettran
c6b531d12a more specific resources in iam policies 2026-01-15 15:31:23 -05:00
jackiettran
942867d94c fixed bug where had to login every time the server restarted 2026-01-15 15:14:55 -05:00
jackiettran
c560d9e13c updated gitignore 2026-01-15 12:07:24 -05:00
jackiettran
2242ed810e lazy loading email templates 2026-01-14 23:42:04 -05:00
jackiettran
e7081620a9 removed the cron jobs 2026-01-14 22:44:18 -05:00
jackiettran
7f2f45b1c2 payout retry lambda 2026-01-14 18:05:41 -05:00
jackiettran
da82872297 image processing lambda 2026-01-14 12:11:50 -05:00
jackiettran
f5fdcbfb82 condition check lambda 2026-01-13 17:14:19 -05:00
jackiettran
2ee5571b5b updated variable name 2026-01-12 18:12:17 -05:00
jackiettran
89dd99c263 added missing email template file references 2026-01-12 17:58:16 -05:00
jackiettran
c2ebe8709d fixed bug where earnings would show set up before disappearing even when user has stripePayoutsEnabled 2026-01-12 17:44:53 -05:00
jackiettran
6c9fd8aec2 have the right dispute statuses 2026-01-12 17:00:08 -05:00
jackiettran
80d643c65c Fixed bug where could not rent 3-4 and 4-5PM 2026-01-12 16:52:37 -05:00
jackiettran
415bcc5021 replaced some console.errors with logger 2026-01-10 20:47:29 -05:00
jackiettran
86cb8b3fe0 can cancel a rental request before owner approval 2026-01-10 19:22:15 -05:00
jackiettran
860b6d6160 Stripe error handling and now you can rent an item for a different time while having an upcoming or active rental 2026-01-10 13:29:09 -05:00
jackiettran
8aea3c38ed idempotency for stripe transfer, refund, charge 2026-01-09 14:14:49 -05:00
jackiettran
e2e32f7632 handling changes to stripe account where owner needs to provide information 2026-01-08 19:08:14 -05:00
jackiettran
0ea35e9d6f handling when payout is canceled 2026-01-08 18:12:58 -05:00
jackiettran
8585633907 handling if owner disconnects their stripe account 2026-01-08 17:49:02 -05:00
jackiettran
3042a9007f handling stripe disputes/chargeback where renter disputes the charge through their credit card company or bank 2026-01-08 17:23:55 -05:00
jackiettran
5248c3dc39 handling case where payout failed and webhook event not received 2026-01-08 15:27:02 -05:00
jackiettran
65b7574be2 updated card and bank error handling messages 2026-01-08 15:00:12 -05:00
jackiettran
bcb917c959 3D Secure handling 2026-01-08 12:44:57 -05:00
jackiettran
8b9b92d848 Text changes with earnings 2026-01-07 22:37:41 -05:00
jackiettran
550de32a41 migrations readme 2026-01-07 21:55:41 -05:00
254 changed files with 76065 additions and 20584 deletions

7
.gitignore vendored
View File

@@ -17,6 +17,7 @@ node_modules/
.env.development.local
.env.test.local
.env.production.local
.env.dev
.mcp.json
.claude
@@ -65,3 +66,9 @@ frontend/.env.local
# Uploads
uploads/
temp/
# Infrastructure CDK
infrastructure/cdk/dist/
infrastructure/cdk/cdk.out/
infrastructure/cdk/*.js
infrastructure/cdk/*.d.ts

113
README.md
View File

@@ -1,112 +1 @@
# Rentall App
A full-stack marketplace application for renting items, built with React and Node.js.
## Features
- **User Authentication**: Secure JWT-based authentication
- **Item Listings**: Create, edit, and manage rental items
- **Smart Search**: Browse and filter available items
- **Availability Calendar**: Visual calendar for managing item availability
- **Rental Requests**: Accept or reject rental requests with custom reasons
- **Delivery Options**: Support for pickup, delivery, and in-place use
- **User Profiles**: Manage profile information and view rental statistics
- **Responsive Design**: Mobile-friendly interface with Bootstrap
## Tech Stack
### Frontend
- React with TypeScript
- React Router for navigation
- Bootstrap for styling
- Axios for API calls
- Google Places API for address autocomplete
### Backend
- Node.js with Express
- SQLite database with Sequelize ORM
- JWT for authentication
- Bcrypt for password hashing
## Getting Started
### Prerequisites
- Node.js (v14 or higher)
- npm or yarn
### Installation
1. Clone the repository
```bash
git clone https://github.com/YOUR_USERNAME/rentall-app.git
cd rentall-app
```
2. Install backend dependencies
```bash
cd backend
npm install
```
3. Set up backend environment variables
Create a `.env` file in the backend directory:
```
JWT_SECRET=your_jwt_secret_here
PORT=5001
```
4. Install frontend dependencies
```bash
cd ../frontend
npm install
```
5. Set up frontend environment variables
Create a `.env` file in the frontend directory:
```
REACT_APP_API_URL=http://localhost:5001
REACT_APP_GOOGLE_MAPS_API_KEY=your_google_maps_api_key
```
### Running the Application
1. Start the backend server
```bash
cd backend
npm start
```
2. In a new terminal, start the frontend
```bash
cd frontend
npm start
```
The application will be available at `http://localhost:3000`
## Key Features Explained
### Item Management
- Create listings with multiple images, pricing options, and delivery methods
- Set availability using an intuitive calendar interface
- Manage rental rules and requirements
### Rental Process
- Browse available items with search and filter options
- Select rental dates with calendar interface
- Secure payment information collection
- Real-time rental request notifications
### User Dashboard
- View and manage your listings
- Track rental requests and accepted rentals
- Monitor rental statistics
- Update profile information
## Contributing
Feel free to submit issues and enhancement requests!
## License
This project is open source and available under the MIT License.
# Village Share

1
backend/.gitignore vendored
View File

@@ -1,6 +1,5 @@
node_modules/
.env
.env.*
uploads/
*.log
.DS_Store

View File

@@ -1,127 +0,0 @@
# AWS S3 Image Storage Integration Plan
## Overview
Integrate AWS S3 for image storage using **direct-to-S3 uploads with presigned URLs**. Frontend will upload directly to S3, reducing backend load. Images will use a **hybrid access model**: public URLs for profiles/items/forum, private signed URLs for messages and condition-checks.
## Architecture
```
Frontend Backend AWS S3
│ │ │
│ 1. POST /api/upload/presign │ │
│────────────────────────────────>│ │
│ │ 2. Generate presigned URL │
│ 3. Return {uploadUrl, key} │ │
│<────────────────────────────────│ │
│ │ │
│ 4. PUT file directly to S3 │ │
│────────────────────────────────────────────────────────────────>│
│ │ │
│ 5. POST /api/upload/confirm │ │
│────────────────────────────────>│ 6. Verify object exists │
│ │──────────────────────────────>│
│ 7. Return confirmation │ │
│<────────────────────────────────│ │
```
## S3 Bucket Structure
```
s3://village-share-{env}/
├── profiles/{uuid}.{ext} # Public access
├── items/{uuid}.{ext} # Public access
├── forum/{uuid}.{ext} # Public access
├── messages/{uuid}.{ext} # Private (signed URLs)
└── condition-checks/{uuid}.{ext} # Private (signed URLs)
```
---
### AWS S3 Bucket Setup
#### Bucket Policy (Hybrid: Public + Private)
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicRead",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": [
"arn:aws:s3:::village-share-dev/profiles/*",
"arn:aws:s3:::village-share-dev/items/*",
"arn:aws:s3:::village-share-dev/forum/*"
]
}
]
}
```
Note: `messages/*` and `condition-checks/*` are NOT included - require signed URLs.
#### CORS Configuration
```json
[
{
"AllowedHeaders": [
"Content-Type",
"Content-Length",
"Content-Disposition",
"Cache-Control",
"x-amz-content-sha256",
"x-amz-date",
"x-amz-security-token"
],
"AllowedMethods": ["PUT", "GET"],
"AllowedOrigins": ["http://localhost:3000"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]
```
#### IAM Policy for Backend
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject"],
"Resource": "arn:aws:s3:::village-share-dev/*"
}
]
}
```
**Security Note:** `s3:DeleteObject` is intentionally NOT included. File deletion is not exposed via the API to prevent unauthorized deletion attacks. Use S3 lifecycle policies for cleanup instead.
## Environment Variables to Add
```bash
# Backend (.env)
S3_ENABLED=true # Set to "true" to enable S3
S3_BUCKET=village-share-{env}
# Frontend (.env)
REACT_APP_S3_BUCKET=village-share-{env}
REACT_APP_AWS_REGION=us-east-1
```
---
## Deployment Checklist
1. Create S3 buckets for each environment (dev, qa, prod)
2. Apply bucket policies (public folders + private messages)
3. Configure CORS on each bucket
4. Attach IAM policy to EC2/ECS role
5. Add environment variables
6. Deploy backend changes
7. Deploy frontend changes

5
backend/babel.config.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }]
]
};

View File

@@ -18,7 +18,7 @@ function getAWSCredentials() {
*/
function getAWSConfig() {
const config = {
region: process.env.AWS_REGION || "us-east-1",
region: process.env.AWS_REGION,
};
const credentials = getAWSCredentials();

View File

@@ -8,7 +8,7 @@ if (!process.env.DB_NAME && process.env.NODE_ENV) {
const result = dotenv.config({ path: envFile });
if (result.error && process.env.NODE_ENV !== "production") {
console.warn(
`Warning: Could not load ${envFile}, using existing environment variables`
`Warning: Could not load ${envFile}, using existing environment variables`,
);
}
}
@@ -20,7 +20,7 @@ const dbConfig = {
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
host: process.env.DB_HOST,
port: process.env.DB_PORT || 5432,
port: process.env.DB_PORT,
dialect: "postgres",
logging: false,
pool: {
@@ -52,7 +52,7 @@ const sequelize = new Sequelize(
dialect: dbConfig.dialect,
logging: dbConfig.logging,
pool: dbConfig.pool,
}
},
);
// Export the sequelize instance as default (for backward compatibility)

View File

@@ -6,6 +6,9 @@ module.exports = {
testMatch: ['**/tests/unit/**/*.test.js'],
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
testTimeout: 10000,
transformIgnorePatterns: [
'node_modules/(?!(@scure|@otplib|otplib|@noble)/)'
],
},
{
displayName: 'integration',
@@ -13,6 +16,9 @@ module.exports = {
testMatch: ['**/tests/integration/**/*.test.js'],
setupFilesAfterEnv: ['<rootDir>/tests/integration-setup.js'],
testTimeout: 30000,
transformIgnorePatterns: [
'node_modules/(?!(@scure|@otplib|otplib|@noble)/)'
],
},
],
// Run tests sequentially to avoid module cache conflicts between unit and integration tests
@@ -23,7 +29,10 @@ module.exports = {
'!**/node_modules/**',
'!**/coverage/**',
'!**/tests/**',
'!jest.config.js'
'!**/migrations/**',
'!**/scripts/**',
'!jest.config.js',
'!babel.config.js',
],
coverageReporters: ['text', 'lcov', 'html'],
coverageThreshold: {

View File

@@ -1,258 +0,0 @@
const cron = require("node-cron");
const {
Rental,
User,
Item,
ConditionCheck,
} = require("../models");
const { Op } = require("sequelize");
const emailServices = require("../services/email");
const logger = require("../utils/logger");
const reminderSchedule = "0 * * * *"; // Run every hour
class ConditionCheckReminderJob {
static startScheduledReminders() {
console.log("Starting automated condition check reminder job...");
const reminderJob = cron.schedule(
reminderSchedule,
async () => {
try {
await this.sendConditionCheckReminders();
} catch (error) {
logger.error("Error in scheduled condition check reminders", {
error: error.message,
stack: error.stack,
});
}
},
{
scheduled: false,
timezone: "America/New_York",
}
);
// Start the job
reminderJob.start();
console.log("Condition check reminder job scheduled:");
console.log("- Reminders every hour: " + reminderSchedule);
return {
reminderJob,
stop() {
reminderJob.stop();
console.log("Condition check reminder job stopped");
},
getStatus() {
return {
reminderJobRunning: reminderJob.getStatus() === "scheduled",
};
},
};
}
// Send reminders for upcoming condition check windows
static async sendConditionCheckReminders() {
try {
const now = new Date();
const reminderWindow = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 24 hours ahead
// Find rentals with upcoming condition check windows
const rentals = await Rental.findAll({
where: {
status: {
[Op.in]: ["confirmed", "active", "completed"],
},
},
include: [
{ model: User, as: "owner" },
{ model: User, as: "renter" },
{ model: Item, as: "item" },
],
});
for (const rental of rentals) {
await this.checkAndSendConditionReminders(rental, now, reminderWindow);
}
console.log(
`Processed ${rentals.length} rentals for condition check reminders`
);
} catch (error) {
console.error("Error sending condition check reminders:", error);
}
}
// Check specific rental for reminder needs
static async checkAndSendConditionReminders(rental, now, reminderWindow) {
const rentalStart = new Date(rental.startDateTime);
const rentalEnd = new Date(rental.endDateTime);
// Pre-rental owner check (24 hours before rental start)
const preRentalWindow = new Date(
rentalStart.getTime() - 24 * 60 * 60 * 1000
);
if (now <= preRentalWindow && preRentalWindow <= reminderWindow) {
const existingCheck = await ConditionCheck.findOne({
where: {
rentalId: rental.id,
checkType: "pre_rental_owner",
},
});
if (!existingCheck) {
await this.sendPreRentalOwnerReminder(rental);
}
}
// Rental start renter check (within 24 hours of rental start)
if (now <= rentalStart && rentalStart <= reminderWindow) {
const existingCheck = await ConditionCheck.findOne({
where: {
rentalId: rental.id,
checkType: "rental_start_renter",
},
});
if (!existingCheck) {
await this.sendRentalStartRenterReminder(rental);
}
}
// Rental end renter check (within 24 hours of rental end)
if (now <= rentalEnd && rentalEnd <= reminderWindow) {
const existingCheck = await ConditionCheck.findOne({
where: {
rentalId: rental.id,
checkType: "rental_end_renter",
},
});
if (!existingCheck) {
await this.sendRentalEndRenterReminder(rental);
}
}
// Post-rental owner check (24 hours after rental end)
const postRentalWindow = new Date(
rentalEnd.getTime() + 24 * 60 * 60 * 1000
);
if (now <= postRentalWindow && postRentalWindow <= reminderWindow) {
const existingCheck = await ConditionCheck.findOne({
where: {
rentalId: rental.id,
checkType: "post_rental_owner",
},
});
if (!existingCheck) {
await this.sendPostRentalOwnerReminder(rental);
}
}
}
// Individual email senders
static async sendPreRentalOwnerReminder(rental) {
const notificationData = {
type: "condition_check_reminder",
subtype: "pre_rental_owner",
title: "Condition Check Reminder",
message: `Please take photos of "${rental.item.name}" before the rental begins tomorrow.`,
rentalId: rental.id,
userId: rental.ownerId,
metadata: {
checkType: "pre_rental_owner",
deadline: new Date(rental.startDateTime).toISOString(),
},
};
await emailServices.rentalReminder.sendConditionCheckReminder(
rental.owner.email,
notificationData,
rental
);
console.log(`Pre-rental owner reminder sent for rental ${rental.id}`);
}
static async sendRentalStartRenterReminder(rental) {
const notificationData = {
type: "condition_check_reminder",
subtype: "rental_start_renter",
title: "Condition Check Reminder",
message: `Please take photos when you receive "${rental.item.name}" to document its condition.`,
rentalId: rental.id,
userId: rental.renterId,
metadata: {
checkType: "rental_start_renter",
deadline: new Date(
rental.startDateTime.getTime() + 24 * 60 * 60 * 1000
).toISOString(),
},
};
await emailServices.rentalReminder.sendConditionCheckReminder(
rental.renter.email,
notificationData,
rental
);
console.log(`Rental start renter reminder sent for rental ${rental.id}`);
}
static async sendRentalEndRenterReminder(rental) {
const notificationData = {
type: "condition_check_reminder",
subtype: "rental_end_renter",
title: "Condition Check Reminder",
message: `Please take photos when returning "${rental.item.name}" to document its condition.`,
rentalId: rental.id,
userId: rental.renterId,
metadata: {
checkType: "rental_end_renter",
deadline: new Date(
rental.endDateTime.getTime() + 24 * 60 * 60 * 1000
).toISOString(),
},
};
await emailServices.rentalReminder.sendConditionCheckReminder(
rental.renter.email,
notificationData,
rental
);
console.log(`Rental end renter reminder sent for rental ${rental.id}`);
}
static async sendPostRentalOwnerReminder(rental) {
const notificationData = {
type: "condition_check_reminder",
subtype: "post_rental_owner",
title: "Condition Check Reminder",
message: `Please take photos and mark the return status for "${rental.item.name}".`,
rentalId: rental.id,
userId: rental.ownerId,
metadata: {
checkType: "post_rental_owner",
deadline: new Date(
rental.endDateTime.getTime() + 48 * 60 * 60 * 1000
).toISOString(),
},
};
await emailServices.rentalReminder.sendConditionCheckReminder(
rental.owner.email,
notificationData,
rental
);
console.log(`Post-rental owner reminder sent for rental ${rental.id}`);
}
}
module.exports = ConditionCheckReminderJob;

View File

@@ -1,57 +0,0 @@
const cron = require("node-cron");
const PayoutService = require("../services/payoutService");
// Daily retry job for failed payouts (hourly job removed - payouts are now triggered immediately on completion)
const retrySchedule = "0 7 * * *"; // Retry failed payouts once daily at 7 AM
class PayoutProcessor {
static startScheduledPayouts() {
console.log("Starting payout retry processor...");
const retryJob = cron.schedule(
retrySchedule,
async () => {
console.log("Running failed payout retry process...");
try {
const results = await PayoutService.retryFailedPayouts();
if (results.totalProcessed > 0) {
console.log(
`Retry batch completed: ${results.successful.length} successful, ${results.failed.length} still failed`
);
}
} catch (error) {
console.error("Error in retry payout processing:", error);
}
},
{
scheduled: false,
timezone: "America/New_York",
}
);
// Start the job
retryJob.start();
console.log("Payout processor jobs scheduled:");
console.log("- Daily retry processing: " + retrySchedule);
return {
retryJob,
stop() {
retryJob.stop();
console.log("Payout processor jobs stopped");
},
getStatus() {
return {
retryJobRunning: retryJob.getStatus() === "scheduled",
};
},
};
}
}
module.exports = PayoutProcessor;

View File

@@ -1,11 +1,24 @@
const csrf = require("csrf");
const cookieParser = require("cookie-parser");
const logger = require("../utils/logger");
// Initialize CSRF token generator
const tokens = new csrf();
// Generate a secret for signing tokens
const secret = tokens.secretSync();
// Use persistent secret from environment variable to prevent token invalidation on restart
const secret = process.env.CSRF_SECRET;
if (!secret) {
const errorMsg = "CSRF_SECRET environment variable is required.";
logger.error(errorMsg);
throw new Error(errorMsg);
}
if (secret.length < 32) {
const errorMsg = "CSRF_SECRET must be at least 32 characters for security";
logger.error(errorMsg);
throw new Error(errorMsg);
}
// CSRF middleware using double submit cookie pattern
const csrfProtection = (req, res, next) => {
@@ -15,8 +28,7 @@ const csrfProtection = (req, res, next) => {
}
// Get token from header or body
const token =
req.headers["x-csrf-token"] || req.body.csrfToken || req.query.csrfToken;
const token = req.headers["x-csrf-token"] || req.body.csrfToken;
// Get token from cookie
const cookieToken = req.cookies && req.cookies["csrf-token"];
@@ -47,7 +59,7 @@ const generateCSRFToken = (req, res, next) => {
// Set token in cookie (httpOnly for security)
res.cookie("csrf-token", token, {
httpOnly: true,
secure: process.env.NODE_ENV !== "dev",
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
sameSite: "strict",
maxAge: 60 * 60 * 1000, // 1 hour
});
@@ -67,7 +79,7 @@ const getCSRFToken = (req, res) => {
res.cookie("csrf-token", token, {
httpOnly: true,
secure: process.env.NODE_ENV !== "dev",
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
sameSite: "strict",
maxAge: 60 * 60 * 1000,
});

View File

@@ -207,6 +207,57 @@ const authRateLimiters = {
legacyHeaders: false,
handler: createRateLimitHandler('general'),
}),
// Two-Factor Authentication rate limiters
twoFactorVerification: rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // 10 verification attempts per 15 minutes
message: {
error: "Too many verification attempts. Please try again later.",
retryAfter: 900,
},
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: true,
handler: createRateLimitHandler('twoFactorVerification'),
}),
twoFactorSetup: rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5, // 5 setup attempts per hour
message: {
error: "Too many setup attempts. Please try again later.",
retryAfter: 3600,
},
standardHeaders: true,
legacyHeaders: false,
handler: createRateLimitHandler('twoFactorSetup'),
}),
recoveryCode: rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 3, // 3 recovery code attempts per 15 minutes
message: {
error: "Too many recovery code attempts. Please try again later.",
retryAfter: 900,
},
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: false, // Count all attempts for security
handler: createRateLimitHandler('recoveryCode'),
}),
emailOtpSend: rateLimit({
windowMs: 10 * 60 * 1000, // 10 minutes
max: 2, // 2 OTP sends per 10 minutes
message: {
error: "Please wait before requesting another code.",
retryAfter: 600,
},
standardHeaders: true,
legacyHeaders: false,
handler: createRateLimitHandler('emailOtpSend'),
}),
};
module.exports = {
@@ -223,6 +274,12 @@ module.exports = {
emailVerificationLimiter: authRateLimiters.emailVerification,
generalLimiter: authRateLimiters.general,
// Two-Factor Authentication rate limiters
twoFactorVerificationLimiter: authRateLimiters.twoFactorVerification,
twoFactorSetupLimiter: authRateLimiters.twoFactorSetup,
recoveryCodeLimiter: authRateLimiters.recoveryCode,
emailOtpSendLimiter: authRateLimiters.emailOtpSend,
// Burst protection
burstProtection,

View File

@@ -0,0 +1,73 @@
const TwoFactorService = require("../services/TwoFactorService");
const logger = require("../utils/logger");
/**
* Middleware to require step-up authentication for sensitive actions.
* Only applies to users who have 2FA enabled.
*
* @param {string} action - The sensitive action being protected
* @returns {Function} Express middleware function
*/
const requireStepUpAuth = (action) => {
return async (req, res, next) => {
try {
// If user doesn't have 2FA enabled, skip step-up requirement
if (!req.user.twoFactorEnabled) {
return next();
}
// Check if user has a valid step-up session (within 5 minutes)
const isValid = TwoFactorService.validateStepUpSession(req.user);
if (!isValid) {
logger.info(
`Step-up authentication required for user ${req.user.id}, action: ${action}`
);
return res.status(403).json({
error: "Multi-factor authentication required",
code: "STEP_UP_REQUIRED",
action: action,
methods: getTwoFactorMethods(req.user),
});
}
next();
} catch (error) {
logger.error("Step-up auth middleware error:", error);
return res.status(500).json({
error: "An error occurred during authentication",
});
}
};
};
/**
* Get available 2FA methods for a user
* @param {Object} user - User object
* @returns {string[]} Array of available methods
*/
function getTwoFactorMethods(user) {
const methods = [];
// Primary method is always available
if (user.twoFactorMethod === "totp") {
methods.push("totp");
}
// Email is always available as a backup method
methods.push("email");
// Recovery codes are available if any remain
if (user.recoveryCodesHash) {
const recoveryData = JSON.parse(user.recoveryCodesHash);
const remaining = TwoFactorService.getRemainingRecoveryCodesCount(recoveryData);
if (remaining > 0) {
methods.push("recovery");
}
}
return methods;
}
module.exports = { requireStepUpAuth };

View File

@@ -1,4 +1,4 @@
const { body, validationResult } = require("express-validator");
const { body, query, validationResult } = require("express-validator");
const DOMPurify = require("dompurify");
const { JSDOM } = require("jsdom");
@@ -316,6 +316,60 @@ const validateFeedback = [
handleValidationErrors,
];
// Coordinate validation for query parameters (e.g., location search)
const validateCoordinatesQuery = [
query("lat")
.optional()
.isFloat({ min: -90, max: 90 })
.withMessage("Latitude must be between -90 and 90"),
query("lng")
.optional()
.isFloat({ min: -180, max: 180 })
.withMessage("Longitude must be between -180 and 180"),
query("radius")
.optional()
.isFloat({ min: 0.1, max: 100 })
.withMessage("Radius must be between 0.1 and 100 miles"),
handleValidationErrors,
];
// Coordinate validation for body parameters (e.g., user addresses, forum posts)
const validateCoordinatesBody = [
body("latitude")
.optional()
.isFloat({ min: -90, max: 90 })
.withMessage("Latitude must be between -90 and 90"),
body("longitude")
.optional()
.isFloat({ min: -180, max: 180 })
.withMessage("Longitude must be between -180 and 180"),
];
// Two-Factor Authentication validation
const validateTotpCode = [
body("code")
.trim()
.matches(/^\d{6}$/)
.withMessage("TOTP code must be exactly 6 digits"),
handleValidationErrors,
];
const validateEmailOtp = [
body("code")
.trim()
.matches(/^\d{6}$/)
.withMessage("Email OTP must be exactly 6 digits"),
handleValidationErrors,
];
const validateRecoveryCode = [
body("code")
.trim()
.matches(/^[A-Za-z0-9]{4}-[A-Za-z0-9]{4}$/i)
.withMessage("Recovery code must be in format XXXX-XXXX"),
handleValidationErrors,
];
module.exports = {
sanitizeInput,
handleValidationErrors,
@@ -328,4 +382,10 @@ module.exports = {
validateResetPassword,
validateVerifyResetToken,
validateFeedback,
validateCoordinatesQuery,
validateCoordinatesBody,
// Two-Factor Authentication
validateTotpCode,
validateEmailOtp,
validateRecoveryCode,
};

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

@@ -0,0 +1,95 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable("ImageMetadata", {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
s3Key: {
type: Sequelize.TEXT,
allowNull: false,
unique: true,
},
latitude: {
type: Sequelize.DECIMAL(10, 8),
allowNull: true,
},
longitude: {
type: Sequelize.DECIMAL(11, 8),
allowNull: true,
},
cameraMake: {
type: Sequelize.STRING(100),
allowNull: true,
},
cameraModel: {
type: Sequelize.STRING(100),
allowNull: true,
},
cameraSoftware: {
type: Sequelize.STRING(100),
allowNull: true,
},
dateTaken: {
type: Sequelize.DATE,
allowNull: true,
},
width: {
type: Sequelize.INTEGER,
allowNull: true,
},
height: {
type: Sequelize.INTEGER,
allowNull: true,
},
orientation: {
type: Sequelize.INTEGER,
allowNull: true,
},
fileSize: {
type: Sequelize.INTEGER,
allowNull: true,
},
processingStatus: {
type: Sequelize.ENUM("pending", "processing", "completed", "failed"),
allowNull: false,
defaultValue: "pending",
},
processedAt: {
type: Sequelize.DATE,
allowNull: true,
},
errorMessage: {
type: Sequelize.TEXT,
allowNull: true,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
// Add indexes
await queryInterface.addIndex("ImageMetadata", ["s3Key"], {
unique: true,
name: "image_metadata_s3_key_unique",
});
await queryInterface.addIndex("ImageMetadata", ["latitude", "longitude"], {
name: "image_metadata_geo",
});
await queryInterface.addIndex("ImageMetadata", ["processingStatus"], {
name: "image_metadata_processing_status",
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable("ImageMetadata");
},
};

View File

@@ -0,0 +1,18 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
// Add 'requires_action' to the paymentStatus enum
// This status is used when 3DS authentication is required for a payment
await queryInterface.sequelize.query(`
ALTER TYPE "enum_Rentals_paymentStatus" ADD VALUE IF NOT EXISTS 'requires_action';
`);
},
down: async (queryInterface, Sequelize) => {
console.log(
"PostgreSQL does not support removing ENUM values. " +
"'requires_action' will remain in the enum but will not be used.",
);
},
};

View File

@@ -0,0 +1,16 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
// Add 'on_hold' to the existing payoutStatus enum
await queryInterface.sequelize.query(`
ALTER TYPE "enum_Rentals_payoutStatus" ADD VALUE IF NOT EXISTS 'on_hold';
`);
},
down: async (queryInterface, Sequelize) => {
console.log(
"Cannot remove enum value - manual intervention required if rollback needed",
);
},
};

View File

@@ -0,0 +1,57 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn("Rentals", "stripeDisputeStatus", {
type: Sequelize.ENUM("open", "won", "lost", "warning_closed"),
allowNull: true,
});
await queryInterface.addColumn("Rentals", "stripeDisputeId", {
type: Sequelize.STRING,
allowNull: true,
});
await queryInterface.addColumn("Rentals", "stripeDisputeReason", {
type: Sequelize.STRING,
allowNull: true,
});
await queryInterface.addColumn("Rentals", "stripeDisputeAmount", {
type: Sequelize.INTEGER,
allowNull: true,
});
await queryInterface.addColumn("Rentals", "stripeDisputeCreatedAt", {
type: Sequelize.DATE,
allowNull: true,
});
await queryInterface.addColumn("Rentals", "stripeDisputeEvidenceDueBy", {
type: Sequelize.DATE,
allowNull: true,
});
await queryInterface.addColumn("Rentals", "stripeDisputeClosedAt", {
type: Sequelize.DATE,
allowNull: true,
});
await queryInterface.addColumn("Rentals", "stripeDisputeLost", {
type: Sequelize.BOOLEAN,
defaultValue: false,
});
await queryInterface.addColumn("Rentals", "stripeDisputeLostAmount", {
type: Sequelize.INTEGER,
allowNull: true,
});
},
down: async (queryInterface) => {
await queryInterface.removeColumn("Rentals", "stripeDisputeStatus");
await queryInterface.removeColumn("Rentals", "stripeDisputeId");
await queryInterface.removeColumn("Rentals", "stripeDisputeReason");
await queryInterface.removeColumn("Rentals", "stripeDisputeAmount");
await queryInterface.removeColumn("Rentals", "stripeDisputeCreatedAt");
await queryInterface.removeColumn("Rentals", "stripeDisputeEvidenceDueBy");
await queryInterface.removeColumn("Rentals", "stripeDisputeClosedAt");
await queryInterface.removeColumn("Rentals", "stripeDisputeLost");
await queryInterface.removeColumn("Rentals", "stripeDisputeLostAmount");
await queryInterface.sequelize.query(
'DROP TYPE IF EXISTS "enum_Rentals_stripeDisputeStatus";'
);
},
};

View File

@@ -0,0 +1,34 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn("Users", "stripeRequirementsCurrentlyDue", {
type: Sequelize.JSON,
defaultValue: [],
allowNull: true,
});
await queryInterface.addColumn("Users", "stripeRequirementsPastDue", {
type: Sequelize.JSON,
defaultValue: [],
allowNull: true,
});
await queryInterface.addColumn("Users", "stripeDisabledReason", {
type: Sequelize.STRING,
allowNull: true,
});
await queryInterface.addColumn("Users", "stripeRequirementsLastUpdated", {
type: Sequelize.DATE,
allowNull: true,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn("Users", "stripeRequirementsCurrentlyDue");
await queryInterface.removeColumn("Users", "stripeRequirementsPastDue");
await queryInterface.removeColumn("Users", "stripeDisabledReason");
await queryInterface.removeColumn("Users", "stripeRequirementsLastUpdated");
},
};

View File

@@ -0,0 +1,15 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
// Add paymentFailedReason - stores the user-friendly error message for payment failures
await queryInterface.addColumn("Rentals", "paymentFailedReason", {
type: Sequelize.TEXT,
allowNull: true,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn("Rentals", "paymentFailedReason");
},
};

View File

@@ -0,0 +1,67 @@
"use strict";
/**
* Replaces stripeDisputeStatus enum with all valid Stripe dispute statuses.
* Previous enum had: open, won, lost, warning_closed
* Stripe uses: needs_response, under_review, won, lost,
* warning_needs_response, warning_under_review, warning_closed
*/
module.exports = {
up: async (queryInterface) => {
// Create new enum type with correct Stripe statuses
await queryInterface.sequelize.query(`
CREATE TYPE "enum_Rentals_stripeDisputeStatus_new" AS ENUM (
'needs_response',
'under_review',
'won',
'lost',
'warning_needs_response',
'warning_under_review',
'warning_closed'
);
`);
// Alter column to use new type
await queryInterface.sequelize.query(`
ALTER TABLE "Rentals"
ALTER COLUMN "stripeDisputeStatus"
TYPE "enum_Rentals_stripeDisputeStatus_new"
USING "stripeDisputeStatus"::text::"enum_Rentals_stripeDisputeStatus_new";
`);
// Drop old enum type
await queryInterface.sequelize.query(`
DROP TYPE "enum_Rentals_stripeDisputeStatus";
`);
// Rename new type to original name
await queryInterface.sequelize.query(`
ALTER TYPE "enum_Rentals_stripeDisputeStatus_new"
RENAME TO "enum_Rentals_stripeDisputeStatus";
`);
},
down: async (queryInterface) => {
await queryInterface.sequelize.query(`
CREATE TYPE "enum_Rentals_stripeDisputeStatus_old" AS ENUM (
'open', 'won', 'lost', 'warning_closed'
);
`);
await queryInterface.sequelize.query(`
ALTER TABLE "Rentals"
ALTER COLUMN "stripeDisputeStatus"
TYPE "enum_Rentals_stripeDisputeStatus_old"
USING "stripeDisputeStatus"::text::"enum_Rentals_stripeDisputeStatus_old";
`);
await queryInterface.sequelize.query(`
DROP TYPE "enum_Rentals_stripeDisputeStatus";
`);
await queryInterface.sequelize.query(`
ALTER TYPE "enum_Rentals_stripeDisputeStatus_old"
RENAME TO "enum_Rentals_stripeDisputeStatus";
`);
},
};

View File

@@ -0,0 +1,107 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
// Add TOTP configuration fields
await queryInterface.addColumn("Users", "twoFactorEnabled", {
type: Sequelize.BOOLEAN,
defaultValue: false,
allowNull: false,
});
await queryInterface.addColumn("Users", "twoFactorMethod", {
type: Sequelize.ENUM("totp", "email"),
allowNull: true,
});
await queryInterface.addColumn("Users", "totpSecret", {
type: Sequelize.STRING,
allowNull: true,
});
await queryInterface.addColumn("Users", "totpSecretIv", {
type: Sequelize.STRING,
allowNull: true,
});
await queryInterface.addColumn("Users", "twoFactorEnabledAt", {
type: Sequelize.DATE,
allowNull: true,
});
// Add Email OTP fields (backup method)
await queryInterface.addColumn("Users", "emailOtpCode", {
type: Sequelize.STRING,
allowNull: true,
});
await queryInterface.addColumn("Users", "emailOtpExpiry", {
type: Sequelize.DATE,
allowNull: true,
});
await queryInterface.addColumn("Users", "emailOtpAttempts", {
type: Sequelize.INTEGER,
defaultValue: 0,
allowNull: false,
});
// Add Recovery Codes fields
await queryInterface.addColumn("Users", "recoveryCodesHash", {
type: Sequelize.TEXT,
allowNull: true,
});
await queryInterface.addColumn("Users", "recoveryCodesGeneratedAt", {
type: Sequelize.DATE,
allowNull: true,
});
await queryInterface.addColumn("Users", "recoveryCodesUsedCount", {
type: Sequelize.INTEGER,
defaultValue: 0,
allowNull: false,
});
// Add Step-up session tracking
await queryInterface.addColumn("Users", "twoFactorVerifiedAt", {
type: Sequelize.DATE,
allowNull: true,
});
// Add temporary secret storage during setup
await queryInterface.addColumn("Users", "twoFactorSetupPendingSecret", {
type: Sequelize.STRING,
allowNull: true,
});
await queryInterface.addColumn("Users", "twoFactorSetupPendingSecretIv", {
type: Sequelize.STRING,
allowNull: true,
});
},
async down(queryInterface, Sequelize) {
// Remove all 2FA fields in reverse order
await queryInterface.removeColumn("Users", "twoFactorSetupPendingSecretIv");
await queryInterface.removeColumn("Users", "twoFactorSetupPendingSecret");
await queryInterface.removeColumn("Users", "twoFactorVerifiedAt");
await queryInterface.removeColumn("Users", "recoveryCodesUsedCount");
await queryInterface.removeColumn("Users", "recoveryCodesGeneratedAt");
await queryInterface.removeColumn("Users", "recoveryCodesHash");
await queryInterface.removeColumn("Users", "emailOtpAttempts");
await queryInterface.removeColumn("Users", "emailOtpExpiry");
await queryInterface.removeColumn("Users", "emailOtpCode");
await queryInterface.removeColumn("Users", "twoFactorEnabledAt");
await queryInterface.removeColumn("Users", "totpSecretIv");
await queryInterface.removeColumn("Users", "totpSecret");
await queryInterface.removeColumn("Users", "twoFactorMethod");
await queryInterface.removeColumn("Users", "twoFactorEnabled");
// Remove the ENUM type
await queryInterface.sequelize.query(
'DROP TYPE IF EXISTS "enum_Users_twoFactorMethod";'
);
},
};

View File

@@ -0,0 +1,32 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
// Add recentTotpCodes field for TOTP replay protection
await queryInterface.addColumn("Users", "recentTotpCodes", {
type: Sequelize.TEXT,
allowNull: true,
comment: "JSON array of hashed recently used TOTP codes for replay protection",
});
// Remove deprecated columns (if they exist)
await queryInterface.removeColumn("Users", "twoFactorEnabledAt").catch(() => {});
await queryInterface.removeColumn("Users", "recoveryCodesUsedCount").catch(() => {});
},
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn("Users", "recentTotpCodes");
// Re-add deprecated columns for rollback
await queryInterface.addColumn("Users", "twoFactorEnabledAt", {
type: Sequelize.DATE,
allowNull: true,
});
await queryInterface.addColumn("Users", "recoveryCodesUsedCount", {
type: Sequelize.INTEGER,
defaultValue: 0,
allowNull: false,
});
},
};

View File

@@ -0,0 +1,303 @@
# Database Migrations
This project uses Sequelize CLI for database migrations. Migrations provide version control for your database schema.
## Quick Reference
```bash
# Run pending migrations
npm run db:migrate
# Undo last migration
npm run db:migrate:undo
# Undo all migrations
npm run db:migrate:undo:all
# Check migration status
npm run db:migrate:status
# Test all migrations (up, down, up)
npm run test:migrations
```
## Available Commands
### `npm run db:migrate`
**Purpose:** Runs all pending migrations that haven't been applied yet.
- Checks the `SequelizeMeta` table to see which migrations have already run
- Executes the `up` function in each pending migration file in order
- Records each successful migration in the `SequelizeMeta` table
- **When to use:** Deploy new schema changes to your database
### `npm run db:migrate:undo`
**Purpose:** Rolls back the most recent migration.
- Executes the `down` function of the last applied migration
- Removes that migration's entry from `SequelizeMeta`
- **When to use:** Quickly revert the last change if something went wrong
### `npm run db:migrate:undo:all`
**Purpose:** Rolls back ALL migrations, returning to an empty database.
- Executes the `down` function of every migration in reverse order
- Clears the `SequelizeMeta` table
- **When to use:** Reset development database to start fresh, or in testing
### `npm run db:migrate:status`
**Purpose:** Shows the status of all migrations.
- Lists which migrations have been executed (with timestamps)
- Shows which migrations are pending
- **When to use:** Check what's been applied before deploying or debugging
### `npm run db:create`
**Purpose:** Creates the database specified in your config.
- Reads `DB_NAME` from environment variables
- Creates a new PostgreSQL database with that name
- **When to use:** Initial setup on a new environment
### `npm run test:migrations`
**Purpose:** Automated testing of migrations (to be implemented in Phase 4).
- Will create a fresh test database
- Run all migrations up, then down, then up again
- Verify migrations are reversible and idempotent
- **When to use:** In CI/CD pipeline before merging migration changes
## Environment Configuration
All commands use the environment specified by `NODE_ENV` (dev, test, qa, prod) and load the corresponding `.env` file automatically.
Examples:
```bash
# Run migrations in development
NODE_ENV=dev npm run db:migrate
# Check status in QA environment
NODE_ENV=qa npm run db:migrate:status
# Run migrations in production
NODE_ENV=prod npm run db:migrate
```
## Creating a New Migration
```bash
# Generate a new migration file
npx sequelize-cli migration:generate --name description-of-change
```
This creates a timestamped file in `backend/migrations/`:
```
20241125123456-description-of-change.js
```
### Migration File Structure
```javascript
module.exports = {
up: async (queryInterface, Sequelize) => {
// Schema changes to apply
},
down: async (queryInterface, Sequelize) => {
// How to revert the changes
},
};
```
## Naming Conventions
Use descriptive names that indicate the action:
- `create-users` - Creating a new table
- `add-email-to-users` - Adding a column
- `remove-legacy-field-from-items` - Removing a column
- `add-index-on-users-email` - Adding an index
- `change-status-enum-in-rentals` - Modifying a column
## Zero-Downtime Patterns
### Adding a Column (Safe)
```javascript
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn("users", "newField", {
type: Sequelize.STRING,
allowNull: true, // Must be nullable or have default
});
};
```
### Adding a NOT NULL Column
```javascript
// Step 1: Add as nullable
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn("users", "newField", {
type: Sequelize.STRING,
allowNull: true,
});
};
// Step 2: Backfill data (separate migration)
up: async (queryInterface, Sequelize) => {
await queryInterface.sequelize.query(
`UPDATE users SET "newField" = 'default_value' WHERE "newField" IS NULL`
);
};
// Step 3: Add NOT NULL constraint (separate migration, after code deployed)
up: async (queryInterface, Sequelize) => {
await queryInterface.changeColumn("users", "newField", {
type: Sequelize.STRING,
allowNull: false,
});
};
```
### Removing a Column (3-Step Process)
1. **Deploy 1**: Update code to stop reading/writing the column
2. **Deploy 2**: Run migration to remove column
3. **Deploy 3**: Remove column references from model (cleanup)
```javascript
// Migration in Deploy 2
up: async (queryInterface) => {
await queryInterface.removeColumn('users', 'oldField');
},
down: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('users', 'oldField', {
type: Sequelize.STRING,
allowNull: true
});
}
```
### Renaming a Column (3-Step Process)
```javascript
// Deploy 1: Add new column, copy data
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn("users", "newName", {
type: Sequelize.STRING,
});
await queryInterface.sequelize.query(
'UPDATE users SET "newName" = "oldName"'
);
};
// Deploy 2: Update code to use newName, keep oldName as fallback
// (no migration)
// Deploy 3: Remove old column
up: async (queryInterface) => {
await queryInterface.removeColumn("users", "oldName");
};
```
### Creating Indexes (Use CONCURRENTLY)
```javascript
up: async (queryInterface) => {
await queryInterface.addIndex("users", ["email"], {
unique: true,
concurrently: true, // Prevents table locking
name: "users_email_unique",
});
};
```
## Testing Checklist
Before committing a migration:
- [ ] Migration has both `up` and `down` functions
- [ ] Tested locally: `npm run db:migrate`
- [ ] Tested rollback: `npm run db:migrate:undo`
- [ ] Tested re-apply: `npm run db:migrate`
- [ ] Full test: `npm run test:migrations`
- [ ] Application starts and works correctly
- [ ] No data loss in `down` migration (where possible)
## Deployment Checklist
Before deploying to production:
- [ ] Backup production database
- [ ] Test migration on copy of production data
- [ ] Review migration for safety (no destructive operations)
- [ ] Schedule during low-traffic window
- [ ] Have rollback plan ready
- [ ] Monitor logs after deployment
## Rollback Procedures
### Undo Last Migration
```bash
npm run db:migrate:undo
```
### Undo Multiple Migrations
```bash
# Undo last 3 migrations
npx sequelize-cli db:migrate:undo --step 3
```
### Undo to Specific Migration
```bash
npx sequelize-cli db:migrate:undo:all --to 20241124000005-create-rentals.js
```
## Common Issues
### "Migration file not found"
Ensure the migration filename in `SequelizeMeta` matches the file on disk.
### "Column already exists"
The migration may have partially run. Check the schema and either:
- Manually fix the schema
- Mark migration as complete: `INSERT INTO "SequelizeMeta" VALUES ('filename.js')`
### "Cannot drop column - dependent objects"
Drop dependent indexes/constraints first:
```javascript
await queryInterface.removeIndex("users", "index_name");
await queryInterface.removeColumn("users", "column_name");
```
### Foreign Key Constraint Failures
Ensure data integrity before adding constraints:
```javascript
// Clean orphaned records first
await queryInterface.sequelize.query(
'DELETE FROM rentals WHERE "itemId" NOT IN (SELECT id FROM items)'
);
// Then add constraint
await queryInterface.addConstraint("rentals", {
fields: ["itemId"],
type: "foreign key",
references: { table: "items", field: "id" },
});
```

View File

@@ -0,0 +1,88 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../config/database");
const ImageMetadata = sequelize.define(
"ImageMetadata",
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
s3Key: {
type: DataTypes.TEXT,
allowNull: false,
unique: true,
},
latitude: {
type: DataTypes.DECIMAL(10, 8),
allowNull: true,
},
longitude: {
type: DataTypes.DECIMAL(11, 8),
allowNull: true,
},
cameraMake: {
type: DataTypes.STRING(100),
allowNull: true,
},
cameraModel: {
type: DataTypes.STRING(100),
allowNull: true,
},
cameraSoftware: {
type: DataTypes.STRING(100),
allowNull: true,
},
dateTaken: {
type: DataTypes.DATE,
allowNull: true,
},
width: {
type: DataTypes.INTEGER,
allowNull: true,
},
height: {
type: DataTypes.INTEGER,
allowNull: true,
},
orientation: {
type: DataTypes.INTEGER,
allowNull: true,
},
fileSize: {
type: DataTypes.INTEGER,
allowNull: true,
},
processingStatus: {
type: DataTypes.ENUM("pending", "processing", "completed", "failed"),
allowNull: false,
defaultValue: "pending",
},
processedAt: {
type: DataTypes.DATE,
allowNull: true,
},
errorMessage: {
type: DataTypes.TEXT,
allowNull: true,
},
},
{
timestamps: true,
indexes: [
{
fields: ["s3Key"],
unique: true,
},
{
fields: ["latitude", "longitude"],
},
{
fields: ["processingStatus"],
},
],
}
);
module.exports = ImageMetadata;

View File

@@ -67,11 +67,11 @@ const Rental = sequelize.define("Rental", {
allowNull: false,
},
paymentStatus: {
type: DataTypes.ENUM("pending", "paid", "refunded", "not_required"),
type: DataTypes.ENUM("pending", "paid", "refunded", "not_required", "requires_action"),
allowNull: false,
},
payoutStatus: {
type: DataTypes.ENUM("pending", "completed", "failed"),
type: DataTypes.ENUM("pending", "completed", "failed", "on_hold"),
allowNull: true,
},
payoutProcessedAt: {
@@ -94,6 +94,52 @@ const Rental = sequelize.define("Rental", {
bankDepositFailureCode: {
type: DataTypes.STRING,
},
// Dispute tracking fields (for tracking Stripe payment disputes/chargebacks)
// Stripe dispute statuses: https://docs.stripe.com/api/disputes/object#dispute_object-status
stripeDisputeStatus: {
type: DataTypes.ENUM(
"needs_response",
"under_review",
"won",
"lost",
"warning_needs_response",
"warning_under_review",
"warning_closed"
),
allowNull: true,
},
stripeDisputeId: {
type: DataTypes.STRING,
allowNull: true,
},
stripeDisputeReason: {
type: DataTypes.STRING,
allowNull: true,
},
stripeDisputeAmount: {
type: DataTypes.INTEGER,
allowNull: true,
},
stripeDisputeCreatedAt: {
type: DataTypes.DATE,
allowNull: true,
},
stripeDisputeEvidenceDueBy: {
type: DataTypes.DATE,
allowNull: true,
},
stripeDisputeClosedAt: {
type: DataTypes.DATE,
allowNull: true,
},
stripeDisputeLost: {
type: DataTypes.BOOLEAN,
defaultValue: false,
},
stripeDisputeLostAmount: {
type: DataTypes.INTEGER,
allowNull: true,
},
// Refund tracking fields
refundAmount: {
type: DataTypes.DECIMAL(10, 2),
@@ -135,6 +181,9 @@ const Rental = sequelize.define("Rental", {
paymentFailedNotifiedAt: {
type: DataTypes.DATE,
},
paymentFailedReason: {
type: DataTypes.TEXT,
},
// Payment method update rate limiting
paymentMethodUpdatedAt: {
type: DataTypes.DATE,

View File

@@ -124,6 +124,24 @@ const User = sequelize.define(
type: DataTypes.STRING,
allowNull: true,
},
stripeRequirementsCurrentlyDue: {
type: DataTypes.JSON,
defaultValue: [],
allowNull: true,
},
stripeRequirementsPastDue: {
type: DataTypes.JSON,
defaultValue: [],
allowNull: true,
},
stripeDisabledReason: {
type: DataTypes.STRING,
allowNull: true,
},
stripeRequirementsLastUpdated: {
type: DataTypes.DATE,
allowNull: true,
},
loginAttempts: {
type: DataTypes.INTEGER,
defaultValue: 0,
@@ -173,6 +191,66 @@ const User = sequelize.define(
defaultValue: 0,
allowNull: true,
},
// Two-Factor Authentication fields
twoFactorEnabled: {
type: DataTypes.BOOLEAN,
defaultValue: false,
allowNull: false,
},
twoFactorMethod: {
type: DataTypes.ENUM("totp", "email"),
allowNull: true,
},
totpSecret: {
type: DataTypes.STRING,
allowNull: true,
},
totpSecretIv: {
type: DataTypes.STRING,
allowNull: true,
},
// Email OTP fields (backup method)
emailOtpCode: {
type: DataTypes.STRING,
allowNull: true,
},
emailOtpExpiry: {
type: DataTypes.DATE,
allowNull: true,
},
emailOtpAttempts: {
type: DataTypes.INTEGER,
defaultValue: 0,
allowNull: false,
},
// Recovery codes
recoveryCodesHash: {
type: DataTypes.TEXT,
allowNull: true,
},
recoveryCodesGeneratedAt: {
type: DataTypes.DATE,
allowNull: true,
},
// Step-up session tracking
twoFactorVerifiedAt: {
type: DataTypes.DATE,
allowNull: true,
},
// Temporary secret during setup
twoFactorSetupPendingSecret: {
type: DataTypes.STRING,
allowNull: true,
},
twoFactorSetupPendingSecretIv: {
type: DataTypes.STRING,
allowNull: true,
},
// TOTP replay protection
recentTotpCodes: {
type: DataTypes.TEXT,
allowNull: true,
},
},
{
hooks: {
@@ -187,7 +265,7 @@ const User = sequelize.define(
}
},
},
}
},
);
User.prototype.comparePassword = async function (password) {
@@ -379,7 +457,258 @@ 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
});
};
// Two-Factor Authentication methods
const TwoFactorService = require("../services/TwoFactorService");
// Store pending TOTP secret during setup
User.prototype.storePendingTotpSecret = async function (
encryptedSecret,
encryptedSecretIv,
) {
return this.update({
twoFactorSetupPendingSecret: encryptedSecret,
twoFactorSetupPendingSecretIv: encryptedSecretIv,
});
};
// Enable TOTP 2FA after verification
User.prototype.enableTotp = async function (recoveryCodes) {
const hashedCodes = await Promise.all(
recoveryCodes.map((code) => bcrypt.hash(code, 12)),
);
// Store in structured format
const recoveryData = {
version: 1,
codes: hashedCodes.map((hash) => ({
hash,
used: false,
})),
};
return this.update({
twoFactorEnabled: true,
twoFactorMethod: "totp",
totpSecret: this.twoFactorSetupPendingSecret,
totpSecretIv: this.twoFactorSetupPendingSecretIv,
twoFactorSetupPendingSecret: null,
twoFactorSetupPendingSecretIv: null,
recoveryCodesHash: JSON.stringify(recoveryData),
recoveryCodesGeneratedAt: new Date(),
twoFactorVerifiedAt: new Date(), // Consider setup as verification
});
};
// Enable Email 2FA
User.prototype.enableEmailTwoFactor = async function (recoveryCodes) {
const hashedCodes = await Promise.all(
recoveryCodes.map((code) => bcrypt.hash(code, 12)),
);
// Store in structured format
const recoveryData = {
version: 1,
codes: hashedCodes.map((hash) => ({
hash,
used: false,
})),
};
return this.update({
twoFactorEnabled: true,
twoFactorMethod: "email",
recoveryCodesHash: JSON.stringify(recoveryData),
recoveryCodesGeneratedAt: new Date(),
twoFactorVerifiedAt: new Date(),
});
};
// Disable 2FA
User.prototype.disableTwoFactor = async function () {
return this.update({
twoFactorEnabled: false,
twoFactorMethod: null,
totpSecret: null,
totpSecretIv: null,
emailOtpCode: null,
emailOtpExpiry: null,
emailOtpAttempts: 0,
recoveryCodesHash: null,
recoveryCodesGeneratedAt: null,
twoFactorVerifiedAt: null,
twoFactorSetupPendingSecret: null,
twoFactorSetupPendingSecretIv: null,
});
};
// Generate and store email OTP
User.prototype.generateEmailOtp = async function () {
const { code, hashedCode, expiry } = TwoFactorService.generateEmailOtp();
await this.update({
emailOtpCode: hashedCode,
emailOtpExpiry: expiry,
emailOtpAttempts: 0,
});
return code; // Return plain code for sending via email
};
// Verify email OTP
User.prototype.verifyEmailOtp = function (inputCode) {
return TwoFactorService.verifyEmailOtp(
inputCode,
this.emailOtpCode,
this.emailOtpExpiry,
);
};
// Increment email OTP attempts
User.prototype.incrementEmailOtpAttempts = async function () {
const newAttempts = (this.emailOtpAttempts || 0) + 1;
await this.update({ emailOtpAttempts: newAttempts });
return newAttempts;
};
// Check if email OTP is locked
User.prototype.isEmailOtpLocked = function () {
return TwoFactorService.isEmailOtpLocked(this.emailOtpAttempts || 0);
};
// Clear email OTP after successful verification
User.prototype.clearEmailOtp = async function () {
return this.update({
emailOtpCode: null,
emailOtpExpiry: null,
emailOtpAttempts: 0,
});
};
// Check if a TOTP code was recently used (replay protection)
User.prototype.hasUsedTotpCode = function (code) {
const crypto = require("crypto");
const recentCodes = JSON.parse(this.recentTotpCodes || "[]");
const codeHash = crypto.createHash("sha256").update(code).digest("hex");
return recentCodes.includes(codeHash);
};
// Mark a TOTP code as used (replay protection)
User.prototype.markTotpCodeUsed = async function (code) {
const crypto = require("crypto");
const recentCodes = JSON.parse(this.recentTotpCodes || "[]");
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)),
});
};
// Verify TOTP code with replay protection
User.prototype.verifyTotpCode = function (code) {
if (!this.totpSecret || !this.totpSecretIv) {
return false;
}
// Check for replay attack
if (this.hasUsedTotpCode(code)) {
return false;
}
return TwoFactorService.verifyTotpCode(
this.totpSecret,
this.totpSecretIv,
code,
);
};
// Verify pending TOTP code (during setup)
User.prototype.verifyPendingTotpCode = function (code) {
if (
!this.twoFactorSetupPendingSecret ||
!this.twoFactorSetupPendingSecretIv
) {
return false;
}
return TwoFactorService.verifyTotpCode(
this.twoFactorSetupPendingSecret,
this.twoFactorSetupPendingSecretIv,
code,
);
};
// Use a recovery code
User.prototype.useRecoveryCode = async function (inputCode) {
if (!this.recoveryCodesHash) {
return { valid: false };
}
const recoveryData = JSON.parse(this.recoveryCodesHash);
const { valid, index } = await TwoFactorService.verifyRecoveryCode(
inputCode,
recoveryData,
);
if (valid) {
// Handle both old and new format
if (recoveryData.version) {
// New structured format - mark as used with timestamp
recoveryData.codes[index].used = true;
recoveryData.codes[index].usedAt = new Date().toISOString();
} else {
// Legacy format - set to null
recoveryData[index] = null;
}
await this.update({
recoveryCodesHash: JSON.stringify(recoveryData),
twoFactorVerifiedAt: new Date(),
});
}
return {
valid,
remainingCodes:
TwoFactorService.getRemainingRecoveryCodesCount(recoveryData),
};
};
// Get remaining recovery codes count
User.prototype.getRemainingRecoveryCodes = function () {
if (!this.recoveryCodesHash) {
return 0;
}
const recoveryData = JSON.parse(this.recoveryCodesHash);
return TwoFactorService.getRemainingRecoveryCodesCount(recoveryData);
};
// Regenerate recovery codes
User.prototype.regenerateRecoveryCodes = async function () {
const { codes, hashedCodes } = await TwoFactorService.generateRecoveryCodes();
// Store in structured format
const recoveryData = {
version: 1,
codes: hashedCodes.map((hash) => ({
hash,
used: false,
})),
};
await this.update({
recoveryCodesHash: JSON.stringify(recoveryData),
recoveryCodesGeneratedAt: new Date(),
});
return codes; // Return plain codes for display to user
};
// Update step-up verification timestamp
User.prototype.updateStepUpSession = async function () {
return this.update({
twoFactorVerifiedAt: new Date(),
});
};

View File

@@ -10,6 +10,7 @@ const UserAddress = require("./UserAddress");
const ConditionCheck = require("./ConditionCheck");
const AlphaInvitation = require("./AlphaInvitation");
const Feedback = require("./Feedback");
const ImageMetadata = require("./ImageMetadata");
User.hasMany(Item, { as: "ownedItems", foreignKey: "ownerId" });
Item.belongsTo(User, { as: "owner", foreignKey: "ownerId" });
@@ -91,4 +92,5 @@ module.exports = {
ConditionCheck,
AlphaInvitation,
Feedback,
ImageMetadata,
};

3124
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,7 @@
"license": "ISC",
"dependencies": {
"@aws-sdk/client-s3": "^3.940.0",
"@aws-sdk/client-scheduler": "^3.896.0",
"@aws-sdk/client-ses": "^3.896.0",
"@aws-sdk/credential-providers": "^3.901.0",
"@aws-sdk/s3-request-presigner": "^3.940.0",
@@ -54,8 +55,9 @@
"jsdom": "^27.0.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.1",
"node-cron": "^3.0.3",
"otplib": "^13.1.1",
"pg": "^8.16.3",
"qrcode": "^1.5.4",
"sequelize": "^6.37.7",
"sequelize-cli": "^6.6.3",
"socket.io": "^4.8.1",
@@ -65,7 +67,10 @@
"winston-daily-rotate-file": "^5.0.0"
},
"devDependencies": {
"@babel/core": "^7.28.6",
"@babel/preset-env": "^7.28.6",
"@types/jest": "^30.0.0",
"babel-jest": "^30.2.0",
"jest": "^30.1.3",
"nodemon": "^3.1.10",
"sequelize-mock": "^0.10.2",

View File

@@ -91,7 +91,7 @@ router.post("/validate-code", alphaCodeValidationLimiter, async (req, res) => {
res.cookie("alphaAccessCode", cookieData, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
sameSite: "strict",
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
});

View File

@@ -28,8 +28,7 @@ const router = express.Router();
const googleClient = new OAuth2Client(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
process.env.GOOGLE_REDIRECT_URI ||
"http://localhost:3000/auth/google/callback"
process.env.GOOGLE_REDIRECT_URI,
);
// Get CSRF token endpoint
@@ -120,7 +119,7 @@ router.post(
try {
await emailServices.auth.sendVerificationEmail(
user,
user.verificationToken
user.verificationToken,
);
verificationEmailSent = true;
} catch (emailError) {
@@ -137,28 +136,26 @@ router.post(
const token = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: "15m" } // Short-lived access token
{ expiresIn: "15m" }, // Short-lived access token
);
const refreshToken = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: "7d" }
{ expiresIn: "7d" },
);
// Set tokens as httpOnly cookies
res.cookie("accessToken", token, {
httpOnly: true,
secure:
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
sameSite: "strict",
maxAge: 15 * 60 * 1000, // 15 minutes
});
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure:
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
sameSite: "strict",
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
@@ -190,7 +187,7 @@ router.post(
});
res.status(500).json({ error: "Registration failed. Please try again." });
}
}
},
);
router.post(
@@ -222,7 +219,8 @@ router.post(
// Check if user is banned
if (user.isBanned) {
return res.status(403).json({
error: "Your account has been suspended. Please contact support for more information.",
error:
"Your account has been suspended. Please contact support for more information.",
code: "USER_BANNED",
});
}
@@ -244,28 +242,26 @@ router.post(
const token = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: "15m" } // Short-lived access token
{ expiresIn: "15m" }, // Short-lived access token
);
const refreshToken = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: "7d" }
{ expiresIn: "7d" },
);
// Set tokens as httpOnly cookies
res.cookie("accessToken", token, {
httpOnly: true,
secure:
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
sameSite: "strict",
maxAge: 15 * 60 * 1000, // 15 minutes
});
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure:
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
sameSite: "strict",
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
@@ -296,7 +292,7 @@ router.post(
});
res.status(500).json({ error: "Login failed. Please try again." });
}
}
},
);
router.post(
@@ -318,9 +314,7 @@ router.post(
// Exchange authorization code for tokens
const { tokens } = await googleClient.getToken({
code,
redirect_uri:
process.env.GOOGLE_REDIRECT_URI ||
"http://localhost:3000/auth/google/callback",
redirect_uri: process.env.GOOGLE_REDIRECT_URI,
});
// Verify the ID token from the token response
@@ -417,7 +411,8 @@ router.post(
// Check if user is banned
if (user.isBanned) {
return res.status(403).json({
error: "Your account has been suspended. Please contact support for more information.",
error:
"Your account has been suspended. Please contact support for more information.",
code: "USER_BANNED",
});
}
@@ -426,28 +421,26 @@ router.post(
const token = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: "15m" }
{ expiresIn: "15m" },
);
const refreshToken = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: "7d" }
{ expiresIn: "7d" },
);
// Set tokens as httpOnly cookies
res.cookie("accessToken", token, {
httpOnly: true,
secure:
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
sameSite: "strict",
maxAge: 15 * 60 * 1000,
});
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure:
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
sameSite: "strict",
maxAge: 7 * 24 * 60 * 60 * 1000,
});
@@ -494,7 +487,7 @@ router.post(
.status(500)
.json({ error: "Google authentication failed. Please try again." });
}
}
},
);
// Email verification endpoint
@@ -611,7 +604,7 @@ router.post(
error: "Email verification failed. Please try again.",
});
}
}
},
);
// Resend verification email endpoint
@@ -656,7 +649,7 @@ router.post(
try {
await emailServices.auth.sendVerificationEmail(
user,
user.verificationToken
user.verificationToken,
);
} catch (emailError) {
const reqLogger = logger.withRequestId(req.id);
@@ -697,7 +690,7 @@ router.post(
error: "Failed to resend verification email. Please try again.",
});
}
}
},
);
// Refresh token endpoint
@@ -733,7 +726,8 @@ router.post("/refresh", async (req, res) => {
// Check if user is banned (defense-in-depth, jwtVersion should already catch this)
if (user.isBanned) {
return res.status(403).json({
error: "Your account has been suspended. Please contact support for more information.",
error:
"Your account has been suspended. Please contact support for more information.",
code: "USER_BANNED",
});
}
@@ -742,13 +736,13 @@ router.post("/refresh", async (req, res) => {
const newAccessToken = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: "15m" }
{ expiresIn: "15m" },
);
// Set new access token cookie
res.cookie("accessToken", newAccessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
sameSite: "strict",
maxAge: 15 * 60 * 1000,
});
@@ -857,7 +851,7 @@ router.post(
"Password reset requested for non-existent or OAuth user",
{
email: email,
}
},
);
}
@@ -877,7 +871,7 @@ router.post(
error: "Failed to process password reset request. Please try again.",
});
}
}
},
);
// Verify reset token endpoint (optional - for frontend UX)
@@ -931,7 +925,7 @@ router.post(
error: "Failed to verify reset token. Please try again.",
});
}
}
},
);
// Reset password endpoint
@@ -1014,7 +1008,7 @@ router.post(
error: "Failed to reset password. Please try again.",
});
}
}
},
);
module.exports = router;

View File

@@ -2,6 +2,7 @@ const express = require('express');
const { Op } = require('sequelize');
const { ForumPost, ForumComment, PostTag, User } = require('../models');
const { authenticateToken, requireAdmin, optionalAuth } = require('../middleware/auth');
const { validateCoordinatesBody, handleValidationErrors } = require('../middleware/validation');
const logger = require('../utils/logger');
const emailServices = require('../services/email');
const googleMapsService = require('../services/googleMapsService');
@@ -239,7 +240,7 @@ router.get('/posts/:id', optionalAuth, async (req, res, next) => {
});
// POST /api/forum/posts - Create new post
router.post('/posts', authenticateToken, async (req, res, next) => {
router.post('/posts', authenticateToken, ...validateCoordinatesBody, handleValidationErrors, async (req, res, next) => {
try {
// Require email verification
if (!req.user.isVerified) {
@@ -729,7 +730,7 @@ router.patch('/posts/:id/status', authenticateToken, async (req, res, next) => {
stack: emailError.stack,
postId: req.params.id
});
console.error("Email notification error:", emailError);
logger.error("Email notification error", { error: emailError });
}
})();
}
@@ -909,7 +910,7 @@ router.patch('/posts/:id/accept-answer', authenticateToken, async (req, res, nex
commentId: commentId,
postId: req.params.id
});
console.error("Email notification error:", emailError);
logger.error("Email notification error", { error: emailError });
}
})();
}
@@ -1109,7 +1110,7 @@ router.post('/posts/:id/comments', authenticateToken, async (req, res, next) =>
commentId: comment.id,
postId: req.params.id
});
console.error("Email notification error:", emailError);
logger.error("Email notification error", { error: emailError });
}
})();
@@ -1689,7 +1690,7 @@ router.patch('/admin/posts/:id/close', authenticateToken, requireAdmin, async (r
stack: emailError.stack,
postId: req.params.id
});
console.error("Email notification error:", emailError);
logger.error("Email notification error", { error: emailError });
}
})();

View File

@@ -2,6 +2,7 @@ const express = require("express");
const { Op, Sequelize } = require("sequelize");
const { Item, User, Rental, sequelize } = require("../models"); // Import from models/index.js to get models with associations
const { authenticateToken, requireVerifiedEmail, requireAdmin, optionalAuth } = require("../middleware/auth");
const { validateCoordinatesQuery, validateCoordinatesBody, handleValidationErrors } = require("../middleware/validation");
const logger = require("../utils/logger");
const { validateS3Keys } = require("../utils/s3KeyValidator");
const { IMAGE_LIMITS } = require("../config/imageLimits");
@@ -53,7 +54,7 @@ function extractAllowedFields(body) {
return result;
}
router.get("/", async (req, res, next) => {
router.get("/", validateCoordinatesQuery, async (req, res, next) => {
try {
const {
minPrice,
@@ -327,7 +328,7 @@ router.get("/:id", optionalAuth, async (req, res, next) => {
}
});
router.post("/", authenticateToken, requireVerifiedEmail, async (req, res, next) => {
router.post("/", authenticateToken, requireVerifiedEmail, ...validateCoordinatesBody, handleValidationErrors, async (req, res, next) => {
try {
// Extract only allowed fields (prevents mass assignment)
const allowedData = extractAllowedFields(req.body);
@@ -435,7 +436,7 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res, next)
}
});
router.put("/:id", authenticateToken, async (req, res, next) => {
router.put("/:id", authenticateToken, ...validateCoordinatesBody, handleValidationErrors, async (req, res, next) => {
try {
const item = await Item.findByPk(req.params.id);

View File

@@ -1,13 +1,10 @@
const express = require('express');
const helmet = require('helmet');
const { Message, User } = require('../models');
const { authenticateToken } = require('../middleware/auth');
const logger = require('../utils/logger');
const { emitNewMessage, emitMessageRead } = require('../sockets/messageSocket');
const { Op } = require('sequelize');
const emailServices = require('../services/email');
const fs = require('fs');
const path = require('path');
const { validateS3Keys } = require('../utils/s3KeyValidator');
const { IMAGE_LIMITS } = require('../config/imageLimits');
const router = express.Router();
@@ -395,53 +392,4 @@ router.get('/unread/count', authenticateToken, async (req, res, next) => {
}
});
// Get message image (authorized)
router.get('/images/:filename',
authenticateToken,
// Override Helmet's CORP header for cross-origin image loading
helmet.crossOriginResourcePolicy({ policy: "cross-origin" }),
async (req, res) => {
try {
// Sanitize filename to prevent path traversal attacks
const filename = path.basename(req.params.filename);
// Verify user is sender or receiver of a message with this image
const message = await Message.findOne({
where: {
imageFilename: filename,
[Op.or]: [
{ senderId: req.user.id },
{ receiverId: req.user.id }
]
}
});
if (!message) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.warn('Unauthorized image access attempt', {
userId: req.user.id,
filename
});
return res.status(403).json({ error: 'Access denied' });
}
// Serve the image
const filePath = path.join(__dirname, '../uploads/messages', filename);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'Image not found' });
}
res.sendFile(filePath);
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error('Image serve failed', {
error: error.message,
stack: error.stack,
filename: req.params.filename
});
res.status(500).json({ error: 'Failed to load image' });
}
});
module.exports = router;

View File

@@ -14,6 +14,7 @@ const DamageAssessmentService = require("../services/damageAssessmentService");
const StripeWebhookService = require("../services/stripeWebhookService");
const StripeService = require("../services/stripeService");
const emailServices = require("../services/email");
const EventBridgeSchedulerService = require("../services/eventBridgeSchedulerService");
const logger = require("../utils/logger");
const { PaymentError } = require("../utils/stripeErrors");
const { validateS3Keys } = require("../utils/s3KeyValidator");
@@ -268,42 +269,25 @@ 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
const overlappingRental = await Rental.findOne({
where: {
itemId,
status: "confirmed",
[Op.or]: [
{
startDateTime: { [Op.not]: null },
endDateTime: { [Op.not]: null },
[Op.and]: [
{ startDateTime: { [Op.not]: null } },
{ endDateTime: { [Op.not]: null } },
{
[Op.or]: [
{
startDateTime: {
[Op.between]: [rentalStartDateTime, rentalEndDateTime],
},
},
{
endDateTime: {
[Op.between]: [rentalStartDateTime, rentalEndDateTime],
},
},
{
[Op.and]: [
{ startDateTime: { [Op.lte]: rentalStartDateTime } },
{ endDateTime: { [Op.gte]: rentalEndDateTime } },
],
},
],
},
],
},
// existingStart < newEnd (existing rental starts before new one ends)
{ startDateTime: { [Op.lt]: rentalEndDateTime } },
// existingEnd > newStart (existing rental ends after new one starts)
{ endDateTime: { [Op.gt]: rentalStartDateTime } },
],
},
});
@@ -368,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", {
@@ -390,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", {
@@ -490,9 +474,51 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
itemName: rental.item.name,
renterId: rental.renterId,
ownerId: rental.ownerId,
}
},
);
// Check if 3DS authentication is required
if (paymentResult.requiresAction) {
// Store payment intent for later completion
await rental.update({
stripePaymentIntentId: paymentResult.paymentIntentId,
paymentStatus: "requires_action",
});
// Send email to renter (without direct link for security)
try {
await emailServices.rentalFlow.sendAuthenticationRequiredEmail(
rental.renter.email,
{
renterName: rental.renter.firstName,
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", {
rentalId: rental.id,
renterId: rental.renterId,
});
} 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,
});
}
return res.status(402).json({
error: "authentication_required",
requiresAction: true,
message: "The renter's card requires additional authentication.",
rentalId: rental.id,
});
}
// Update rental with payment completion
await rental.update({
status: "confirmed",
@@ -525,13 +551,27 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
],
});
// Create condition check reminder schedules
try {
await EventBridgeSchedulerService.createConditionCheckSchedules(
updatedRental,
);
} catch (schedulerError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Failed to create condition check schedules", {
error: schedulerError.message,
rentalId: updatedRental.id,
});
// Don't fail the confirmation - schedules are non-critical
}
// Send confirmation emails
// Send approval confirmation to owner with Stripe reminder
try {
await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(
updatedRental.owner,
updatedRental.renter,
updatedRental
updatedRental,
);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental approval confirmation sent to owner", {
@@ -547,7 +587,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
stack: emailError.stack,
rentalId: updatedRental.id,
ownerId: updatedRental.ownerId,
}
},
);
}
@@ -570,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", {
@@ -587,7 +627,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
stack: emailError.stack,
rentalId: updatedRental.id,
renterId: updatedRental.renterId,
}
},
);
}
@@ -609,8 +649,11 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
? paymentError.renterMessage
: "Your payment could not be processed. Please try a different payment method.";
// Track payment failure timestamp
await rental.update({ paymentFailedNotifiedAt: new Date() });
// Track payment failure timestamp and reason
await rental.update({
paymentFailedNotifiedAt: new Date(),
paymentFailedReason: renterMessage,
});
// Auto-send payment declined email to renter
try {
@@ -621,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,
@@ -676,13 +719,27 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
],
});
// Create condition check reminder schedules
try {
await EventBridgeSchedulerService.createConditionCheckSchedules(
updatedRental,
);
} catch (schedulerError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Failed to create condition check schedules", {
error: schedulerError.message,
rentalId: updatedRental.id,
});
// Don't fail the confirmation - schedules are non-critical
}
// 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
updatedRental,
);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental approval confirmation sent to owner", {
@@ -698,7 +755,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
stack: emailError.stack,
rentalId: updatedRental.id,
ownerId: updatedRental.ownerId,
}
},
);
}
@@ -721,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", {
@@ -738,7 +795,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
stack: emailError.stack,
rentalId: updatedRental.id,
renterId: updatedRental.renterId,
}
},
);
}
@@ -844,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", {
@@ -1039,11 +1096,32 @@ router.post("/cost-preview", authenticateToken, async (req, res) => {
});
}
// Check for overlapping rentals (same logic as in POST /rentals)
// Two ranges overlap if: existingStart < newEnd AND existingEnd > newStart
const overlappingRental = await Rental.findOne({
where: {
itemId,
status: "confirmed",
startDateTime: { [Op.not]: null },
endDateTime: { [Op.not]: null },
[Op.and]: [
{ startDateTime: { [Op.lt]: rentalEndDateTime } },
{ endDateTime: { [Op.gt]: rentalStartDateTime } },
],
},
});
if (overlappingRental) {
return res
.status(400)
.json({ error: "Item is already booked for these dates" });
}
// Calculate rental cost using duration calculator
const totalAmount = RentalDurationCalculator.calculateRentalCost(
rentalStartDateTime,
rentalEndDateTime,
item
item,
);
// Calculate fees
@@ -1066,7 +1144,18 @@ router.post("/cost-preview", authenticateToken, async (req, res) => {
// Get earnings status for owner's rentals
router.get("/earnings/status", authenticateToken, async (req, res, next) => {
const reqLogger = logger.withRequestId(req.id);
try {
// Trigger payout reconciliation in background (non-blocking)
// This catches any missed payout.paid or payout.failed webhooks
StripeWebhookService.reconcilePayoutStatuses(req.user.id).catch((err) => {
reqLogger.error("Background payout reconciliation failed", {
error: err.message,
userId: req.user.id,
});
});
const ownerRentals = await Rental.findAll({
where: {
ownerId: req.user.id,
@@ -1080,6 +1169,9 @@ router.get("/earnings/status", authenticateToken, async (req, res, next) => {
"payoutStatus",
"payoutProcessedAt",
"stripeTransferId",
"bankDepositStatus",
"bankDepositAt",
"bankDepositFailureCode",
],
include: [{ model: Item, as: "item", attributes: ["name"] }],
order: [["createdAt", "DESC"]],
@@ -1087,7 +1179,6 @@ router.get("/earnings/status", authenticateToken, async (req, res, next) => {
res.json(ownerRentals);
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error getting earnings status", {
error: error.message,
stack: error.stack,
@@ -1102,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) {
@@ -1146,7 +1237,7 @@ router.get(
const lateCalculation = LateReturnService.calculateLateFee(
rental,
actualReturnDateTime
actualReturnDateTime,
);
res.json(lateCalculation);
@@ -1160,7 +1251,7 @@ router.get(
});
next(error);
}
}
},
);
// Cancel rental with refund processing
@@ -1176,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
@@ -1202,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", {
@@ -1303,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", {
@@ -1341,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;
@@ -1363,7 +1454,7 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
const lateReturn = await LateReturnService.processLateReturn(
rentalId,
actualReturnDateTime
actualReturnDateTime,
);
updatedRental = lateReturn.rental;
@@ -1384,7 +1475,7 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
await emailServices.customerService.sendLostItemToCustomerService(
updatedRental,
owner,
renter
renter,
);
break;
@@ -1462,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({
@@ -1476,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);
@@ -1554,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" });
@@ -1599,7 +1690,7 @@ router.put("/:id/payment-method", authenticateToken, async (req, res, next) => {
status: "pending",
paymentStatus: "pending",
},
}
},
);
if (updateCount === 0) {
@@ -1625,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
@@ -1652,4 +1743,260 @@ router.put("/:id/payment-method", authenticateToken, async (req, res, next) => {
}
});
/**
* GET /rentals/:id/payment-client-secret
* Returns client secret for 3DS completion (renter only)
*/
router.get(
"/:id/payment-client-secret",
authenticateToken,
async (req, res, next) => {
try {
const rental = await Rental.findByPk(req.params.id, {
include: [
{ model: User, as: "renter", attributes: ["id", "stripeCustomerId"] },
],
});
if (!rental) {
return res.status(404).json({ error: "Rental not found" });
}
if (rental.renterId !== req.user.id) {
return res.status(403).json({ error: "Not authorized" });
}
if (!rental.stripePaymentIntentId) {
return res.status(400).json({ error: "No payment intent found" });
}
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
const paymentIntent = await stripe.paymentIntents.retrieve(
rental.stripePaymentIntentId,
);
return res.json({
clientSecret: paymentIntent.client_secret,
status: paymentIntent.status,
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Get client secret error", {
error: error.message,
stack: error.stack,
rentalId: req.params.id,
userId: req.user.id,
});
next(error);
}
},
);
/**
* POST /rentals/:id/complete-payment
* Called after renter completes 3DS authentication
*/
router.post(
"/:id/complete-payment",
authenticateToken,
async (req, res, next) => {
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: Item, as: "item" },
],
});
if (!rental) {
return res.status(404).json({ error: "Rental not found" });
}
if (rental.renterId !== req.user.id) {
return res.status(403).json({ error: "Not authorized" });
}
if (rental.paymentStatus !== "requires_action") {
return res.status(400).json({
error: "Invalid state",
message: "This rental is not awaiting payment authentication",
});
}
// Retrieve payment intent to check status (expand latest_charge for payment method details)
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
const paymentIntent = await stripe.paymentIntents.retrieve(
rental.stripePaymentIntentId,
{ expand: ["latest_charge.payment_method_details"] },
);
if (paymentIntent.status !== "succeeded") {
return res.status(402).json({
error: "payment_incomplete",
status: paymentIntent.status,
message:
paymentIntent.status === "requires_action"
? "Authentication not yet completed"
: "Payment could not be completed",
});
}
// Extract payment method details from latest_charge (charges is deprecated)
const charge = paymentIntent.latest_charge;
const paymentMethodDetails = charge?.payment_method_details;
let paymentMethodBrand = null;
let paymentMethodLast4 = null;
if (paymentMethodDetails) {
const type = paymentMethodDetails.type;
if (type === "card") {
paymentMethodBrand = paymentMethodDetails.card?.brand || "card";
paymentMethodLast4 = paymentMethodDetails.card?.last4 || null;
} else if (type === "us_bank_account") {
paymentMethodBrand = "bank_account";
paymentMethodLast4 =
paymentMethodDetails.us_bank_account?.last4 || null;
}
}
// Payment succeeded - complete rental confirmation
await rental.update({
status: "confirmed",
paymentStatus: "paid",
chargedAt: new Date(),
paymentMethodBrand,
paymentMethodLast4,
});
// Create condition check reminder schedules
try {
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,
});
// Don't fail the confirmation - schedules are non-critical
}
// Send confirmation emails
try {
await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(
rental.owner,
rental.renter,
rental,
);
const reqLogger = logger.withRequestId(req.id);
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(
"Failed to send rental approval confirmation email after 3DS",
{
error: emailError.message,
rentalId: rental.id,
},
);
}
try {
const renterNotification = {
type: "rental_confirmed",
title: "Rental Confirmed",
message: `Your rental of "${rental.item.name}" has been confirmed.`,
rentalId: rental.id,
userId: rental.renterId,
metadata: { rentalStart: rental.startDateTime },
};
await emailServices.rentalFlow.sendRentalConfirmation(
rental.renter.email,
renterNotification,
rental,
rental.renter.firstName,
true,
);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental confirmation sent to renter (after 3DS)", {
rentalId: rental.id,
renterId: rental.renterId,
});
} catch (emailError) {
const reqLogger = logger.withRequestId(req.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
) {
try {
await PayoutService.processRentalPayout(rental);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Payout processed after 3DS completion", {
rentalId: rental.id,
ownerId: rental.ownerId,
});
} catch (payoutError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Payout failed after 3DS completion", {
error: payoutError.message,
rentalId: rental.id,
});
}
}
return res.json({
success: true,
rental: {
id: rental.id,
status: "confirmed",
paymentStatus: "paid",
},
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Complete payment error", {
error: error.message,
stack: error.stack,
rentalId: req.params.id,
userId: req.user.id,
});
next(error);
}
},
);
module.exports = router;

View File

@@ -2,6 +2,8 @@ const express = require("express");
const { authenticateToken, requireVerifiedEmail } = require("../middleware/auth");
const { User, Item } = require("../models");
const StripeService = require("../services/stripeService");
const StripeWebhookService = require("../services/stripeWebhookService");
const emailServices = require("../services/email");
const logger = require("../utils/logger");
const router = express.Router();
@@ -168,7 +170,7 @@ router.post("/account-sessions", authenticateToken, requireVerifiedEmail, async
}
});
// Get account status
// Get account status with reconciliation
router.get("/account-status", authenticateToken, async (req, res, next) => {
let user = null;
try {
@@ -190,6 +192,64 @@ router.get("/account-status", authenticateToken, async (req, res, next) => {
payoutsEnabled: accountStatus.payouts_enabled,
});
// Reconciliation: Compare fetched status with stored User fields
const previousPayoutsEnabled = user.stripePayoutsEnabled;
const currentPayoutsEnabled = accountStatus.payouts_enabled;
const requirements = accountStatus.requirements || {};
// Check if status has changed and needs updating
const statusChanged =
previousPayoutsEnabled !== currentPayoutsEnabled ||
JSON.stringify(user.stripeRequirementsCurrentlyDue || []) !==
JSON.stringify(requirements.currently_due || []);
if (statusChanged) {
reqLogger.info("Reconciling account status from API call", {
userId: req.user.id,
previousPayoutsEnabled,
currentPayoutsEnabled,
previousCurrentlyDue: user.stripeRequirementsCurrentlyDue?.length || 0,
newCurrentlyDue: requirements.currently_due?.length || 0,
});
// Update user with current status
await user.update({
stripePayoutsEnabled: currentPayoutsEnabled,
stripeRequirementsCurrentlyDue: requirements.currently_due || [],
stripeRequirementsPastDue: requirements.past_due || [],
stripeDisabledReason: requirements.disabled_reason || null,
stripeRequirementsLastUpdated: new Date(),
});
// If payouts just became disabled (true -> false), send notification
if (!currentPayoutsEnabled && previousPayoutsEnabled) {
reqLogger.warn("Payouts disabled detected during reconciliation", {
userId: req.user.id,
disabledReason: requirements.disabled_reason,
});
try {
const disabledReason = StripeWebhookService.formatDisabledReason(
requirements.disabled_reason
);
await emailServices.payment.sendPayoutsDisabledEmail(user.email, {
ownerName: user.firstName || user.lastName,
disabledReason,
});
reqLogger.info("Sent payouts disabled email during reconciliation", {
userId: req.user.id,
});
} catch (emailError) {
reqLogger.error("Failed to send payouts disabled email", {
userId: req.user.id,
error: emailError.message,
});
}
}
}
res.json({
accountId: accountStatus.id,
detailsSubmitted: accountStatus.details_submitted,

View File

@@ -1,5 +1,6 @@
const express = require("express");
const StripeWebhookService = require("../services/stripeWebhookService");
const DisputeService = require("../services/disputeService");
const logger = require("../utils/logger");
const router = express.Router();
@@ -70,6 +71,31 @@ router.post("/", async (req, res) => {
);
break;
case "payout.canceled":
// Payout was canceled before being deposited
await StripeWebhookService.handlePayoutCanceled(
event.data.object,
event.account
);
break;
case "account.application.deauthorized":
// Owner disconnected their Stripe account from our platform
await StripeWebhookService.handleAccountDeauthorized(event.account);
break;
case "charge.dispute.created":
// Renter disputed a charge with their bank
await DisputeService.handleDisputeCreated(event.data.object);
break;
case "charge.dispute.closed":
case "charge.dispute.funds_reinstated":
case "charge.dispute.funds_withdrawn":
// Dispute was resolved (won, lost, or warning closed)
await DisputeService.handleDisputeClosed(event.data.object);
break;
default:
logger.info("Unhandled webhook event type", { type: event.type });
}
@@ -86,7 +112,7 @@ router.post("/", async (req, res) => {
// Still return 200 to prevent Stripe retries for processing errors
// Failed payouts will be handled by retry job
res.json({ received: true, eventId: event.id, error: error.message });
res.json({ received: true, eventId: event.id });
}
});

627
backend/routes/twoFactor.js Normal file
View File

@@ -0,0 +1,627 @@
const express = require("express");
const { User } = require("../models");
const TwoFactorService = require("../services/TwoFactorService");
const emailServices = require("../services/email");
const logger = require("../utils/logger");
const { authenticateToken } = require("../middleware/auth");
const { requireStepUpAuth } = require("../middleware/stepUpAuth");
const { csrfProtection } = require("../middleware/csrf");
const {
sanitizeInput,
validateTotpCode,
validateEmailOtp,
validateRecoveryCode,
} = require("../middleware/validation");
const {
twoFactorVerificationLimiter,
twoFactorSetupLimiter,
recoveryCodeLimiter,
emailOtpSendLimiter,
} = require("../middleware/rateLimiter");
const router = express.Router();
// Helper for structured security audit logging
const auditLog = (req, action, userId, details = {}) => {
logger.info({
type: 'security_audit',
action,
userId,
ip: req.ip,
userAgent: req.get('User-Agent'),
...details,
});
};
// All routes require authentication
router.use(authenticateToken);
// ============================================
// SETUP ENDPOINTS
// ============================================
/**
* POST /api/2fa/setup/totp/init
* Initialize TOTP setup - generate secret and QR code
*/
router.post(
"/setup/totp/init",
twoFactorSetupLimiter,
csrfProtection,
async (req, res) => {
try {
const user = await User.findByPk(req.user.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
if (user.twoFactorEnabled) {
return res.status(400).json({
error: "Multi-factor authentication is already enabled",
});
}
// Generate TOTP secret and QR code
const { qrCodeDataUrl, encryptedSecret, encryptedSecretIv } =
await TwoFactorService.generateTotpSecret(user.email);
// Store pending secret for verification
await user.storePendingTotpSecret(encryptedSecret, encryptedSecretIv);
auditLog(req, '2fa.setup.initiated', user.id, { method: 'totp' });
res.json({
qrCodeDataUrl,
message: "Scan the QR code with your authenticator app",
});
} catch (error) {
logger.error("TOTP setup init error:", error);
res.status(500).json({ error: "Failed to initialize TOTP setup" });
}
}
);
/**
* POST /api/2fa/setup/totp/verify
* Verify TOTP code and enable 2FA
*/
router.post(
"/setup/totp/verify",
twoFactorSetupLimiter,
csrfProtection,
sanitizeInput,
validateTotpCode,
async (req, res) => {
try {
const { code } = req.body;
const user = await User.findByPk(req.user.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
if (user.twoFactorEnabled) {
return res.status(400).json({
error: "Multi-factor authentication is already enabled",
});
}
if (!user.twoFactorSetupPendingSecret) {
return res.status(400).json({
error: "No pending TOTP setup. Please start the setup process again.",
});
}
// Verify the code against the pending secret
const isValid = user.verifyPendingTotpCode(code);
if (!isValid) {
return res.status(400).json({
error: "Invalid verification code. Please try again.",
});
}
// Generate recovery codes
const { codes: recoveryCodes } =
await TwoFactorService.generateRecoveryCodes();
// Enable TOTP
await user.enableTotp(recoveryCodes);
// Send confirmation email
try {
await emailServices.auth.sendTwoFactorEnabledEmail(user);
} catch (emailError) {
logger.error("Failed to send 2FA enabled email:", emailError);
// Don't fail the request if email fails
}
auditLog(req, '2fa.setup.completed', user.id, { method: 'totp' });
res.json({
message: "Multi-factor authentication enabled successfully",
recoveryCodes,
warning:
"Save these recovery codes in a secure location. You will not be able to see them again.",
});
} catch (error) {
logger.error("TOTP setup verify error:", error);
res.status(500).json({ error: "Failed to enable multi-factor authentication" });
}
}
);
/**
* POST /api/2fa/setup/email/init
* Initialize email 2FA setup - send verification code
*/
router.post(
"/setup/email/init",
twoFactorSetupLimiter,
emailOtpSendLimiter,
csrfProtection,
async (req, res) => {
try {
const user = await User.findByPk(req.user.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
if (user.twoFactorEnabled) {
return res.status(400).json({
error: "Multi-factor authentication is already enabled",
});
}
// Generate and send email OTP
const otpCode = await user.generateEmailOtp();
try {
await emailServices.auth.sendTwoFactorOtpEmail(user, otpCode);
} catch (emailError) {
logger.error("Failed to send 2FA setup OTP email:", emailError);
return res.status(500).json({ error: "Failed to send verification email" });
}
auditLog(req, '2fa.setup.initiated', user.id, { method: 'email' });
res.json({
message: "Verification code sent to your email",
});
} catch (error) {
logger.error("Email 2FA setup init error:", error);
res.status(500).json({ error: "Failed to initialize email 2FA setup" });
}
}
);
/**
* POST /api/2fa/setup/email/verify
* Verify email OTP and enable email 2FA
*/
router.post(
"/setup/email/verify",
twoFactorSetupLimiter,
csrfProtection,
sanitizeInput,
validateEmailOtp,
async (req, res) => {
try {
const { code } = req.body;
const user = await User.findByPk(req.user.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
if (user.twoFactorEnabled) {
return res.status(400).json({
error: "Multi-factor authentication is already enabled",
});
}
if (user.isEmailOtpLocked()) {
return res.status(429).json({
error: "Too many failed attempts. Please request a new code.",
});
}
// Verify the OTP
const isValid = user.verifyEmailOtp(code);
if (!isValid) {
await user.incrementEmailOtpAttempts();
return res.status(400).json({
error: "Invalid or expired verification code",
});
}
// Generate recovery codes
const { codes: recoveryCodes } =
await TwoFactorService.generateRecoveryCodes();
// Enable email 2FA
await user.enableEmailTwoFactor(recoveryCodes);
await user.clearEmailOtp();
// Send confirmation email
try {
await emailServices.auth.sendTwoFactorEnabledEmail(user);
} catch (emailError) {
logger.error("Failed to send 2FA enabled email:", emailError);
}
auditLog(req, '2fa.setup.completed', user.id, { method: 'email' });
res.json({
message: "Multi-factor authentication enabled successfully",
recoveryCodes,
warning:
"Save these recovery codes in a secure location. You will not be able to see them again.",
});
} catch (error) {
logger.error("Email 2FA setup verify error:", error);
res.status(500).json({ error: "Failed to enable multi-factor authentication" });
}
}
);
// ============================================
// VERIFICATION ENDPOINTS (Step-up auth)
// ============================================
/**
* POST /api/2fa/verify/totp
* Verify TOTP code for step-up authentication
*/
router.post(
"/verify/totp",
twoFactorVerificationLimiter,
csrfProtection,
sanitizeInput,
validateTotpCode,
async (req, res) => {
try {
const { code } = req.body;
const user = await User.findByPk(req.user.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
if (!user.twoFactorEnabled || user.twoFactorMethod !== "totp") {
logger.warn(`2FA verify failed for user ${user.id}: TOTP not enabled or wrong method`);
return res.status(400).json({
error: "Verification failed",
});
}
const isValid = user.verifyTotpCode(code);
if (!isValid) {
auditLog(req, '2fa.verify.failed', user.id, { method: 'totp' });
return res.status(400).json({
error: "Invalid verification code",
});
}
// Mark code as used for replay protection
await user.markTotpCodeUsed(code);
// Update step-up session
await user.updateStepUpSession();
auditLog(req, '2fa.verify.success', user.id, { method: 'totp' });
res.json({
message: "Verification successful",
verified: true,
});
} catch (error) {
logger.error("TOTP verification error:", error);
res.status(500).json({ error: "Verification failed" });
}
}
);
/**
* POST /api/2fa/verify/email/send
* Send email OTP for step-up authentication
*/
router.post(
"/verify/email/send",
emailOtpSendLimiter,
csrfProtection,
async (req, res) => {
try {
const user = await User.findByPk(req.user.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
if (!user.twoFactorEnabled) {
logger.warn(`2FA verify failed for user ${user.id}: 2FA not enabled`);
return res.status(400).json({
error: "Verification failed",
});
}
// Generate and send email OTP
const otpCode = await user.generateEmailOtp();
try {
await emailServices.auth.sendTwoFactorOtpEmail(user, otpCode);
} catch (emailError) {
logger.error("Failed to send 2FA OTP email:", emailError);
return res.status(500).json({ error: "Failed to send verification email" });
}
auditLog(req, '2fa.otp.sent', user.id, { method: 'email' });
res.json({
message: "Verification code sent to your email",
});
} catch (error) {
logger.error("Email OTP send error:", error);
res.status(500).json({ error: "Failed to send verification code" });
}
}
);
/**
* POST /api/2fa/verify/email
* Verify email OTP for step-up authentication
*/
router.post(
"/verify/email",
twoFactorVerificationLimiter,
csrfProtection,
sanitizeInput,
validateEmailOtp,
async (req, res) => {
try {
const { code } = req.body;
const user = await User.findByPk(req.user.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
if (!user.twoFactorEnabled) {
logger.warn(`2FA verify failed for user ${user.id}: 2FA not enabled`);
return res.status(400).json({
error: "Verification failed",
});
}
if (user.isEmailOtpLocked()) {
return res.status(429).json({
error: "Too many failed attempts. Please request a new code.",
});
}
const isValid = user.verifyEmailOtp(code);
if (!isValid) {
await user.incrementEmailOtpAttempts();
auditLog(req, '2fa.verify.failed', user.id, { method: 'email' });
return res.status(400).json({
error: "Invalid or expired verification code",
});
}
// Update step-up session and clear OTP
await user.updateStepUpSession();
await user.clearEmailOtp();
auditLog(req, '2fa.verify.success', user.id, { method: 'email' });
res.json({
message: "Verification successful",
verified: true,
});
} catch (error) {
logger.error("Email OTP verification error:", error);
res.status(500).json({ error: "Verification failed" });
}
}
);
/**
* POST /api/2fa/verify/recovery
* Use recovery code for step-up authentication
*/
router.post(
"/verify/recovery",
recoveryCodeLimiter,
csrfProtection,
sanitizeInput,
validateRecoveryCode,
async (req, res) => {
try {
const { code } = req.body;
const user = await User.findByPk(req.user.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
if (!user.twoFactorEnabled) {
logger.warn(`2FA verify failed for user ${user.id}: 2FA not enabled`);
return res.status(400).json({
error: "Verification failed",
});
}
const { valid, remainingCodes } = await user.useRecoveryCode(code);
if (!valid) {
auditLog(req, '2fa.verify.failed', user.id, { method: 'recovery' });
return res.status(400).json({
error: "Invalid recovery code",
});
}
// Send alert email about recovery code usage
try {
await emailServices.auth.sendRecoveryCodeUsedEmail(user, remainingCodes);
} catch (emailError) {
logger.error("Failed to send recovery code used email:", emailError);
}
auditLog(req, '2fa.recovery.used', user.id, { lowCodes: remainingCodes <= 2 });
res.json({
message: "Verification successful",
verified: true,
remainingCodes,
warning:
remainingCodes <= 2
? "You are running low on recovery codes. Please generate new ones."
: null,
});
} catch (error) {
logger.error("Recovery code verification error:", error);
res.status(500).json({ error: "Verification failed" });
}
}
);
// ============================================
// MANAGEMENT ENDPOINTS
// ============================================
/**
* GET /api/2fa/status
* Get current 2FA status for the user
*/
router.get("/status", async (req, res) => {
try {
const user = await User.findByPk(req.user.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
res.json({
enabled: user.twoFactorEnabled,
method: user.twoFactorMethod,
hasRecoveryCodes: user.getRemainingRecoveryCodes() > 0,
lowRecoveryCodes: user.getRemainingRecoveryCodes() <= 2,
});
} catch (error) {
logger.error("2FA status error:", error);
res.status(500).json({ error: "Failed to get 2FA status" });
}
});
/**
* POST /api/2fa/disable
* Disable 2FA (requires step-up authentication)
*/
router.post(
"/disable",
csrfProtection,
requireStepUpAuth("2fa_disable"),
async (req, res) => {
try {
const user = await User.findByPk(req.user.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
if (!user.twoFactorEnabled) {
logger.warn(`2FA disable failed for user ${user.id}: 2FA not enabled`);
return res.status(400).json({
error: "Operation failed",
});
}
await user.disableTwoFactor();
// Send notification email
try {
await emailServices.auth.sendTwoFactorDisabledEmail(user);
} catch (emailError) {
logger.error("Failed to send 2FA disabled email:", emailError);
}
auditLog(req, '2fa.disabled', user.id);
res.json({
message: "Multi-factor authentication has been disabled",
});
} catch (error) {
logger.error("2FA disable error:", error);
res.status(500).json({ error: "Failed to disable multi-factor authentication" });
}
}
);
/**
* POST /api/2fa/recovery/regenerate
* Generate new recovery codes (requires step-up authentication)
*/
router.post(
"/recovery/regenerate",
csrfProtection,
requireStepUpAuth("recovery_regenerate"),
async (req, res) => {
try {
const user = await User.findByPk(req.user.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
if (!user.twoFactorEnabled) {
logger.warn(`Recovery regenerate failed for user ${user.id}: 2FA not enabled`);
return res.status(400).json({
error: "Operation failed",
});
}
const recoveryCodes = await user.regenerateRecoveryCodes();
auditLog(req, '2fa.recovery.regenerated', user.id);
res.json({
recoveryCodes,
warning:
"Save these recovery codes in a secure location. Your previous codes are now invalid.",
});
} catch (error) {
logger.error("Recovery code regeneration error:", error);
res.status(500).json({ error: "Failed to regenerate recovery codes" });
}
}
);
/**
* GET /api/2fa/recovery/remaining
* Get recovery codes status (not exact count for security)
*/
router.get("/recovery/remaining", async (req, res) => {
try {
const user = await User.findByPk(req.user.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
const remaining = user.getRemainingRecoveryCodes();
res.json({
hasRecoveryCodes: remaining > 0,
lowRecoveryCodes: remaining <= 2,
});
} catch (error) {
logger.error("Recovery codes remaining error:", error);
res.status(500).json({ error: "Failed to get recovery codes status" });
}
});
module.exports = router;

View File

@@ -1,8 +1,12 @@
const express = require('express');
const { User, UserAddress } = require('../models'); // Import from models/index.js to get models with associations
const { authenticateToken, optionalAuth, requireAdmin } = require('../middleware/auth');
const { validateCoordinatesBody, validatePasswordChange, handleValidationErrors, sanitizeInput } = require('../middleware/validation');
const { requireStepUpAuth } = require('../middleware/stepUpAuth');
const { csrfProtection } = require('../middleware/csrf');
const logger = require('../utils/logger');
const userService = require('../services/UserService');
const emailServices = require('../services/email');
const { validateS3Keys } = require('../utils/s3KeyValidator');
const { IMAGE_LIMITS } = require('../config/imageLimits');
const router = express.Router();
@@ -109,7 +113,7 @@ router.get('/addresses', authenticateToken, async (req, res, next) => {
}
});
router.post('/addresses', authenticateToken, async (req, res, next) => {
router.post('/addresses', authenticateToken, ...validateCoordinatesBody, handleValidationErrors, async (req, res, next) => {
try {
// Extract only allowed fields (prevents mass assignment)
const allowedData = extractAllowedAddressFields(req.body);
@@ -128,7 +132,7 @@ router.post('/addresses', authenticateToken, async (req, res, next) => {
}
});
router.put('/addresses/:id', authenticateToken, async (req, res, next) => {
router.put('/addresses/:id', authenticateToken, ...validateCoordinatesBody, handleValidationErrors, async (req, res, next) => {
try {
// Extract only allowed fields (prevents mass assignment)
const allowedData = extractAllowedAddressFields(req.body);
@@ -269,7 +273,7 @@ router.put('/profile', authenticateToken, async (req, res, next) => {
res.json(updatedUser);
} catch (error) {
console.error('Profile update error:', error);
logger.error('Profile update error', { error });
next(error);
}
});
@@ -361,6 +365,58 @@ router.post('/admin/:id/ban', authenticateToken, requireAdmin, async (req, res,
}
});
// Change password (requires step-up auth if 2FA is enabled)
router.put('/password', authenticateToken, csrfProtection, requireStepUpAuth('password_change'), sanitizeInput, validatePasswordChange, async (req, res, next) => {
try {
const { currentPassword, newPassword } = req.body;
const user = await User.findByPk(req.user.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Google OAuth users can't change password
if (user.authProvider === 'google' && !user.password) {
return res.status(400).json({
error: 'Cannot change password for accounts linked with Google'
});
}
// Verify current password
const isValid = await user.comparePassword(currentPassword);
if (!isValid) {
return res.status(400).json({ error: 'Current password is incorrect' });
}
// Update password (this increments jwtVersion to invalidate other sessions)
await user.resetPassword(newPassword);
// Send password changed notification
try {
await emailServices.auth.sendPasswordChangedEmail(user);
} catch (emailError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error('Failed to send password changed email', {
error: emailError.message,
userId: req.user.id
});
}
const reqLogger = logger.withRequestId(req.id);
reqLogger.info('Password changed successfully', { userId: req.user.id });
res.json({ message: 'Password changed successfully' });
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error('Password change failed', {
error: error.message,
stack: error.stack,
userId: req.user.id
});
next(error);
}
});
// Admin: Unban a user
router.post('/admin/:id/unban', authenticateToken, requireAdmin, async (req, res, next) => {
try {

View File

@@ -1,5 +1,5 @@
// Load environment config
const env = process.env.NODE_ENV || "dev";
const env = process.env.NODE_ENV;
const envFile = `.env.${env}`;
require("dotenv").config({ path: envFile });
@@ -101,11 +101,11 @@ async function resendInvitation(emailOrCode) {
// Try to find by code first (if it looks like a code), otherwise by email
if (input.toUpperCase().startsWith("ALPHA-")) {
invitation = await AlphaInvitation.findOne({
where: { code: input.toUpperCase() }
where: { code: input.toUpperCase() },
});
} else {
invitation = await AlphaInvitation.findOne({
where: { email: normalizeEmail(input) }
where: { email: normalizeEmail(input) },
});
}
@@ -131,7 +131,10 @@ async function resendInvitation(emailOrCode) {
// Resend the email
try {
await emailServices.alphaInvitation.sendAlphaInvitation(invitation.email, invitation.code);
await emailServices.alphaInvitation.sendAlphaInvitation(
invitation.email,
invitation.code,
);
console.log(`\n✅ Alpha invitation resent successfully!`);
console.log(` Email: ${invitation.email}`);
@@ -178,7 +181,7 @@ async function listInvitations(filter = "all") {
});
console.log(
`\n📋 Alpha Invitations (${invitations.length} total, filter: ${filter})\n`
`\n📋 Alpha Invitations (${invitations.length} total, filter: ${filter})\n`,
);
console.log("─".repeat(100));
console.log(
@@ -186,7 +189,7 @@ async function listInvitations(filter = "all") {
"EMAIL".padEnd(30) +
"STATUS".padEnd(10) +
"USED BY".padEnd(25) +
"CREATED"
"CREATED",
);
console.log("─".repeat(100));
@@ -204,7 +207,7 @@ async function listInvitations(filter = "all") {
inv.email.padEnd(30) +
inv.status.padEnd(10) +
usedBy.padEnd(25) +
created
created,
);
});
}
@@ -221,7 +224,7 @@ async function listInvitations(filter = "all") {
};
console.log(
`\nSummary: ${stats.pending} pending | ${stats.active} active | ${stats.revoked} revoked\n`
`\nSummary: ${stats.pending} pending | ${stats.active} active | ${stats.revoked} revoked\n`,
);
return invitations;
@@ -274,7 +277,9 @@ async function restoreInvitation(code) {
}
if (invitation.status !== "revoked") {
console.log(`\n⚠️ Invitation is not revoked (current status: ${invitation.status})`);
console.log(
`\n⚠️ Invitation is not revoked (current status: ${invitation.status})`,
);
console.log(` Code: ${code}`);
console.log(` Email: ${invitation.email}`);
return invitation;
@@ -288,7 +293,9 @@ async function restoreInvitation(code) {
console.log(`\n✅ Invitation restored successfully!`);
console.log(` Code: ${code}`);
console.log(` Email: ${invitation.email}`);
console.log(` Status: ${newStatus} (${invitation.usedBy ? 'was previously used' : 'never used'})`);
console.log(
` Status: ${newStatus} (${invitation.usedBy ? "was previously used" : "never used"})`,
);
return invitation;
} catch (error) {
@@ -313,7 +320,7 @@ async function bulkImport(csvPath) {
const dataLines = hasHeader ? lines.slice(1) : lines;
console.log(
`\n📥 Importing ${dataLines.length} invitations from ${csvPath}...\n`
`\n📥 Importing ${dataLines.length} invitations from ${csvPath}...\n`,
);
let successCount = 0;
@@ -391,7 +398,7 @@ CSV Format:
if (!email) {
console.log("\n❌ Error: Email is required");
console.log(
"Usage: node scripts/manageAlphaInvitations.js add <email> [notes]\n"
"Usage: node scripts/manageAlphaInvitations.js add <email> [notes]\n",
);
process.exit(1);
}
@@ -406,7 +413,7 @@ CSV Format:
if (!code) {
console.log("\n❌ Error: Code is required");
console.log(
"Usage: node scripts/manageAlphaInvitations.js revoke <code>\n"
"Usage: node scripts/manageAlphaInvitations.js revoke <code>\n",
);
process.exit(1);
}
@@ -418,7 +425,7 @@ CSV Format:
if (!emailOrCode) {
console.log("\n❌ Error: Email or code is required");
console.log(
"Usage: node scripts/manageAlphaInvitations.js resend <email|code>\n"
"Usage: node scripts/manageAlphaInvitations.js resend <email|code>\n",
);
process.exit(1);
}
@@ -430,7 +437,7 @@ CSV Format:
if (!code) {
console.log("\n❌ Error: Code is required");
console.log(
"Usage: node scripts/manageAlphaInvitations.js restore <code>\n"
"Usage: node scripts/manageAlphaInvitations.js restore <code>\n",
);
process.exit(1);
}
@@ -442,7 +449,7 @@ CSV Format:
if (!csvPath) {
console.log("\n❌ Error: CSV path is required");
console.log(
"Usage: node scripts/manageAlphaInvitations.js bulk <csvPath>\n"
"Usage: node scripts/manageAlphaInvitations.js bulk <csvPath>\n",
);
process.exit(1);
}
@@ -451,7 +458,7 @@ CSV Format:
} else {
console.log(`\n❌ Unknown command: ${command}`);
console.log(
"Run 'node scripts/manageAlphaInvitations.js help' for usage information\n"
"Run 'node scripts/manageAlphaInvitations.js help' for usage information\n",
);
process.exit(1);
}

View File

@@ -1,5 +1,5 @@
// Load environment-specific config
const env = process.env.NODE_ENV || "dev";
const env = process.env.NODE_ENV;
const envFile = `.env.${env}`;
require("dotenv").config({
@@ -31,9 +31,8 @@ const conditionCheckRoutes = require("./routes/conditionChecks");
const feedbackRoutes = require("./routes/feedback");
const uploadRoutes = require("./routes/upload");
const healthRoutes = require("./routes/health");
const twoFactorRoutes = require("./routes/twoFactor");
const PayoutProcessor = require("./jobs/payoutProcessor");
const ConditionCheckReminderJob = require("./jobs/conditionCheckReminder");
const emailServices = require("./services/email");
const s3Service = require("./services/s3Service");
@@ -47,7 +46,7 @@ const server = http.createServer(app);
// Initialize Socket.io with CORS
const io = new Server(server, {
cors: {
origin: process.env.FRONTEND_URL || "http://localhost:3000",
origin: process.env.FRONTEND_URL,
credentials: true,
methods: ["GET", "POST"],
},
@@ -69,6 +68,7 @@ const {
addRequestId,
sanitizeError,
} = require("./middleware/security");
const { sanitizeInput } = require("./middleware/validation");
const { generalLimiter } = require("./middleware/rateLimiter");
const errorLogger = require("./middleware/errorLogger");
const apiLogger = require("./middleware/apiLogger");
@@ -93,7 +93,7 @@ app.use(
frameSrc: ["'self'", "https://accounts.google.com"],
},
},
})
}),
);
// Cookie parser for CSRF
@@ -108,11 +108,11 @@ app.use("/api/", apiLogger);
// CORS with security settings (must come BEFORE rate limiter to ensure headers on all responses)
app.use(
cors({
origin: process.env.FRONTEND_URL || "http://localhost:3000",
origin: process.env.FRONTEND_URL,
credentials: true,
optionsSuccessStatus: 200,
exposedHeaders: ["X-CSRF-Token"],
})
}),
);
// General rate limiting for all routes
@@ -126,22 +126,18 @@ app.use(
// Store raw body for webhook verification
req.rawBody = buf;
},
})
}),
);
app.use(
bodyParser.urlencoded({
extended: true,
limit: "1mb",
parameterLimit: 100, // Limit number of parameters
})
}),
);
// Serve static files from uploads directory with CORS headers
app.use(
"/uploads",
helmet.crossOriginResourcePolicy({ policy: "cross-origin" }),
express.static(path.join(__dirname, "uploads"))
);
// Apply input sanitization to all API routes (XSS prevention)
app.use("/api/", sanitizeInput);
// Health check endpoints (no auth, no rate limiting)
app.use("/health", healthRoutes);
@@ -157,6 +153,7 @@ app.get("/", (req, res) => {
// Public routes (no alpha access required)
app.use("/api/alpha", alphaRoutes);
app.use("/api/auth", authRoutes); // Auth has its own alpha checks in registration
app.use("/api/2fa", twoFactorRoutes); // 2FA routes require authentication (handled in router)
// Protected routes (require alpha access)
app.use("/api/users", requireAlphaAccess, userRoutes);
@@ -174,7 +171,7 @@ app.use("/api/upload", requireAlphaAccess, uploadRoutes);
app.use(errorLogger);
app.use(sanitizeError);
const PORT = process.env.PORT || 5000;
const PORT = process.env.PORT;
const { checkPendingMigrations } = require("./utils/checkMigrations");
@@ -188,7 +185,7 @@ sequelize
if (pendingMigrations.length > 0) {
logger.error(
`Found ${pendingMigrations.length} pending migration(s). Please run 'npm run db:migrate'`,
{ pendingMigrations }
{ pendingMigrations },
);
process.exit(1);
}
@@ -206,12 +203,12 @@ sequelize
// Fail fast - don't start server if email templates can't load
if (env === "prod" || env === "production") {
logger.error(
"Cannot start server without email services in production"
"Cannot start server without email services in production",
);
process.exit(1);
} else {
logger.warn(
"Email services failed to initialize - continuing in dev mode"
"Email services failed to initialize - continuing in dev mode",
);
}
}
@@ -229,15 +226,6 @@ sequelize
process.exit(1);
}
// Start the payout processor
const payoutJobs = PayoutProcessor.startScheduledPayouts();
logger.info("Payout processor started");
// Start the condition check reminder job
const conditionCheckJobs =
ConditionCheckReminderJob.startScheduledReminders();
logger.info("Condition check reminder job started");
server.listen(PORT, () => {
logger.info(`Server is running on port ${PORT}`, {
port: PORT,

View File

@@ -0,0 +1,305 @@
const crypto = require("crypto");
const { authenticator } = require("otplib");
const QRCode = require("qrcode");
const bcrypt = require("bcryptjs");
const logger = require("../utils/logger");
// Configuration
const TOTP_ISSUER = process.env.TOTP_ISSUER;
const EMAIL_OTP_EXPIRY_MINUTES = parseInt(
process.env.TWO_FACTOR_EMAIL_OTP_EXPIRY_MINUTES,
10,
);
const STEP_UP_VALIDITY_MINUTES = parseInt(
process.env.TWO_FACTOR_STEP_UP_VALIDITY_MINUTES,
10,
);
const MAX_EMAIL_OTP_ATTEMPTS = 3;
const RECOVERY_CODE_COUNT = 10;
const BCRYPT_ROUNDS = 12;
// Characters for recovery codes (excludes confusing chars: 0, O, 1, I, L)
const RECOVERY_CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
class TwoFactorService {
/**
* Generate a new TOTP secret and QR code for setup
* @param {string} email - User's email address
* @returns {Promise<{qrCodeDataUrl: string, encryptedSecret: string, encryptedSecretIv: string}>}
*/
static async generateTotpSecret(email) {
const secret = authenticator.generateSecret();
const otpAuthUrl = authenticator.keyuri(email, TOTP_ISSUER, secret);
// Generate QR code as data URL
const qrCodeDataUrl = await QRCode.toDataURL(otpAuthUrl);
// Encrypt the secret for storage
const { encrypted, iv } = this._encryptSecret(secret);
return {
qrCodeDataUrl,
encryptedSecret: encrypted,
encryptedSecretIv: iv,
};
}
/**
* Verify a TOTP code against a user's secret
* @param {string} encryptedSecret - Encrypted TOTP secret
* @param {string} iv - Initialization vector for decryption
* @param {string} code - 6-digit TOTP code to verify
* @returns {boolean}
*/
static verifyTotpCode(encryptedSecret, iv, code) {
try {
// Validate code format
if (!/^\d{6}$/.test(code)) {
return false;
}
// Decrypt the secret
const secret = this._decryptSecret(encryptedSecret, iv);
// Verify with window 0 (only current 30-second period) to prevent replay attacks
return authenticator.verify({ token: code, secret, window: 0 });
} catch (error) {
logger.error("TOTP verification error:", error);
return false;
}
}
/**
* Generate a 6-digit email OTP code
* @returns {{code: string, hashedCode: string, expiry: Date}}
*/
static generateEmailOtp() {
// Generate 6-digit numeric code
const code = crypto.randomInt(100000, 999999).toString();
// Hash the code for storage (SHA-256)
const hashedCode = crypto.createHash("sha256").update(code).digest("hex");
// Calculate expiry
const expiry = new Date(Date.now() + EMAIL_OTP_EXPIRY_MINUTES * 60 * 1000);
return { code, hashedCode, expiry };
}
/**
* Verify an email OTP code using timing-safe comparison
* @param {string} inputCode - Code entered by user
* @param {string} storedHash - Hashed code stored in database
* @param {Date} expiry - Expiry timestamp
* @returns {boolean}
*/
static verifyEmailOtp(inputCode, storedHash, expiry) {
try {
// Validate code format
if (!/^\d{6}$/.test(inputCode)) {
return false;
}
// Check expiry
if (!expiry || new Date() > new Date(expiry)) {
return false;
}
// Hash the input code
const inputHash = crypto
.createHash("sha256")
.update(inputCode)
.digest("hex");
// Timing-safe comparison
const inputBuffer = Buffer.from(inputHash, "hex");
const storedBuffer = Buffer.from(storedHash, "hex");
if (inputBuffer.length !== storedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(inputBuffer, storedBuffer);
} catch (error) {
logger.error("Email OTP verification error:", error);
return false;
}
}
/**
* Generate recovery codes (10 codes in XXXX-XXXX format)
* @returns {Promise<{codes: string[], hashedCodes: string[]}>}
*/
static async generateRecoveryCodes() {
const codes = [];
const hashedCodes = [];
for (let i = 0; i < RECOVERY_CODE_COUNT; i++) {
// Generate code in XXXX-XXXX format
let code = "";
for (let j = 0; j < 8; j++) {
if (j === 4) code += "-";
code +=
RECOVERY_CODE_CHARS[crypto.randomInt(RECOVERY_CODE_CHARS.length)];
}
codes.push(code);
// Hash the code for storage
const hashedCode = await bcrypt.hash(code, BCRYPT_ROUNDS);
hashedCodes.push(hashedCode);
}
return { codes, hashedCodes };
}
/**
* Verify a recovery code and return the index if valid
* @param {string} inputCode - Recovery code entered by user
* @param {Object} recoveryData - Recovery codes data (structured format)
* @returns {Promise<{valid: boolean, index: number}>}
*/
static async verifyRecoveryCode(inputCode, recoveryData) {
// Normalize input (uppercase, ensure format)
const normalizedCode = inputCode.toUpperCase().trim();
// Validate format
if (!/^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(normalizedCode)) {
return { valid: false, index: -1 };
}
// Handle both old format (array) and new format (structured object)
const codes = recoveryData.version
? recoveryData.codes
: recoveryData.map((hash, i) => ({
hash,
used: hash === null,
index: i,
}));
// Check each code
for (let i = 0; i < codes.length; i++) {
const codeEntry = codes[i];
// Skip already used codes
if (codeEntry.used || !codeEntry.hash) continue;
const isMatch = await bcrypt.compare(normalizedCode, codeEntry.hash);
if (isMatch) {
return { valid: true, index: i };
}
}
return { valid: false, index: -1 };
}
/**
* Validate if a step-up session is still valid
* @param {Object} user - User object with twoFactorVerifiedAt field
* @param {number} maxAgeMinutes - Maximum age in minutes (default: 15)
* @returns {boolean}
*/
static validateStepUpSession(user, maxAgeMinutes = STEP_UP_VALIDITY_MINUTES) {
if (!user.twoFactorVerifiedAt) {
return false;
}
const verifiedAt = new Date(user.twoFactorVerifiedAt);
const maxAge = maxAgeMinutes * 60 * 1000;
const now = Date.now();
return now - verifiedAt.getTime() < maxAge;
}
/**
* Get the count of remaining recovery codes
* @param {Object|Array} recoveryData - Recovery codes data (structured or legacy format)
* @returns {number}
*/
static getRemainingRecoveryCodesCount(recoveryData) {
if (!recoveryData) {
return 0;
}
// Handle new structured format
if (recoveryData.version) {
return recoveryData.codes.filter((code) => !code.used).length;
}
// Handle legacy array format
if (Array.isArray(recoveryData)) {
return recoveryData.filter((code) => code !== null && code !== "").length;
}
return 0;
}
/**
* Encrypt a TOTP secret using AES-256-GCM
* @param {string} secret - Plain text secret
* @returns {{encrypted: string, iv: string}}
* @private
*/
static _encryptSecret(secret) {
const encryptionKey = process.env.TOTP_ENCRYPTION_KEY;
if (!encryptionKey || encryptionKey.length !== 64) {
throw new Error(
"TOTP_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)",
);
}
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(
"aes-256-gcm",
Buffer.from(encryptionKey, "hex"),
iv,
);
let encrypted = cipher.update(secret, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag().toString("hex");
return {
encrypted: encrypted + ":" + authTag,
iv: iv.toString("hex"),
};
}
/**
* Decrypt a TOTP secret using AES-256-GCM
* @param {string} encryptedData - Encrypted data with auth tag
* @param {string} iv - Initialization vector (hex)
* @returns {string} - Decrypted secret
* @private
*/
static _decryptSecret(encryptedData, iv) {
const encryptionKey = process.env.TOTP_ENCRYPTION_KEY;
if (!encryptionKey || encryptionKey.length !== 64) {
throw new Error(
"TOTP_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)",
);
}
const [ciphertext, authTag] = encryptedData.split(":");
const decipher = crypto.createDecipheriv(
"aes-256-gcm",
Buffer.from(encryptionKey, "hex"),
Buffer.from(iv, "hex"),
);
decipher.setAuthTag(Buffer.from(authTag, "hex"));
let decrypted = decipher.update(ciphertext, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}
/**
* Check if email OTP attempts are locked
* @param {number} attempts - Current attempt count
* @returns {boolean}
*/
static isEmailOtpLocked(attempts) {
return attempts >= MAX_EMAIL_OTP_ATTEMPTS;
}
}
module.exports = TwoFactorService;

View File

@@ -0,0 +1,162 @@
const { Rental, User, Item } = require("../models");
const emailServices = require("./email");
const logger = require("../utils/logger");
class DisputeService {
/**
* Handle charge.dispute.created webhook
* Called when a renter disputes a charge with their bank
* @param {Object} dispute - The Stripe dispute object from the webhook
* @returns {Object} Processing result
*/
static async handleDisputeCreated(dispute) {
const paymentIntentId = dispute.payment_intent;
logger.info("Processing dispute.created webhook", {
disputeId: dispute.id,
paymentIntentId,
reason: dispute.reason,
amount: dispute.amount,
});
const rental = await Rental.findOne({
where: { stripePaymentIntentId: paymentIntentId },
include: [
{ model: User, as: "owner" },
{ model: User, as: "renter" },
{ model: Item, as: "item" },
],
});
if (!rental) {
logger.warn("Dispute received for unknown rental", {
paymentIntentId,
disputeId: dispute.id,
});
return { processed: false, reason: "rental_not_found" };
}
// Update rental with dispute info
await rental.update({
stripeDisputeStatus: dispute.status,
stripeDisputeId: dispute.id,
stripeDisputeReason: dispute.reason,
stripeDisputeAmount: dispute.amount,
stripeDisputeCreatedAt: new Date(dispute.created * 1000),
stripeDisputeEvidenceDueBy: new Date(
dispute.evidence_details.due_by * 1000
),
});
// Pause payout if not yet deposited to owner's bank
if (rental.bankDepositStatus !== "paid") {
await rental.update({ payoutStatus: "on_hold" });
logger.info("Payout placed on hold due to dispute", {
rentalId: rental.id,
});
}
// Send admin notification
await emailServices.payment.sendDisputeAlertEmail({
rentalId: rental.id,
amount: dispute.amount / 100,
reason: dispute.reason,
evidenceDueBy: new Date(dispute.evidence_details.due_by * 1000),
renterEmail: rental.renter?.email,
renterName: rental.renter?.firstName,
ownerEmail: rental.owner?.email,
ownerName: rental.owner?.firstName,
itemName: rental.item?.name,
});
logger.warn("Dispute created for rental", {
rentalId: rental.id,
disputeId: dispute.id,
reason: dispute.reason,
evidenceDueBy: dispute.evidence_details.due_by,
});
return { processed: true, rentalId: rental.id };
}
/**
* Handle dispute closed events (won, lost, or warning_closed)
* Called for: charge.dispute.closed, charge.dispute.funds_reinstated, charge.dispute.funds_withdrawn
* @param {Object} dispute - The Stripe dispute object from the webhook
* @returns {Object} Processing result
*/
static async handleDisputeClosed(dispute) {
logger.info("Processing dispute closed webhook", {
disputeId: dispute.id,
status: dispute.status,
});
const rental = await Rental.findOne({
where: { stripeDisputeId: dispute.id },
include: [{ model: User, as: "owner" }],
});
if (!rental) {
logger.warn("Dispute closed for unknown rental", {
disputeId: dispute.id,
});
return { processed: false, reason: "rental_not_found" };
}
const won = dispute.status === "won";
await rental.update({
stripeDisputeStatus: dispute.status,
stripeDisputeClosedAt: new Date(),
});
// If we won the dispute, resume payout if it was on hold
if (won && rental.payoutStatus === "on_hold") {
await rental.update({ payoutStatus: "pending" });
logger.info("Payout resumed after winning dispute", {
rentalId: rental.id,
});
}
// If we lost, record the loss amount
if (!won && dispute.status === "lost") {
await rental.update({
stripeDisputeLost: true,
stripeDisputeLostAmount: dispute.amount,
});
logger.warn("Dispute lost", {
rentalId: rental.id,
amount: dispute.amount,
});
// If owner was already paid, flag for manual review
if (rental.bankDepositStatus === "paid") {
await emailServices.payment.sendDisputeLostAlertEmail({
rentalId: rental.id,
amount: dispute.amount / 100,
ownerAlreadyPaid: true,
ownerPayoutAmount: rental.payoutAmount,
ownerEmail: rental.owner?.email,
ownerName: rental.owner?.firstName,
});
logger.warn(
"Dispute lost - owner already paid, flagged for manual review",
{
rentalId: rental.id,
payoutAmount: rental.payoutAmount,
}
);
}
}
logger.info("Dispute closed", {
rentalId: rental.id,
disputeId: dispute.id,
outcome: dispute.status,
});
return { processed: true, won, rentalId: rental.id };
}
}
module.exports = DisputeService;

View File

@@ -1,6 +1,7 @@
const { SESClient, SendEmailCommand } = require("@aws-sdk/client-ses");
const { getAWSConfig } = require("../../../config/aws");
const { htmlToPlainText } = require("./emailUtils");
const logger = require("../../../utils/logger");
/**
* EmailClient handles AWS SES configuration and core email sending functionality
@@ -44,9 +45,9 @@ class EmailClient {
this.sesClient = new SESClient(awsConfig);
this.initialized = true;
console.log("AWS SES Email Client initialized successfully");
logger.info("AWS SES Email Client initialized successfully");
} catch (error) {
console.error("Failed to initialize AWS SES Email Client:", error);
logger.error("Failed to initialize AWS SES Email Client", { error });
throw error;
}
})();
@@ -69,7 +70,7 @@ class EmailClient {
// Check if email sending is enabled in the environment
if (!process.env.EMAIL_ENABLED || process.env.EMAIL_ENABLED !== "true") {
console.log("Email sending disabled in environment");
logger.debug("Email sending disabled in environment");
return { success: true, messageId: "disabled" };
}
@@ -115,12 +116,10 @@ class EmailClient {
const command = new SendEmailCommand(params);
const result = await this.sesClient.send(command);
console.log(
`Email sent successfully to ${to}, MessageId: ${result.MessageId}`
);
logger.info("Email sent successfully", { to, messageId: result.MessageId });
return { success: true, messageId: result.MessageId };
} catch (error) {
console.error("Failed to send email:", error);
logger.error("Failed to send email", { error, to });
return { success: false, error: error.message };
}
}

View File

@@ -1,5 +1,7 @@
const fs = require("fs").promises;
const path = require("path");
const logger = require("../../../utils/logger");
const { escapeHtml } = require("./emailUtils");
/**
* TemplateManager handles loading, caching, and rendering email templates
@@ -9,6 +11,14 @@ const path = require("path");
* - Rendering templates with variable substitution
* - Providing fallback templates when files can't be loaded
*/
// Critical templates that must be preloaded at startup for auth flows
const CRITICAL_TEMPLATES = [
"emailVerificationToUser",
"passwordResetToUser",
"passwordChangedToUser",
"personalInfoChangedToUser",
];
class TemplateManager {
constructor() {
// Singleton pattern - return existing instance if already created
@@ -16,15 +26,76 @@ class TemplateManager {
return TemplateManager.instance;
}
this.templates = new Map();
this.templates = new Map(); // Cached template content
this.templateNames = new Set(); // Discovered template names
this.initialized = false;
this.initializationPromise = null;
this.templatesDir = path.join(
__dirname,
"..",
"..",
"..",
"templates",
"emails"
);
TemplateManager.instance = this;
}
/**
* Initialize the template manager by loading all email templates
* Discover all available templates by scanning the templates directory
* Only reads filenames, not content (for fast startup)
* @returns {Promise<void>}
*/
async discoverTemplates() {
try {
const files = await fs.readdir(this.templatesDir);
for (const file of files) {
if (file.endsWith(".html")) {
this.templateNames.add(file.replace(".html", ""));
}
}
logger.info("Discovered email templates", {
count: this.templateNames.size,
});
} catch (error) {
logger.error("Failed to discover email templates", {
templatesDir: this.templatesDir,
error,
});
throw error;
}
}
/**
* Load a single template from disk (lazy loading)
* @param {string} templateName - Name of the template (without .html extension)
* @returns {Promise<string>} Template content
*/
async loadTemplate(templateName) {
// Return cached template if already loaded
if (this.templates.has(templateName)) {
return this.templates.get(templateName);
}
const templatePath = path.join(this.templatesDir, `${templateName}.html`);
try {
const content = await fs.readFile(templatePath, "utf-8");
this.templates.set(templateName, content);
logger.debug("Loaded template", { templateName });
return content;
} catch (error) {
logger.error("Failed to load template", {
templateName,
templatePath,
error,
});
throw error;
}
}
/**
* Initialize the template manager by discovering templates and preloading critical ones
* @returns {Promise<void>}
*/
async initialize() {
@@ -38,130 +109,35 @@ class TemplateManager {
// Start initialization and store the promise
this.initializationPromise = (async () => {
await this.loadEmailTemplates();
this.initialized = true;
console.log("Email Template Manager initialized successfully");
})();
// Discover all available templates (fast - only reads filenames)
await this.discoverTemplates();
return this.initializationPromise;
}
/**
* Load all email templates from disk into memory
* @returns {Promise<void>}
*/
async loadEmailTemplates() {
const templatesDir = path.join(
__dirname,
"..",
"..",
"..",
"templates",
"emails"
);
// Critical templates that must load for the app to function
const criticalTemplates = [
"emailVerificationToUser.html",
"passwordResetToUser.html",
"passwordChangedToUser.html",
"personalInfoChangedToUser.html",
];
try {
const templateFiles = [
"conditionCheckReminderToUser.html",
"rentalConfirmationToUser.html",
"emailVerificationToUser.html",
"passwordResetToUser.html",
"passwordChangedToUser.html",
"personalInfoChangedToUser.html",
"lateReturnToCS.html",
"damageReportToCS.html",
"lostItemToCS.html",
"rentalRequestToOwner.html",
"rentalRequestConfirmationToRenter.html",
"rentalCancellationConfirmationToUser.html",
"rentalCancellationNotificationToUser.html",
"rentalDeclinedToRenter.html",
"rentalApprovalConfirmationToOwner.html",
"rentalCompletionThankYouToRenter.html",
"rentalCompletionCongratsToOwner.html",
"payoutReceivedToOwner.html",
"firstListingCelebrationToOwner.html",
"itemDeletionToOwner.html",
"alphaInvitationToUser.html",
"feedbackConfirmationToUser.html",
"feedbackNotificationToAdmin.html",
"newMessageToUser.html",
"forumCommentToPostAuthor.html",
"forumReplyToCommentAuthor.html",
"forumAnswerAcceptedToCommentAuthor.html",
"forumThreadActivityToParticipant.html",
"forumPostClosed.html",
"forumItemRequestNotification.html",
"forumPostDeletionToAuthor.html",
"forumCommentDeletionToAuthor.html",
"paymentDeclinedToRenter.html",
"paymentMethodUpdatedToOwner.html",
"userBannedNotification.html",
];
const failedTemplates = [];
for (const templateFile of templateFiles) {
try {
const templatePath = path.join(templatesDir, templateFile);
const templateContent = await fs.readFile(templatePath, "utf-8");
const templateName = path.basename(templateFile, ".html");
this.templates.set(templateName, templateContent);
console.log(`✓ Loaded template: ${templateName}`);
} catch (error) {
console.error(
`✗ Failed to load template ${templateFile}:`,
error.message
);
console.error(
` Template path: ${path.join(templatesDir, templateFile)}`
);
failedTemplates.push(templateFile);
// Preload critical templates for auth flows
const missingCritical = [];
for (const templateName of CRITICAL_TEMPLATES) {
if (!this.templateNames.has(templateName)) {
missingCritical.push(templateName);
} else {
await this.loadTemplate(templateName);
}
}
console.log(
`Loaded ${this.templates.size} of ${templateFiles.length} email templates`
);
// Check if critical templates are missing
const missingCriticalTemplates = criticalTemplates.filter(
(template) => !this.templates.has(path.basename(template, ".html"))
);
if (missingCriticalTemplates.length > 0) {
if (missingCritical.length > 0) {
const error = new Error(
`Critical email templates failed to load: ${missingCriticalTemplates.join(
", "
)}`
`Critical email templates not found: ${missingCritical.join(", ")}`
);
error.missingTemplates = missingCriticalTemplates;
error.missingTemplates = missingCritical;
throw error;
}
// Warn if non-critical templates failed
if (failedTemplates.length > 0) {
console.warn(
`⚠️ Non-critical templates failed to load: ${failedTemplates.join(
", "
)}`
);
console.warn("These templates will use fallback versions");
}
} catch (error) {
console.error("Failed to load email templates:", error);
console.error("Templates directory:", templatesDir);
console.error("Error stack:", error.stack);
throw error; // Re-throw to fail server startup
}
this.initialized = true;
logger.info("Email Template Manager initialized successfully", {
discovered: this.templateNames.size,
preloaded: CRITICAL_TEMPLATES.length,
});
})();
return this.initializationPromise;
}
/**
@@ -173,60 +149,74 @@ class TemplateManager {
async renderTemplate(templateName, variables = {}) {
// Ensure service is initialized before rendering
if (!this.initialized) {
console.log(`Template manager not initialized yet, initializing now...`);
logger.debug("Template manager not initialized yet, initializing now...");
await this.initialize();
}
let template = this.templates.get(templateName);
let template;
if (!template) {
console.error(`Template not found: ${templateName}`);
console.error(
`Available templates: ${Array.from(this.templates.keys()).join(", ")}`
);
console.error(`Stack trace:`, new Error().stack);
console.log(`Using fallback template for: ${templateName}`);
template = this.getFallbackTemplate(templateName);
// Check if template exists in our discovered templates
if (this.templateNames.has(templateName)) {
// Lazy load the template if not already cached
template = await this.loadTemplate(templateName);
} else {
console.log(`Template found: ${templateName}`);
logger.error("Template not found, using fallback", {
templateName,
discoveredTemplates: Array.from(this.templateNames),
});
template = this.getFallbackTemplate(templateName);
}
let rendered = template;
try {
Object.keys(variables).forEach((key) => {
// Variables ending in 'Html' or 'Section' contain trusted HTML content
// (e.g., refundSection, stripeSection, earningsSection) - don't escape these
const isTrustedHtml = key.endsWith("Html") || key.endsWith("Section");
let value = variables[key] || "";
// Escape HTML in user-provided values to prevent XSS
if (!isTrustedHtml && typeof value === "string") {
value = escapeHtml(value);
}
const regex = new RegExp(`{{${key}}}`, "g");
rendered = rendered.replace(regex, variables[key] || "");
rendered = rendered.replace(regex, value);
});
} catch (error) {
console.error(`Error rendering template ${templateName}:`, error);
console.error(`Stack trace:`, error.stack);
console.error(`Variables provided:`, Object.keys(variables));
logger.error("Error rendering template", {
templateName,
variableKeys: Object.keys(variables),
error,
});
}
return rendered;
}
/**
* Get a fallback template when the HTML file is not available
* @param {string} templateName - Name of the template
* @returns {string} Fallback HTML template
* Get a generic fallback template when the HTML file is not available
* This is used as a last resort when a template cannot be loaded
* @param {string} templateName - Name of the template (for logging)
* @returns {string} Generic fallback HTML template
*/
getFallbackTemplate(templateName) {
const baseTemplate = `
logger.warn("Using generic fallback template", { templateName });
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{title}}</title>
<title>Village Share</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5; }
.container { max-width: 600px; margin: 0 auto; background-color: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.header { text-align: center; border-bottom: 2px solid #e9ecef; padding-bottom: 20px; margin-bottom: 30px; }
.logo { font-size: 24px; font-weight: bold; color: #333; }
.content { line-height: 1.6; color: #555; }
.button { display: inline-block; background-color: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; margin: 20px 0; }
.footer { margin-top: 30px; padding-top: 20px; border-top: 1px solid #e9ecef; text-align: center; font-size: 12px; color: #6c757d; }
</style>
</head>
@@ -236,7 +226,9 @@ class TemplateManager {
<div class="logo">Village Share</div>
</div>
<div class="content">
{{content}}
<p>Hi {{recipientName}},</p>
<h2>{{title}}</h2>
<p>{{message}}</p>
</div>
<div class="footer">
<p>This email was sent from Village Share. If you have any questions, please contact support.</p>
@@ -245,315 +237,6 @@ class TemplateManager {
</body>
</html>
`;
const templates = {
conditionCheckReminderToUser: baseTemplate.replace(
"{{content}}",
`
<h2>{{title}}</h2>
<p>{{message}}</p>
<p><strong>Rental Item:</strong> {{itemName}}</p>
<p><strong>Deadline:</strong> {{deadline}}</p>
<p>Please complete this condition check as soon as possible to ensure proper documentation.</p>
`
),
rentalConfirmationToUser: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{recipientName}},</p>
<h2>{{title}}</h2>
<p>{{message}}</p>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
<p>Thank you for using Village Share!</p>
`
),
emailVerificationToUser: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{recipientName}},</p>
<h2>Verify Your Email Address</h2>
<p>Thank you for registering with Village Share! Please verify your email address by clicking the button below.</p>
<p><a href="{{verificationUrl}}" class="button">Verify Email Address</a></p>
<p>If the button doesn't work, copy and paste this link into your browser: {{verificationUrl}}</p>
<p><strong>This link will expire in 24 hours.</strong></p>
`
),
passwordResetToUser: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{recipientName}},</p>
<h2>Reset Your Password</h2>
<p>We received a request to reset the password for your Village Share account. Click the button below to choose a new password.</p>
<p><a href="{{resetUrl}}" class="button">Reset Password</a></p>
<p>If the button doesn't work, copy and paste this link into your browser: {{resetUrl}}</p>
<p><strong>This link will expire in 1 hour.</strong></p>
<p>If you didn't request this, you can safely ignore this email.</p>
`
),
passwordChangedToUser: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{recipientName}},</p>
<h2>Your Password Has Been Changed</h2>
<p>This is a confirmation that the password for your Village Share account ({{email}}) has been successfully changed.</p>
<p><strong>Changed on:</strong> {{timestamp}}</p>
<p>For your security, all existing sessions have been logged out.</p>
<p><strong>Didn't change your password?</strong> If you did not make this change, please contact our support team immediately.</p>
`
),
rentalRequestToOwner: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{ownerName}},</p>
<h2>New Rental Request for {{itemName}}</h2>
<p>{{renterName}} would like to rent your item.</p>
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
<p><strong>Total Amount:</strong> \${{totalAmount}}</p>
<p><strong>Your Earnings:</strong> \${{payoutAmount}}</p>
<p><strong>Delivery Method:</strong> {{deliveryMethod}}</p>
<p><strong>Intended Use:</strong> {{intendedUse}}</p>
<p><a href="{{approveUrl}}" class="button">Review & Respond</a></p>
<p>Please respond to this request within 24 hours.</p>
`
),
rentalRequestConfirmationToRenter: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{renterName}},</p>
<h2>Your Rental Request Has Been Submitted!</h2>
<p>Your request to rent <strong>{{itemName}}</strong> has been sent to the owner.</p>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
<p><strong>Delivery Method:</strong> {{deliveryMethod}}</p>
<p><strong>Total Amount:</strong> \${{totalAmount}}</p>
<p>{{paymentMessage}}</p>
<p>You'll receive an email notification once the owner responds to your request.</p>
<p><a href="{{viewRentalsUrl}}" class="button">View My Rentals</a></p>
`
),
rentalCancellationConfirmationToUser: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{recipientName}},</p>
<h2>Rental Cancelled Successfully</h2>
<p>This confirms that your rental for <strong>{{itemName}}</strong> has been cancelled.</p>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Start Date:</strong> {{startDate}}</p>
<p><strong>End Date:</strong> {{endDate}}</p>
<p><strong>Cancelled On:</strong> {{cancelledAt}}</p>
{{refundSection}}
`
),
rentalCancellationNotificationToUser: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{recipientName}},</p>
<h2>Rental Cancellation Notice</h2>
<p>{{cancellationMessage}}</p>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Start Date:</strong> {{startDate}}</p>
<p><strong>End Date:</strong> {{endDate}}</p>
<p><strong>Cancelled On:</strong> {{cancelledAt}}</p>
{{additionalInfo}}
<p>If you have any questions or concerns, please reach out to our support team.</p>
`
),
payoutReceivedToOwner: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{ownerName}},</p>
<h2 style="color: #28a745;">Earnings Received: \${{payoutAmount}}</h2>
<p>Great news! Your earnings from the rental of <strong>{{itemName}}</strong> have been transferred to your account.</p>
<h3>Rental Details</h3>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
<p><strong>Transfer ID:</strong> {{stripeTransferId}}</p>
<h3>Earnings Breakdown</h3>
<p><strong>Rental Amount:</strong> \${{totalAmount}}</p>
<p><strong>Community Upkeep Fee (10%):</strong> -\${{platformFee}}</p>
<p style="font-size: 18px; color: #28a745;"><strong>Your Earnings:</strong> \${{payoutAmount}}</p>
<p>Funds are typically available in your bank account within 2-3 business days.</p>
<p><a href="{{earningsDashboardUrl}}" class="button">View Earnings Dashboard</a></p>
<p>Thank you for being a valued member of the Village Share community!</p>
`
),
rentalDeclinedToRenter: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{renterName}},</p>
<h2>Rental Request Declined</h2>
<p>Thank you for your interest in renting <strong>{{itemName}}</strong>. Unfortunately, the owner is unable to accept your rental request at this time.</p>
<h3>Request Details</h3>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Start Date:</strong> {{startDate}}</p>
<p><strong>End Date:</strong> {{endDate}}</p>
<p><strong>Delivery Method:</strong> {{deliveryMethod}}</p>
{{ownerMessage}}
<div class="info-box">
<p><strong>What happens next?</strong></p>
<p>{{paymentMessage}}</p>
<p>We encourage you to explore other similar items available for rent on Village Share. There are many great options waiting for you!</p>
</div>
<p style="text-align: center;"><a href="{{browseItemsUrl}}" class="button">Browse Available Items</a></p>
<p>If you have any questions or concerns, please don't hesitate to contact our support team.</p>
`
),
rentalApprovalConfirmationToOwner: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{ownerName}},</p>
<h2>You've Approved the Rental Request!</h2>
<p>You've successfully approved the rental request for <strong>{{itemName}}</strong>.</p>
<h3>Rental Details</h3>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Renter:</strong> {{renterName}}</p>
<p><strong>Start Date:</strong> {{startDate}}</p>
<p><strong>End Date:</strong> {{endDate}}</p>
<p><strong>Your Earnings:</strong> \${{payoutAmount}}</p>
{{stripeSection}}
<h3>What's Next?</h3>
<ul>
<li>Coordinate with the renter on pickup details</li>
<li>Take photos of the item's condition before handoff</li>
<li>Provide any care instructions or usage tips</li>
</ul>
<p><a href="{{rentalDetailsUrl}}" class="button">View Rental Details</a></p>
`
),
rentalCompletionThankYouToRenter: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{renterName}},</p>
<h2>Thank You for Returning On Time!</h2>
<p>You've successfully returned <strong>{{itemName}}</strong> on time. On-time returns like yours help build trust in the Village Share community!</p>
<h3>Rental Summary</h3>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
<p><strong>Returned On:</strong> {{returnedDate}}</p>
{{reviewSection}}
<p><a href="{{browseItemsUrl}}" class="button">Browse Available Items</a></p>
`
),
rentalCompletionCongratsToOwner: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{ownerName}},</p>
<h2>Congratulations on Completing a Rental!</h2>
<p><strong>{{itemName}}</strong> has been successfully returned on time. Great job!</p>
<h3>Rental Summary</h3>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Renter:</strong> {{renterName}}</p>
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
{{earningsSection}}
{{stripeSection}}
<p><a href="{{owningUrl}}" class="button">View My Listings</a></p>
`
),
feedbackConfirmationToUser: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{userName}},</p>
<h2>Thank You for Your Feedback!</h2>
<p>We've received your feedback and our team will review it carefully.</p>
<div style="background-color: #f8f9fa; border-left: 4px solid #007bff; padding: 15px; margin: 20px 0; font-style: italic;">
{{feedbackText}}
</div>
<p><strong>Submitted:</strong> {{submittedAt}}</p>
<p>Your input helps us improve Village Share for everyone. We take all feedback seriously and use it to make the platform better.</p>
<p>If your feedback requires a response, our team will reach out to you directly.</p>
`
),
feedbackNotificationToAdmin: baseTemplate.replace(
"{{content}}",
`
<h2>New Feedback Received</h2>
<p><strong>From:</strong> {{userName}} ({{userEmail}})</p>
<p><strong>User ID:</strong> {{userId}}</p>
<p><strong>Submitted:</strong> {{submittedAt}}</p>
<h3>Feedback Content</h3>
<div style="background-color: #e7f3ff; border-left: 4px solid #007bff; padding: 20px; margin: 20px 0;">
{{feedbackText}}
</div>
<h3>Technical Context</h3>
<p><strong>Feedback ID:</strong> {{feedbackId}}</p>
<p><strong>Page URL:</strong> {{url}}</p>
<p><strong>User Agent:</strong> {{userAgent}}</p>
<p>Please review this feedback and take appropriate action if needed.</p>
`
),
paymentDeclinedToRenter: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{renterFirstName}},</p>
<h2>Payment Issue with Your Rental Request</h2>
<p>The owner tried to approve your rental for <strong>{{itemName}}</strong>, but there was an issue processing your payment.</p>
<h3>What Happened</h3>
<p>{{declineReason}}</p>
<div class="info-box">
<p><strong>What You Can Do</strong></p>
<p>Please update your payment method so the owner can complete the approval of your rental request.</p>
</div>
<p>Once you update your payment method, the owner will be notified and can try to approve your rental again.</p>
`
),
paymentMethodUpdatedToOwner: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{ownerFirstName}},</p>
<h2>Payment Method Updated</h2>
<p>The renter has updated their payment method for the rental of <strong>{{itemName}}</strong>.</p>
<div class="info-box">
<p><strong>Ready to Approve</strong></p>
<p>You can now try approving the rental request again. The renter's new payment method will be charged when you approve.</p>
</div>
<p style="text-align: center;"><a href="{{approvalUrl}}" class="button">Review & Approve Rental</a></p>
`
),
userBannedNotification: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{userName}},</p>
<h2>Your Account Has Been Suspended</h2>
<p>Your Village Share account has been suspended by our moderation team.</p>
<div style="background-color: #f8d7da; border-left: 4px solid #dc3545; padding: 20px; margin: 20px 0;">
<p><strong>Reason for Suspension:</strong></p>
<p>{{banReason}}</p>
</div>
<p>You have been logged out of all devices and cannot log in to your account.</p>
<p>If you believe this suspension was made in error, please contact <a href="mailto:{{supportEmail}}">{{supportEmail}}</a>.</p>
`
),
};
return (
templates[templateName] ||
baseTemplate.replace(
"{{content}}",
`
<h2>{{title}}</h2>
<p>{{message}}</p>
`
)
);
}
}

View File

@@ -90,9 +90,26 @@ function formatCurrency(amount, currency = "USD") {
}).format(amount / 100);
}
/**
* Escape HTML special characters to prevent XSS attacks
* Converts characters that could be interpreted as HTML into safe entities
* @param {*} str - Value to escape (will be converted to string)
* @returns {string} HTML-escaped string safe for insertion into HTML
*/
function escapeHtml(str) {
if (str === null || str === undefined) return "";
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
module.exports = {
htmlToPlainText,
formatEmailDate,
formatShortDate,
formatCurrency,
escapeHtml,
};

View File

@@ -1,5 +1,6 @@
const EmailClient = require("../core/EmailClient");
const TemplateManager = require("../core/TemplateManager");
const logger = require("../../../utils/logger");
/**
* AlphaInvitationEmailService handles alpha program invitation emails
@@ -26,7 +27,7 @@ class AlphaInvitationEmailService {
]);
this.initialized = true;
console.log("Alpha Invitation Email Service initialized successfully");
logger.info("Alpha Invitation Email Service initialized successfully");
}
/**
@@ -41,7 +42,7 @@ class AlphaInvitationEmailService {
}
try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
const variables = {
code: code,
@@ -53,16 +54,16 @@ class AlphaInvitationEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"alphaInvitationToUser",
variables
variables,
);
return await this.emailClient.sendEmail(
email,
"Your Alpha Access Code - Village Share",
htmlContent
htmlContent,
);
} catch (error) {
console.error("Failed to send alpha invitation email:", error);
logger.error("Failed to send alpha invitation email", { error });
return { success: false, error: error.message };
}
}

View File

@@ -44,7 +44,7 @@ class AuthEmailService {
await this.initialize();
}
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
const verificationUrl = `${frontendUrl}/verify-email?token=${verificationToken}`;
const variables = {
@@ -55,13 +55,13 @@ class AuthEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"emailVerificationToUser",
variables
variables,
);
return await this.emailClient.sendEmail(
user.email,
"Verify Your Email - Village Share",
htmlContent
htmlContent,
);
}
@@ -78,7 +78,7 @@ class AuthEmailService {
await this.initialize();
}
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
const resetUrl = `${frontendUrl}/reset-password?token=${resetToken}`;
const variables = {
@@ -88,13 +88,13 @@ class AuthEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"passwordResetToUser",
variables
variables,
);
return await this.emailClient.sendEmail(
user.email,
"Reset Your Password - Village Share",
htmlContent
htmlContent,
);
}
@@ -123,13 +123,13 @@ class AuthEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"passwordChangedToUser",
variables
variables,
);
return await this.emailClient.sendEmail(
user.email,
"Password Changed Successfully - Village Share",
htmlContent
htmlContent,
);
}
@@ -158,13 +158,157 @@ class AuthEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"personalInfoChangedToUser",
variables
variables,
);
return await this.emailClient.sendEmail(
user.email,
"Personal Information Updated - Village Share",
htmlContent
htmlContent,
);
}
/**
* Send two-factor authentication OTP email
* @param {Object} user - User object
* @param {string} user.firstName - User's first name
* @param {string} user.email - User's email address
* @param {string} otpCode - 6-digit OTP code
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
*/
async sendTwoFactorOtpEmail(user, otpCode) {
if (!this.initialized) {
await this.initialize();
}
const variables = {
recipientName: user.firstName || "there",
otpCode: otpCode,
};
const htmlContent = await this.templateManager.renderTemplate(
"twoFactorOtpToUser",
variables,
);
return await this.emailClient.sendEmail(
user.email,
"Your Verification Code - Village Share",
htmlContent,
);
}
/**
* Send two-factor authentication enabled confirmation email
* @param {Object} user - User object
* @param {string} user.firstName - User's first name
* @param {string} user.email - User's email address
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
*/
async sendTwoFactorEnabledEmail(user) {
if (!this.initialized) {
await this.initialize();
}
const timestamp = new Date().toLocaleString("en-US", {
dateStyle: "long",
timeStyle: "short",
});
const variables = {
recipientName: user.firstName || "there",
timestamp: timestamp,
};
const htmlContent = await this.templateManager.renderTemplate(
"twoFactorEnabledToUser",
variables,
);
return await this.emailClient.sendEmail(
user.email,
"Multi-Factor Authentication Enabled - Village Share",
htmlContent,
);
}
/**
* Send two-factor authentication disabled notification email
* @param {Object} user - User object
* @param {string} user.firstName - User's first name
* @param {string} user.email - User's email address
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
*/
async sendTwoFactorDisabledEmail(user) {
if (!this.initialized) {
await this.initialize();
}
const timestamp = new Date().toLocaleString("en-US", {
dateStyle: "long",
timeStyle: "short",
});
const variables = {
recipientName: user.firstName || "there",
timestamp: timestamp,
};
const htmlContent = await this.templateManager.renderTemplate(
"twoFactorDisabledToUser",
variables,
);
return await this.emailClient.sendEmail(
user.email,
"Multi-Factor Authentication Disabled - Village Share",
htmlContent,
);
}
/**
* Send recovery code used notification email
* @param {Object} user - User object
* @param {string} user.firstName - User's first name
* @param {string} user.email - User's email address
* @param {number} remainingCodes - Number of remaining recovery codes
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
*/
async sendRecoveryCodeUsedEmail(user, remainingCodes) {
if (!this.initialized) {
await this.initialize();
}
const timestamp = new Date().toLocaleString("en-US", {
dateStyle: "long",
timeStyle: "short",
});
// Determine color based on remaining codes
let remainingCodesColor = "#28a745"; // Green
if (remainingCodes <= 2) {
remainingCodesColor = "#dc3545"; // Red
} else if (remainingCodes <= 5) {
remainingCodesColor = "#fd7e14"; // Orange
}
const variables = {
recipientName: user.firstName || "there",
remainingCodes: remainingCodes,
remainingCodesColor: remainingCodesColor,
lowCodesWarning: remainingCodes <= 2,
timestamp: timestamp,
};
const htmlContent = await this.templateManager.renderTemplate(
"recoveryCodeUsedToUser",
variables,
);
return await this.emailClient.sendEmail(
user.email,
"Recovery Code Used - Village Share",
htmlContent,
);
}
}

View File

@@ -1,5 +1,6 @@
const EmailClient = require("../core/EmailClient");
const TemplateManager = require("../core/TemplateManager");
const logger = require("../../../utils/logger");
/**
* CustomerServiceEmailService handles all customer service alert emails
@@ -28,7 +29,7 @@ class CustomerServiceEmailService {
]);
this.initialized = true;
console.log("Customer Service Email Service initialized successfully");
logger.info("Customer Service Email Service initialized successfully");
}
/**
@@ -59,7 +60,7 @@ class CustomerServiceEmailService {
try {
const csEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
if (!csEmail) {
console.warn("No customer service email configured");
logger.warn("No customer service email configured");
return { success: false, error: "No customer service email configured" };
}
@@ -92,14 +93,14 @@ class CustomerServiceEmailService {
);
if (result.success) {
console.log(
logger.info(
`Late return notification sent to customer service for rental ${rental.id}`
);
}
return result;
} catch (error) {
console.error(
logger.error(
"Failed to send late return notification to customer service:",
error
);
@@ -148,7 +149,7 @@ class CustomerServiceEmailService {
try {
const csEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
if (!csEmail) {
console.warn("No customer service email configured");
logger.warn("No customer service email configured");
return { success: false, error: "No customer service email configured" };
}
@@ -206,14 +207,14 @@ class CustomerServiceEmailService {
);
if (result.success) {
console.log(
logger.info(
`Damage report notification sent to customer service for rental ${rental.id}`
);
}
return result;
} catch (error) {
console.error(
logger.error(
"Failed to send damage report notification to customer service:",
error
);
@@ -248,7 +249,7 @@ class CustomerServiceEmailService {
try {
const csEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
if (!csEmail) {
console.warn("No customer service email configured");
logger.warn("No customer service email configured");
return { success: false, error: "No customer service email configured" };
}
@@ -280,14 +281,14 @@ class CustomerServiceEmailService {
);
if (result.success) {
console.log(
logger.info(
`Lost item notification sent to customer service for rental ${rental.id}`
);
}
return result;
} catch (error) {
console.error(
logger.error(
"Failed to send lost item notification to customer service:",
error
);

View File

@@ -60,13 +60,13 @@ class FeedbackEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"feedbackConfirmationToUser",
variables
variables,
);
return await this.emailClient.sendEmail(
user.email,
"Thank You for Your Feedback - Village Share",
htmlContent
htmlContent,
);
}
@@ -90,8 +90,7 @@ class FeedbackEmailService {
await this.initialize();
}
const adminEmail =
process.env.FEEDBACK_EMAIL || process.env.CUSTOMER_SUPPORT_EMAIL;
const adminEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
if (!adminEmail) {
console.warn("No admin email configured for feedback notifications");
@@ -117,13 +116,13 @@ class FeedbackEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"feedbackNotificationToAdmin",
variables
variables,
);
return await this.emailClient.sendEmail(
adminEmail,
`New Feedback from ${user.firstName} ${user.lastName}`,
htmlContent
htmlContent,
);
}
}

View File

@@ -1,5 +1,6 @@
const EmailClient = require("../core/EmailClient");
const TemplateManager = require("../core/TemplateManager");
const logger = require("../../../utils/logger");
/**
* ForumEmailService handles all forum-related email notifications
@@ -31,7 +32,7 @@ class ForumEmailService {
]);
this.initialized = true;
console.log("Forum Email Service initialized successfully");
logger.info("Forum Email Service initialized successfully");
}
/**
@@ -56,7 +57,7 @@ class ForumEmailService {
}
try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
const timestamp = new Date(comment.createdAt).toLocaleString("en-US", {
@@ -76,7 +77,7 @@ class ForumEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"forumCommentToPostAuthor",
variables
variables,
);
const subject = `${commenter.firstName} ${commenter.lastName} commented on your post`;
@@ -84,18 +85,18 @@ class ForumEmailService {
const result = await this.emailClient.sendEmail(
postAuthor.email,
subject,
htmlContent
htmlContent,
);
if (result.success) {
console.log(
`Forum comment notification email sent to ${postAuthor.email}`
logger.info(
`Forum comment notification email sent to ${postAuthor.email}`,
);
}
return result;
} catch (error) {
console.error("Failed to send forum comment notification email:", error);
logger.error("Failed to send forum comment notification email:", error);
return { success: false, error: error.message };
}
}
@@ -123,14 +124,14 @@ class ForumEmailService {
replier,
post,
reply,
parentComment
parentComment,
) {
if (!this.initialized) {
await this.initialize();
}
try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
const timestamp = new Date(reply.createdAt).toLocaleString("en-US", {
@@ -151,7 +152,7 @@ class ForumEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"forumReplyToCommentAuthor",
variables
variables,
);
const subject = `${replier.firstName} ${replier.lastName} replied to your comment`;
@@ -159,18 +160,18 @@ class ForumEmailService {
const result = await this.emailClient.sendEmail(
commentAuthor.email,
subject,
htmlContent
htmlContent,
);
if (result.success) {
console.log(
`Forum reply notification email sent to ${commentAuthor.email}`
logger.info(
`Forum reply notification email sent to ${commentAuthor.email}`,
);
}
return result;
} catch (error) {
console.error("Failed to send forum reply notification email:", error);
logger.error("Failed to send forum reply notification email:", error);
return { success: false, error: error.message };
}
}
@@ -194,14 +195,14 @@ class ForumEmailService {
commentAuthor,
postAuthor,
post,
comment
comment,
) {
if (!this.initialized) {
await this.initialize();
}
try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
const variables = {
@@ -215,7 +216,7 @@ class ForumEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"forumAnswerAcceptedToCommentAuthor",
variables
variables,
);
const subject = `Your comment was marked as the accepted answer!`;
@@ -223,20 +224,20 @@ class ForumEmailService {
const result = await this.emailClient.sendEmail(
commentAuthor.email,
subject,
htmlContent
htmlContent,
);
if (result.success) {
console.log(
`Forum answer accepted notification email sent to ${commentAuthor.email}`
logger.info(
`Forum answer accepted notification email sent to ${commentAuthor.email}`,
);
}
return result;
} catch (error) {
console.error(
logger.error(
"Failed to send forum answer accepted notification email:",
error
error,
);
return { success: false, error: error.message };
}
@@ -262,14 +263,14 @@ class ForumEmailService {
participant,
commenter,
post,
comment
comment,
) {
if (!this.initialized) {
await this.initialize();
}
try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
const timestamp = new Date(comment.createdAt).toLocaleString("en-US", {
@@ -289,7 +290,7 @@ class ForumEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"forumThreadActivityToParticipant",
variables
variables,
);
const subject = `New activity on a post you're following`;
@@ -297,20 +298,20 @@ class ForumEmailService {
const result = await this.emailClient.sendEmail(
participant.email,
subject,
htmlContent
htmlContent,
);
if (result.success) {
console.log(
`Forum thread activity notification email sent to ${participant.email}`
logger.info(
`Forum thread activity notification email sent to ${participant.email}`,
);
}
return result;
} catch (error) {
console.error(
logger.error(
"Failed to send forum thread activity notification email:",
error
error,
);
return { success: false, error: error.message };
}
@@ -330,18 +331,13 @@ class ForumEmailService {
* @param {Date} closedAt - Timestamp when discussion was closed
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
*/
async sendForumPostClosedNotification(
recipient,
closer,
post,
closedAt
) {
async sendForumPostClosedNotification(recipient, closer, post, closedAt) {
if (!this.initialized) {
await this.initialize();
}
try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
const timestamp = new Date(closedAt).toLocaleString("en-US", {
@@ -351,8 +347,7 @@ class ForumEmailService {
const variables = {
recipientName: recipient.firstName || "there",
adminName:
`${closer.firstName} ${closer.lastName}`.trim() || "A user",
adminName: `${closer.firstName} ${closer.lastName}`.trim() || "A user",
postTitle: post.title,
postUrl: postUrl,
timestamp: timestamp,
@@ -360,7 +355,7 @@ class ForumEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"forumPostClosed",
variables
variables,
);
const subject = `Discussion closed: ${post.title}`;
@@ -368,20 +363,20 @@ class ForumEmailService {
const result = await this.emailClient.sendEmail(
recipient.email,
subject,
htmlContent
htmlContent,
);
if (result.success) {
console.log(
`Forum post closed notification email sent to ${recipient.email}`
logger.info(
`Forum post closed notification email sent to ${recipient.email}`,
);
}
return result;
} catch (error) {
console.error(
logger.error(
"Failed to send forum post closed notification email:",
error
error,
);
return { success: false, error: error.message };
}
@@ -400,18 +395,24 @@ class ForumEmailService {
* @param {string} deletionReason - Reason for deletion
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
*/
async sendForumPostDeletionNotification(postAuthor, admin, post, deletionReason) {
async sendForumPostDeletionNotification(
postAuthor,
admin,
post,
deletionReason,
) {
if (!this.initialized) {
await this.initialize();
}
try {
const supportEmail = process.env.SUPPORT_EMAIL;
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const supportEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
const frontendUrl = process.env.FRONTEND_URL;
const variables = {
postAuthorName: postAuthor.firstName || "there",
adminName: `${admin.firstName} ${admin.lastName}`.trim() || "An administrator",
adminName:
`${admin.firstName} ${admin.lastName}`.trim() || "An administrator",
postTitle: post.title,
deletionReason,
supportEmail,
@@ -420,7 +421,7 @@ class ForumEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"forumPostDeletionToAuthor",
variables
variables,
);
const subject = `Important: Your forum post "${post.title}" has been removed`;
@@ -428,20 +429,20 @@ class ForumEmailService {
const result = await this.emailClient.sendEmail(
postAuthor.email,
subject,
htmlContent
htmlContent,
);
if (result.success) {
console.log(
`Forum post deletion notification email sent to ${postAuthor.email}`
logger.info(
`Forum post deletion notification email sent to ${postAuthor.email}`,
);
}
return result;
} catch (error) {
console.error(
logger.error(
"Failed to send forum post deletion notification email:",
error
error,
);
return { success: false, error: error.message };
}
@@ -461,19 +462,25 @@ class ForumEmailService {
* @param {string} deletionReason - Reason for deletion
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
*/
async sendForumCommentDeletionNotification(commentAuthor, admin, post, deletionReason) {
async sendForumCommentDeletionNotification(
commentAuthor,
admin,
post,
deletionReason,
) {
if (!this.initialized) {
await this.initialize();
}
try {
const supportEmail = process.env.SUPPORT_EMAIL;
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const supportEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
const frontendUrl = process.env.FRONTEND_URL;
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
const variables = {
commentAuthorName: commentAuthor.firstName || "there",
adminName: `${admin.firstName} ${admin.lastName}`.trim() || "An administrator",
adminName:
`${admin.firstName} ${admin.lastName}`.trim() || "An administrator",
postTitle: post.title,
postUrl,
deletionReason,
@@ -482,7 +489,7 @@ class ForumEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"forumCommentDeletionToAuthor",
variables
variables,
);
const subject = `Your comment on "${post.title}" has been removed`;
@@ -490,20 +497,20 @@ class ForumEmailService {
const result = await this.emailClient.sendEmail(
commentAuthor.email,
subject,
htmlContent
htmlContent,
);
if (result.success) {
console.log(
`Forum comment deletion notification email sent to ${commentAuthor.email}`
logger.info(
`Forum comment deletion notification email sent to ${commentAuthor.email}`,
);
}
return result;
} catch (error) {
console.error(
logger.error(
"Failed to send forum comment deletion notification email:",
error
error,
);
return { success: false, error: error.message };
}
@@ -530,7 +537,7 @@ class ForumEmailService {
}
try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
const variables = {
@@ -545,7 +552,7 @@ class ForumEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"forumItemRequestNotification",
variables
variables,
);
const subject = `Someone nearby is looking for: ${post.title}`;
@@ -553,18 +560,18 @@ class ForumEmailService {
const result = await this.emailClient.sendEmail(
recipient.email,
subject,
htmlContent
htmlContent,
);
if (result.success) {
console.log(
`Item request notification email sent to ${recipient.email}`
logger.info(
`Item request notification email sent to ${recipient.email}`,
);
}
return result;
} catch (error) {
console.error("Failed to send item request notification email:", error);
logger.error("Failed to send item request notification email:", error);
return { success: false, error: error.message };
}
}

View File

@@ -1,5 +1,6 @@
const EmailClient = require("../core/EmailClient");
const TemplateManager = require("../core/TemplateManager");
const logger = require("../../../utils/logger");
/**
* MessagingEmailService handles all messaging-related email notifications
@@ -26,7 +27,7 @@ class MessagingEmailService {
]);
this.initialized = true;
console.log("Messaging Email Service initialized successfully");
logger.info("Messaging Email Service initialized successfully");
}
/**
@@ -49,7 +50,7 @@ class MessagingEmailService {
}
try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
const conversationUrl = `${frontendUrl}/messages/conversations/${sender.id}`;
const timestamp = new Date(message.createdAt).toLocaleString("en-US", {
@@ -67,7 +68,7 @@ class MessagingEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"newMessageToUser",
variables
variables,
);
const subject = `New message from ${sender.firstName} ${sender.lastName}`;
@@ -75,18 +76,18 @@ class MessagingEmailService {
const result = await this.emailClient.sendEmail(
receiver.email,
subject,
htmlContent
htmlContent,
);
if (result.success) {
console.log(
`Message notification email sent to ${receiver.email} from ${sender.firstName} ${sender.lastName}`
logger.info(
`Message notification email sent to ${receiver.email} from ${sender.firstName} ${sender.lastName}`,
);
}
return result;
} catch (error) {
console.error("Failed to send message notification email:", error);
logger.error("Failed to send message notification email:", error);
return { success: false, error: error.message };
}
}

View File

@@ -1,5 +1,7 @@
const EmailClient = require("../core/EmailClient");
const TemplateManager = require("../core/TemplateManager");
const { formatEmailDate } = require("../core/emailUtils");
const logger = require("../../../utils/logger");
/**
* PaymentEmailService handles payment-related emails
@@ -27,7 +29,7 @@ class PaymentEmailService {
]);
this.initialized = true;
console.log("Payment Email Service initialized successfully");
logger.info("Payment Email Service initialized successfully");
}
/**
@@ -47,12 +49,8 @@ class PaymentEmailService {
}
try {
const {
renterFirstName,
itemName,
declineReason,
updatePaymentUrl,
} = params;
const { renterFirstName, itemName, declineReason, updatePaymentUrl } =
params;
const variables = {
renterFirstName: renterFirstName || "there",
@@ -63,16 +61,16 @@ class PaymentEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"paymentDeclinedToRenter",
variables
variables,
);
return await this.emailClient.sendEmail(
renterEmail,
`Action Required: Payment Issue - ${itemName || "Your Rental"}`,
htmlContent
htmlContent,
);
} catch (error) {
console.error("Failed to send payment declined notification:", error);
logger.error("Failed to send payment declined notification", { error });
return { success: false, error: error.message };
}
}
@@ -103,19 +101,275 @@ class PaymentEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"paymentMethodUpdatedToOwner",
variables
variables,
);
return await this.emailClient.sendEmail(
ownerEmail,
`Payment Method Updated - ${itemName || "Your Item"}`,
htmlContent
htmlContent,
);
} catch (error) {
console.error("Failed to send payment method updated notification:", error);
logger.error("Failed to send payment method updated notification", {
error,
});
return { success: false, error: error.message };
}
}
/**
* Send payout failed notification to owner
* @param {string} ownerEmail - Owner's email address
* @param {Object} params - Email parameters
* @param {string} params.ownerName - Owner's name
* @param {number} params.payoutAmount - Payout amount in dollars
* @param {string} params.failureMessage - User-friendly failure message
* @param {string} params.actionRequired - Action the owner needs to take
* @param {string} params.failureCode - The Stripe failure code
* @param {boolean} params.requiresBankUpdate - Whether bank account update is needed
* @param {string} params.payoutSettingsUrl - URL to payout settings
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
*/
async sendPayoutFailedNotification(ownerEmail, params) {
if (!this.initialized) {
await this.initialize();
}
try {
const {
ownerName,
payoutAmount,
failureMessage,
actionRequired,
failureCode,
requiresBankUpdate,
payoutSettingsUrl,
} = params;
const variables = {
ownerName: ownerName || "there",
payoutAmount: payoutAmount?.toFixed(2) || "0.00",
failureMessage:
failureMessage || "There was an issue with your payout.",
actionRequired:
actionRequired || "Please check your bank account details.",
failureCode: failureCode || "unknown",
requiresBankUpdate: requiresBankUpdate || false,
payoutSettingsUrl:
payoutSettingsUrl || process.env.FRONTEND_URL + "/settings/payouts",
};
const htmlContent = await this.templateManager.renderTemplate(
"payoutFailedToOwner",
variables,
);
return await this.emailClient.sendEmail(
ownerEmail,
"Action Required: Payout Issue - Village Share",
htmlContent,
);
} catch (error) {
logger.error("Failed to send payout failed notification", { error });
return { success: false, error: error.message };
}
}
/**
* Send notification when owner disconnects their Stripe account
* @param {string} ownerEmail - Owner's email address
* @param {Object} params - Email parameters
* @param {string} params.ownerName - Owner's name
* @param {boolean} params.hasPendingPayouts - Whether there are pending payouts
* @param {number} params.pendingPayoutCount - Number of pending payouts
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
*/
async sendAccountDisconnectedEmail(ownerEmail, params) {
if (!this.initialized) {
await this.initialize();
}
try {
const { ownerName, hasPendingPayouts, pendingPayoutCount } = params;
const variables = {
ownerName: ownerName || "there",
hasPendingPayouts: hasPendingPayouts || false,
pendingPayoutCount: pendingPayoutCount || 0,
reconnectUrl: `${process.env.FRONTEND_URL}/settings/payouts`,
};
const htmlContent = await this.templateManager.renderTemplate(
"accountDisconnectedToOwner",
variables,
);
return await this.emailClient.sendEmail(
ownerEmail,
"Your payout account has been disconnected - Village Share",
htmlContent,
);
} catch (error) {
logger.error("Failed to send account disconnected email", { error });
return { success: false, error: error.message };
}
}
/**
* Send notification when owner's payouts are disabled due to requirements
* @param {string} ownerEmail - Owner's email address
* @param {Object} params - Email parameters
* @param {string} params.ownerName - Owner's name
* @param {string} params.disabledReason - Human-readable reason for disabling
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
*/
async sendPayoutsDisabledEmail(ownerEmail, params) {
if (!this.initialized) {
await this.initialize();
}
try {
const { ownerName, disabledReason } = params;
const variables = {
ownerName: ownerName || "there",
disabledReason:
disabledReason ||
"Additional verification is required for your account.",
earningsUrl: `${process.env.FRONTEND_URL}/earnings`,
};
const htmlContent = await this.templateManager.renderTemplate(
"payoutsDisabledToOwner",
variables,
);
return await this.emailClient.sendEmail(
ownerEmail,
"Action Required: Your payouts have been paused - Village Share",
htmlContent,
);
} catch (error) {
logger.error("Failed to send payouts disabled email", { error });
return { success: false, error: error.message };
}
}
/**
* Send dispute alert to platform admin
* Called when a new dispute is opened
* @param {Object} disputeData - Dispute information
* @param {string} disputeData.rentalId - Rental ID
* @param {number} disputeData.amount - Disputed amount in dollars
* @param {string} disputeData.reason - Stripe dispute reason code
* @param {Date} disputeData.evidenceDueBy - Evidence submission deadline
* @param {string} disputeData.renterEmail - Renter's email
* @param {string} disputeData.renterName - Renter's name
* @param {string} disputeData.ownerEmail - Owner's email
* @param {string} disputeData.ownerName - Owner's name
* @param {string} disputeData.itemName - Item name
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
*/
async sendDisputeAlertEmail(disputeData) {
if (!this.initialized) {
await this.initialize();
}
try {
const variables = {
rentalId: disputeData.rentalId,
itemName: disputeData.itemName || "Unknown Item",
amount: disputeData.amount.toFixed(2),
reason: this.formatDisputeReason(disputeData.reason),
evidenceDueBy: formatEmailDate(disputeData.evidenceDueBy),
renterName: disputeData.renterName || "Unknown",
renterEmail: disputeData.renterEmail || "Unknown",
ownerName: disputeData.ownerName || "Unknown",
ownerEmail: disputeData.ownerEmail || "Unknown",
};
const htmlContent = await this.templateManager.renderTemplate(
"disputeAlertToAdmin",
variables,
);
// Send to admin email (configure in env)
const adminEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
return await this.emailClient.sendEmail(
adminEmail,
`URGENT: Payment Dispute - Rental #${disputeData.rentalId}`,
htmlContent,
);
} catch (error) {
logger.error("Failed to send dispute alert email", { error });
return { success: false, error: error.message };
}
}
/**
* Send alert when dispute is lost and owner was already paid
* Flags for manual review to decide on potential clawback
* @param {Object} disputeData - Dispute information
* @param {string} disputeData.rentalId - Rental ID
* @param {number} disputeData.amount - Lost dispute amount in dollars
* @param {number} disputeData.ownerPayoutAmount - Amount already paid to owner
* @param {string} disputeData.ownerEmail - Owner's email
* @param {string} disputeData.ownerName - Owner's name
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
*/
async sendDisputeLostAlertEmail(disputeData) {
if (!this.initialized) {
await this.initialize();
}
try {
const variables = {
rentalId: disputeData.rentalId,
amount: disputeData.amount.toFixed(2),
ownerPayoutAmount: parseFloat(
disputeData.ownerPayoutAmount || 0,
).toFixed(2),
ownerName: disputeData.ownerName || "Unknown",
ownerEmail: disputeData.ownerEmail || "Unknown",
};
const htmlContent = await this.templateManager.renderTemplate(
"disputeLostAlertToAdmin",
variables,
);
const adminEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
return await this.emailClient.sendEmail(
adminEmail,
`ACTION REQUIRED: Dispute Lost - Owner Already Paid - Rental #${disputeData.rentalId}`,
htmlContent,
);
} catch (error) {
logger.error("Failed to send dispute lost alert email", { error });
return { success: false, error: error.message };
}
}
/**
* Format Stripe dispute reason codes to human-readable text
* @param {string} reason - Stripe dispute reason code
* @returns {string} Human-readable reason
*/
formatDisputeReason(reason) {
const reasonMap = {
duplicate: "Duplicate charge",
fraudulent: "Fraudulent transaction",
subscription_canceled: "Subscription canceled",
product_unacceptable: "Product unacceptable",
product_not_received: "Product not received",
unrecognized: "Unrecognized charge",
credit_not_processed: "Credit not processed",
general: "General dispute",
};
return reasonMap[reason] || reason || "Unknown reason";
}
}
module.exports = PaymentEmailService;

View File

@@ -34,7 +34,7 @@ class RentalFlowEmailService {
]);
this.initialized = true;
console.log("Rental Flow Email Service initialized successfully");
logger.info("Rental Flow Email Service initialized successfully");
}
/**
@@ -62,12 +62,13 @@ class RentalFlowEmailService {
}
try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
const approveUrl = `${frontendUrl}/owning?rentalId=${rental.id}`;
const variables = {
ownerName: owner.firstName,
renterName: `${renter.firstName} ${renter.lastName}`.trim() || "A renter",
renterName:
`${renter.firstName} ${renter.lastName}`.trim() || "A renter",
itemName: rental.item?.name || "your item",
startDate: rental.startDateTime
? new Date(rental.startDateTime).toLocaleString("en-US", {
@@ -94,16 +95,16 @@ class RentalFlowEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"rentalRequestToOwner",
variables
variables,
);
return await this.emailClient.sendEmail(
owner.email,
`Rental Request for ${rental.item?.name || "Your Item"}`,
htmlContent
htmlContent,
);
} catch (error) {
console.error("Failed to send rental request email:", error);
logger.error("Failed to send rental request email", { error });
return { success: false, error: error.message };
}
}
@@ -128,7 +129,7 @@ class RentalFlowEmailService {
}
try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
const viewRentalsUrl = `${frontendUrl}/renting`;
// Determine payment message based on rental amount
@@ -161,16 +162,18 @@ class RentalFlowEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"rentalRequestConfirmationToRenter",
variables
variables,
);
return await this.emailClient.sendEmail(
renter.email,
`Rental Request Submitted - ${rental.item?.name || "Item"}`,
htmlContent
htmlContent,
);
} catch (error) {
console.error("Failed to send rental request confirmation email:", error);
logger.error("Failed to send rental request confirmation email", {
error,
});
return { success: false, error: error.message };
}
}
@@ -202,7 +205,7 @@ class RentalFlowEmailService {
}
try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
// Determine if Stripe setup is needed
const hasStripeAccount = !!owner.stripeConnectedAccountId;
@@ -227,15 +230,15 @@ class RentalFlowEmailService {
<table class="info-table">
<tr>
<th>Total Rental Amount</th>
<td>\\$${totalAmount.toFixed(2)}</td>
<td>$${totalAmount.toFixed(2)}</td>
</tr>
<tr>
<th>Community Upkeep Fee (10%)</th>
<td>-\\$${platformFee.toFixed(2)}</td>
<td>-$${platformFee.toFixed(2)}</td>
</tr>
<tr>
<th>Your Payout</th>
<td class="highlight">\\$${payoutAmount.toFixed(2)}</td>
<td class="highlight">$${payoutAmount.toFixed(2)}</td>
</tr>
</table>
`;
@@ -248,8 +251,8 @@ class RentalFlowEmailService {
stripeSection = `
<div class="warning-box">
<p><strong>⚠️ Action Required: Set Up Your Earnings Account</strong></p>
<p>To receive your payout of <strong>\\$${payoutAmount.toFixed(
2
<p>To receive your payout of <strong>$${payoutAmount.toFixed(
2,
)}</strong> when this rental completes, you need to set up your earnings account.</p>
</div>
<h2>Set Up Earnings to Get Paid</h2>
@@ -274,8 +277,8 @@ class RentalFlowEmailService {
stripeSection = `
<div class="success-box">
<p><strong>✓ Earnings Account Active</strong></p>
<p>Your earnings account is set up. You'll automatically receive \\$${payoutAmount.toFixed(
2
<p>Your earnings account is set up. You'll automatically receive $${payoutAmount.toFixed(
2,
)} when this rental completes.</p>
<p><a href="${frontendUrl}/earnings" style="color: #155724; text-decoration: underline;">View your earnings dashboard →</a></p>
</div>
@@ -312,7 +315,7 @@ class RentalFlowEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"rentalApprovalConfirmationToOwner",
variables
variables,
);
const subject = `Rental Approved - ${rental.item?.name || "Your Item"}`;
@@ -320,10 +323,12 @@ class RentalFlowEmailService {
return await this.emailClient.sendEmail(
owner.email,
subject,
htmlContent
htmlContent,
);
} catch (error) {
console.error("Failed to send rental approval confirmation email:", error);
logger.error("Failed to send rental approval confirmation email", {
error,
});
return { success: false, error: error.message };
}
}
@@ -350,7 +355,7 @@ class RentalFlowEmailService {
}
try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
const browseItemsUrl = `${frontendUrl}/`;
// Determine payment message based on rental amount
@@ -397,16 +402,16 @@ class RentalFlowEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"rentalDeclinedToRenter",
variables
variables,
);
return await this.emailClient.sendEmail(
renter.email,
`Rental Request Declined - ${rental.item?.name || "Item"}`,
htmlContent
htmlContent,
);
} catch (error) {
console.error("Failed to send rental declined email:", error);
logger.error("Failed to send rental declined email", { error });
return { success: false, error: error.message };
}
}
@@ -437,7 +442,7 @@ class RentalFlowEmailService {
notification,
rental,
recipientName = null,
isRenter = false
isRenter = false,
) {
if (!this.initialized) {
await this.initialize();
@@ -532,7 +537,7 @@ class RentalFlowEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"rentalConfirmationToUser",
variables
variables,
);
// Use clear, transactional subject line with item name
@@ -540,7 +545,7 @@ class RentalFlowEmailService {
return await this.emailClient.sendEmail(userEmail, subject, htmlContent);
} catch (error) {
console.error("Failed to send rental confirmation:", error);
logger.error("Failed to send rental confirmation", { error });
return { success: false, error: error.message };
}
}
@@ -601,24 +606,24 @@ class RentalFlowEmailService {
ownerNotification,
rental,
owner.firstName,
false // isRenter = false for owner
false, // isRenter = false for owner
);
if (ownerResult.success) {
console.log(
`Rental confirmation email sent to owner: ${owner.email}`
);
logger.info("Rental confirmation email sent to owner", {
email: owner.email,
});
results.ownerEmailSent = true;
} else {
console.error(
`Failed to send rental confirmation email to owner (${owner.email}):`,
ownerResult.error
);
logger.error("Failed to send rental confirmation email to owner", {
email: owner.email,
error: ownerResult.error,
});
}
} catch (error) {
console.error(
`Failed to send rental confirmation email to owner (${owner.email}):`,
error.message
);
logger.error("Failed to send rental confirmation email to owner", {
email: owner.email,
error,
});
}
}
@@ -630,31 +635,30 @@ class RentalFlowEmailService {
renterNotification,
rental,
renter.firstName,
true // isRenter = true for renter (enables payment receipt)
true, // isRenter = true for renter (enables payment receipt)
);
if (renterResult.success) {
console.log(
`Rental confirmation email sent to renter: ${renter.email}`
);
logger.info("Rental confirmation email sent to renter", {
email: renter.email,
});
results.renterEmailSent = true;
} else {
console.error(
`Failed to send rental confirmation email to renter (${renter.email}):`,
renterResult.error
);
logger.error("Failed to send rental confirmation email to renter", {
email: renter.email,
error: renterResult.error,
});
}
} catch (error) {
console.error(
`Failed to send rental confirmation email to renter (${renter.email}):`,
error.message
);
logger.error("Failed to send rental confirmation email to renter", {
email: renter.email,
error,
});
}
}
} catch (error) {
console.error(
"Error fetching user data for rental confirmation emails:",
error
);
logger.error("Error fetching user data for rental confirmation emails", {
error,
});
}
return results;
@@ -693,7 +697,7 @@ class RentalFlowEmailService {
};
try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
const browseUrl = `${frontendUrl}/`;
const cancelledBy = rental.cancelledBy;
@@ -737,7 +741,7 @@ class RentalFlowEmailService {
<div class="info-box">
<p><strong>Full Refund Processed</strong></p>
<p>You will receive a full refund of $${refundInfo.amount.toFixed(
2
2,
)}. The refund will appear in your account within 5-10 business days.</p>
</div>
<div style="text-align: center">
@@ -780,7 +784,7 @@ class RentalFlowEmailService {
<div class="refund-amount">$${refundInfo.amount.toFixed(2)}</div>
<div class="info-box">
<p><strong>Refund Amount:</strong> $${refundInfo.amount.toFixed(
2
2,
)} (${refundPercentage}% of total)</p>
<p><strong>Reason:</strong> ${refundInfo.reason}</p>
<p><strong>Processing Time:</strong> Refunds typically appear within 5-10 business days.</p>
@@ -810,26 +814,27 @@ class RentalFlowEmailService {
const confirmationHtml = await this.templateManager.renderTemplate(
"rentalCancellationConfirmationToUser",
confirmationVariables
confirmationVariables,
);
const confirmationResult = await this.emailClient.sendEmail(
confirmationRecipient,
`Cancellation Confirmed - ${itemName}`,
confirmationHtml
confirmationHtml,
);
if (confirmationResult.success) {
console.log(
`Cancellation confirmation email sent to ${cancelledBy}: ${confirmationRecipient}`
);
logger.info("Cancellation confirmation email sent", {
cancelledBy,
email: confirmationRecipient,
});
results.confirmationEmailSent = true;
}
} catch (error) {
console.error(
`Failed to send cancellation confirmation email to ${cancelledBy}:`,
error.message
);
logger.error("Failed to send cancellation confirmation email", {
cancelledBy,
error,
});
}
// Send notification email to other party
@@ -846,31 +851,29 @@ class RentalFlowEmailService {
const notificationHtml = await this.templateManager.renderTemplate(
"rentalCancellationNotificationToUser",
notificationVariables
notificationVariables,
);
const notificationResult = await this.emailClient.sendEmail(
notificationRecipient,
`Rental Cancelled - ${itemName}`,
notificationHtml
notificationHtml,
);
if (notificationResult.success) {
console.log(
`Cancellation notification email sent to ${
cancelledBy === "owner" ? "renter" : "owner"
}: ${notificationRecipient}`
);
logger.info("Cancellation notification email sent", {
recipientType: cancelledBy === "owner" ? "renter" : "owner",
email: notificationRecipient,
});
results.notificationEmailSent = true;
}
} catch (error) {
console.error(
`Failed to send cancellation notification email:`,
error.message
);
logger.error("Failed to send cancellation notification email", {
error,
});
}
} catch (error) {
console.error("Error sending cancellation emails:", error);
logger.error("Error sending cancellation emails", { error });
}
return results;
@@ -905,7 +908,7 @@ class RentalFlowEmailService {
await this.initialize();
}
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
const results = {
renterEmailSent: false,
ownerEmailSent: false,
@@ -977,32 +980,32 @@ class RentalFlowEmailService {
const renterHtmlContent = await this.templateManager.renderTemplate(
"rentalCompletionThankYouToRenter",
renterVariables
renterVariables,
);
const renterResult = await this.emailClient.sendEmail(
renter.email,
`Thank You for Returning "${rental.item?.name || "Item"}" On Time!`,
renterHtmlContent
renterHtmlContent,
);
if (renterResult.success) {
console.log(
`Rental completion thank you email sent to renter: ${renter.email}`
);
logger.info("Rental completion thank you email sent to renter", {
email: renter.email,
});
results.renterEmailSent = true;
} else {
console.error(
`Failed to send rental completion email to renter (${renter.email}):`,
renterResult.error
);
logger.error("Failed to send rental completion email to renter", {
email: renter.email,
error: renterResult.error,
});
}
} catch (emailError) {
logger.error("Failed to send rental completion email to renter", {
error: emailError.message,
stack: emailError.stack,
renterEmail: renter.email,
rentalId: rental.id
rentalId: rental.id,
});
}
@@ -1021,15 +1024,15 @@ class RentalFlowEmailService {
<table class="info-table">
<tr>
<th>Total Rental Amount</th>
<td>\\$${totalAmount.toFixed(2)}</td>
<td>$${totalAmount.toFixed(2)}</td>
</tr>
<tr>
<th>Community Upkeep Fee (10%)</th>
<td>-\\$${platformFee.toFixed(2)}</td>
<td>-$${platformFee.toFixed(2)}</td>
</tr>
<tr>
<th>Your Payout</th>
<td class="highlight">\\$${payoutAmount.toFixed(2)}</td>
<td class="highlight">$${payoutAmount.toFixed(2)}</td>
</tr>
</table>
<p style="font-size: 14px; color: #6c757d;">
@@ -1045,8 +1048,8 @@ class RentalFlowEmailService {
stripeSection = `
<div class="warning-box">
<p><strong>⚠️ Action Required: Set Up Your Earnings Account</strong></p>
<p>To receive your payout of <strong>\\$${payoutAmount.toFixed(
2
<p>To receive your payout of <strong>$${payoutAmount.toFixed(
2,
)}</strong>, you need to set up your earnings account.</p>
</div>
<h2>Set Up Earnings to Get Paid</h2>
@@ -1071,8 +1074,8 @@ class RentalFlowEmailService {
stripeSection = `
<div class="success-box">
<p><strong>✓ Payout Initiated</strong></p>
<p>Your earnings of <strong>\\$${payoutAmount.toFixed(
2
<p>Your earnings of <strong>$${payoutAmount.toFixed(
2,
)}</strong> have been transferred to your Stripe account.</p>
<p style="font-size: 14px; margin-top: 10px;">Funds typically reach your bank within 2-7 business days.</p>
<p><a href="${frontendUrl}/earnings" style="color: #155724; text-decoration: underline;">View your earnings dashboard →</a></p>
@@ -1097,39 +1100,39 @@ class RentalFlowEmailService {
const ownerHtmlContent = await this.templateManager.renderTemplate(
"rentalCompletionCongratsToOwner",
ownerVariables
ownerVariables,
);
const ownerResult = await this.emailClient.sendEmail(
owner.email,
`Rental Complete - ${rental.item?.name || "Your Item"}`,
ownerHtmlContent
ownerHtmlContent,
);
if (ownerResult.success) {
console.log(
`Rental completion congratulations email sent to owner: ${owner.email}`
);
logger.info("Rental completion congratulations email sent to owner", {
email: owner.email,
});
results.ownerEmailSent = true;
} else {
console.error(
`Failed to send rental completion email to owner (${owner.email}):`,
ownerResult.error
);
logger.error("Failed to send rental completion email to owner", {
email: owner.email,
error: ownerResult.error,
});
}
} catch (emailError) {
logger.error("Failed to send rental completion email to owner", {
error: emailError.message,
stack: emailError.stack,
ownerEmail: owner.email,
rentalId: rental.id
rentalId: rental.id,
});
}
} catch (error) {
logger.error("Error sending rental completion emails", {
error: error.message,
stack: error.stack,
rentalId: rental?.id
rentalId: rental?.id,
});
}
@@ -1158,7 +1161,7 @@ class RentalFlowEmailService {
}
try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
const earningsDashboardUrl = `${frontendUrl}/earnings`;
// Format currency values
@@ -1190,7 +1193,7 @@ class RentalFlowEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"payoutReceivedToOwner",
variables
variables,
);
return await this.emailClient.sendEmail(
@@ -1198,10 +1201,54 @@ class RentalFlowEmailService {
`Earnings Received - $${payoutAmount.toFixed(2)} for ${
rental.item?.name || "Your Item"
}`,
htmlContent
htmlContent,
);
} catch (error) {
console.error("Failed to send payout received email:", error);
logger.error("Failed to send payout received email", { error });
return { success: false, error: error.message };
}
}
/**
* Send authentication required email to renter when 3DS verification is needed
* This is sent when the owner approves a rental but the renter's bank requires
* additional verification (3D Secure) to complete the payment.
*
* @param {string} email - Renter's email address
* @param {Object} data - Email data
* @param {string} data.renterName - Renter's first name
* @param {string} data.itemName - Name of the item being rented
* @param {string} data.ownerName - Owner's first name
* @param {number} data.amount - Total rental amount
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
*/
async sendAuthenticationRequiredEmail(email, data) {
if (!this.initialized) {
await this.initialize();
}
try {
const { renterName, itemName, ownerName, amount } = data;
const variables = {
renterName: renterName || "there",
itemName: itemName || "the item",
ownerName: ownerName || "The owner",
amount: typeof amount === "number" ? amount.toFixed(2) : "0.00",
};
const htmlContent = await this.templateManager.renderTemplate(
"authenticationRequiredToRenter",
variables,
);
return await this.emailClient.sendEmail(
email,
`Action Required: Complete payment for ${itemName}`,
htmlContent,
);
} catch (error) {
logger.error("Failed to send authentication required email", { error });
return { success: false, error: error.message };
}
}

View File

@@ -1,5 +1,6 @@
const EmailClient = require("../core/EmailClient");
const TemplateManager = require("../core/TemplateManager");
const logger = require("../../../utils/logger");
/**
* RentalReminderEmailService handles rental reminder emails
@@ -26,7 +27,7 @@ class RentalReminderEmailService {
]);
this.initialized = true;
console.log("Rental Reminder Email Service initialized successfully");
logger.info("Rental Reminder Email Service initialized successfully");
}
/**
@@ -68,7 +69,7 @@ class RentalReminderEmailService {
htmlContent
);
} catch (error) {
console.error("Failed to send condition check reminder:", error);
logger.error("Failed to send condition check reminder:", error);
return { success: false, error: error.message };
}
}

View File

@@ -1,5 +1,6 @@
const EmailClient = require("../core/EmailClient");
const TemplateManager = require("../core/TemplateManager");
const logger = require("../../../utils/logger");
/**
* UserEngagementEmailService handles user engagement emails
@@ -27,7 +28,7 @@ class UserEngagementEmailService {
]);
this.initialized = true;
console.log("User Engagement Email Service initialized successfully");
logger.info("User Engagement Email Service initialized successfully");
}
/**
@@ -46,7 +47,7 @@ class UserEngagementEmailService {
}
try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
const variables = {
ownerName: owner.firstName || "there",
@@ -57,7 +58,7 @@ class UserEngagementEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"firstListingCelebrationToOwner",
variables
variables,
);
const subject = `Congratulations! Your first item is live on Village Share`;
@@ -65,10 +66,10 @@ class UserEngagementEmailService {
return await this.emailClient.sendEmail(
owner.email,
subject,
htmlContent
htmlContent,
);
} catch (error) {
console.error("Failed to send first listing celebration email:", error);
logger.error("Failed to send first listing celebration email", { error });
return { success: false, error: error.message };
}
}
@@ -90,8 +91,8 @@ class UserEngagementEmailService {
}
try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const supportEmail = process.env.SUPPORT_EMAIL;
const frontendUrl = process.env.FRONTEND_URL;
const supportEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
const variables = {
ownerName: owner.firstName || "there",
@@ -103,7 +104,7 @@ class UserEngagementEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"itemDeletionToOwner",
variables
variables,
);
const subject = `Important: Your listing "${item.name}" has been removed`;
@@ -111,10 +112,12 @@ class UserEngagementEmailService {
return await this.emailClient.sendEmail(
owner.email,
subject,
htmlContent
htmlContent,
);
} catch (error) {
console.error("Failed to send item deletion notification email:", error);
logger.error("Failed to send item deletion notification email", {
error,
});
return { success: false, error: error.message };
}
}
@@ -136,7 +139,7 @@ class UserEngagementEmailService {
}
try {
const supportEmail = process.env.SUPPORT_EMAIL;
const supportEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
const variables = {
userName: bannedUser.firstName || "there",
@@ -146,26 +149,27 @@ class UserEngagementEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"userBannedNotification",
variables
variables,
);
const subject = "Important: Your Village Share Account Has Been Suspended";
const subject =
"Important: Your Village Share Account Has Been Suspended";
const result = await this.emailClient.sendEmail(
bannedUser.email,
subject,
htmlContent
htmlContent,
);
if (result.success) {
console.log(
`User banned notification email sent to ${bannedUser.email}`
);
logger.info("User banned notification email sent", {
email: bannedUser.email,
});
}
return result;
} catch (error) {
console.error("Failed to send user banned notification email:", error);
logger.error("Failed to send user banned notification email", { error });
return { success: false, error: error.message };
}
}

View File

@@ -0,0 +1,292 @@
const {
SchedulerClient,
CreateScheduleCommand,
DeleteScheduleCommand,
} = require("@aws-sdk/client-scheduler");
const { getAWSConfig } = require("../config/aws");
const logger = require("../utils/logger");
/**
* Service for managing EventBridge Scheduler schedules for condition check reminders.
*
* Creates one-time schedules when a rental is confirmed:
* - pre_rental_owner: 24 hours before rental start
* - rental_start_renter: At rental start
* - rental_end_renter: At rental end
* - post_rental_owner: 24 hours after rental end
*/
class EventBridgeSchedulerService {
constructor() {
if (EventBridgeSchedulerService.instance) {
return EventBridgeSchedulerService.instance;
}
this.client = null;
this.initialized = false;
// Only enable when explicitly set - allows deploying code without activating the feature
this.conditionCheckRemindersEnabled =
process.env.CONDITION_CHECK_REMINDERS_ENABLED === "true";
EventBridgeSchedulerService.instance = this;
}
/**
* Initialize the EventBridge Scheduler client.
*/
async initialize() {
if (this.initialized) return;
try {
const awsConfig = getAWSConfig();
this.client = new SchedulerClient(awsConfig);
this.initialized = true;
logger.info("EventBridge Scheduler Service initialized");
} catch (error) {
logger.error("Failed to initialize EventBridge Scheduler Service", {
error,
});
throw error;
}
}
/**
* Get configuration from environment.
*/
getConfig() {
return {
groupName:
process.env.SCHEDULER_GROUP_NAME || "condition-check-reminders",
lambdaArn: process.env.LAMBDA_CONDITION_CHECK_ARN,
schedulerRoleArn: process.env.SCHEDULER_ROLE_ARN,
};
}
/**
* Create schedule name for a rental and check type.
*/
getScheduleName(rentalId, checkType) {
return `rental-${rentalId}-${checkType}`;
}
/**
* Calculate schedule times for all 4 check types.
*/
getScheduleTimes(rental) {
const startDate = new Date(rental.startDateTime);
const endDate = new Date(rental.endDateTime);
return {
pre_rental_owner: new Date(startDate.getTime() - 24 * 60 * 60 * 1000),
rental_start_renter: startDate,
rental_end_renter: endDate,
post_rental_owner: new Date(endDate.getTime() + 24 * 60 * 60 * 1000),
};
}
/**
* Create a single EventBridge schedule.
*/
async createSchedule(name, scheduleTime, payload) {
if (!this.initialized) {
await this.initialize();
}
const config = this.getConfig();
if (!config.lambdaArn) {
logger.warn("Lambda ARN not configured, skipping schedule creation", {
name,
});
return null;
}
// Don't create schedules for times in the past
if (scheduleTime <= new Date()) {
logger.info("Schedule time is in the past, skipping", {
name,
scheduleTime,
});
return null;
}
try {
const command = new CreateScheduleCommand({
Name: name,
GroupName: config.groupName,
ScheduleExpression: `at(${scheduleTime.toISOString().replace(".000Z", "")})`,
ScheduleExpressionTimezone: "UTC",
FlexibleTimeWindow: {
Mode: "OFF",
},
Target: {
Arn: config.lambdaArn,
RoleArn: config.schedulerRoleArn,
Input: JSON.stringify({
...payload,
scheduleName: name,
}),
},
ActionAfterCompletion: "DELETE", // Auto-delete after execution
});
const result = await this.client.send(command);
logger.info("Created EventBridge schedule", {
name,
scheduleTime,
scheduleArn: result.ScheduleArn,
});
return result.ScheduleArn;
} catch (error) {
// If schedule already exists, that's okay
if (error.name === "ConflictException") {
logger.info("Schedule already exists", { name });
return name;
}
logger.error("Failed to create EventBridge schedule", {
name,
error: error.message,
});
throw error;
}
}
/**
* Delete a single EventBridge schedule.
*/
async deleteSchedule(name) {
if (!this.initialized) {
await this.initialize();
}
const config = this.getConfig();
try {
const command = new DeleteScheduleCommand({
Name: name,
GroupName: config.groupName,
});
await this.client.send(command);
logger.info("Deleted EventBridge schedule", { name });
return true;
} catch (error) {
// If schedule doesn't exist, that's okay
if (error.name === "ResourceNotFoundException") {
logger.info("Schedule not found (already deleted)", { name });
return true;
}
logger.error("Failed to delete EventBridge schedule", {
name,
error: error.message,
});
throw error;
}
}
/**
* Create all 4 condition check schedules for a rental.
* Call this after a rental is confirmed.
*/
async createConditionCheckSchedules(rental) {
if (!this.conditionCheckRemindersEnabled) {
logger.debug(
"EventBridge Scheduler disabled, skipping schedule creation"
);
return null;
}
logger.info("Creating condition check schedules for rental", {
rentalId: rental.id,
});
const scheduleTimes = this.getScheduleTimes(rental);
let schedulesCreated = 0;
const checkTypes = [
"pre_rental_owner",
"rental_start_renter",
"rental_end_renter",
"post_rental_owner",
];
for (const checkType of checkTypes) {
const name = this.getScheduleName(rental.id, checkType);
const scheduleTime = scheduleTimes[checkType];
try {
const arn = await this.createSchedule(name, scheduleTime, {
rentalId: rental.id,
checkType,
scheduledFor: scheduleTime.toISOString(),
});
if (arn) {
schedulesCreated++;
}
} catch (error) {
logger.error("Failed to create schedule, continuing with others", {
rentalId: rental.id,
checkType,
error: error.message,
});
}
}
logger.info("Finished creating condition check schedules", {
rentalId: rental.id,
schedulesCreated,
});
return schedulesCreated;
}
/**
* Delete all condition check schedules for a rental.
* Call this when a rental is cancelled.
*/
async deleteConditionCheckSchedules(rental) {
if (!this.conditionCheckRemindersEnabled) {
logger.debug(
"EventBridge Scheduler disabled, skipping schedule deletion"
);
return;
}
logger.info("Deleting condition check schedules for rental", {
rentalId: rental.id,
});
const checkTypes = [
"pre_rental_owner",
"rental_start_renter",
"rental_end_renter",
"post_rental_owner",
];
for (const checkType of checkTypes) {
const name = this.getScheduleName(rental.id, checkType);
try {
await this.deleteSchedule(name);
} catch (error) {
logger.error("Failed to delete schedule, continuing with others", {
rentalId: rental.id,
checkType,
error: error.message,
});
}
}
logger.info("Finished deleting condition check schedules", {
rentalId: rental.id,
});
}
}
// Export singleton instance
module.exports = new EventBridgeSchedulerService();

View File

@@ -1,4 +1,5 @@
const { Client } = require('@googlemaps/google-maps-services-js');
const logger = require('../utils/logger');
class GoogleMapsService {
constructor() {
@@ -6,9 +7,9 @@ class GoogleMapsService {
this.apiKey = process.env.GOOGLE_MAPS_API_KEY;
if (!this.apiKey) {
console.error('Google Maps API key not configured in environment variables');
logger.error('Google Maps API key not configured in environment variables');
} else {
console.log('Google Maps service initialized');
logger.info('Google Maps service initialized');
}
}
@@ -61,7 +62,7 @@ class GoogleMapsService {
}))
};
} else {
console.error('Places Autocomplete API error:', response.data.status, response.data.error_message);
logger.error('Places Autocomplete API error', { status: response.data.status, errorMessage: response.data.error_message });
return {
predictions: [],
error: this.getErrorMessage(response.data.status),
@@ -69,7 +70,7 @@ class GoogleMapsService {
};
}
} catch (error) {
console.error('Places Autocomplete service error:', error.message);
logger.error('Places Autocomplete service error', { error });
throw new Error('Failed to fetch place predictions');
}
}
@@ -145,11 +146,11 @@ class GoogleMapsService {
}
};
} else {
console.error('Place Details API error:', response.data.status, response.data.error_message);
logger.error('Place Details API error', { status: response.data.status, errorMessage: response.data.error_message });
throw new Error(this.getErrorMessage(response.data.status));
}
} catch (error) {
console.error('Place Details service error:', error.message);
logger.error('Place Details service error', { error });
throw error;
}
}
@@ -200,14 +201,14 @@ class GoogleMapsService {
placeId: result.place_id
};
} else {
console.error('Geocoding API error:', response.data.status, response.data.error_message);
logger.error('Geocoding API error', { status: response.data.status, errorMessage: response.data.error_message });
return {
error: this.getErrorMessage(response.data.status),
status: response.data.status
};
}
} catch (error) {
console.error('Geocoding service error:', error.message);
logger.error('Geocoding service error', { error });
throw new Error('Failed to geocode address');
}
}

View File

@@ -1,5 +1,6 @@
const { sequelize } = require("../models");
const { QueryTypes } = require("sequelize");
const logger = require("../utils/logger");
class LocationService {
/**
@@ -25,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
@@ -71,7 +72,7 @@ class LocationService {
distance: parseFloat(user.distance).toFixed(2), // Round to 2 decimal places
}));
} catch (error) {
console.error("Error finding users in radius:", error);
logger.error("Error finding users in radius", { error });
throw new Error(`Failed to find users in radius: ${error.message}`);
}
}

View File

@@ -1,6 +1,8 @@
const { Rental } = require("../models");
const StripeService = require("./stripeService");
const EventBridgeSchedulerService = require("./eventBridgeSchedulerService");
const { isActive } = require("../utils/rentalStatus");
const logger = require("../utils/logger");
class RefundService {
/**
@@ -93,8 +95,12 @@ class RefundService {
};
}
// Check payment status - allow cancellation for both paid and free rentals
if (rental.paymentStatus !== "paid" && rental.paymentStatus !== "not_required") {
// Allow cancellation for pending rentals (before owner approval) or paid/free rentals
const isPendingRequest = rental.status === "pending";
const isPaymentSettled =
rental.paymentStatus === "paid" || rental.paymentStatus === "not_required";
if (!isPendingRequest && !isPaymentSettled) {
return {
canCancel: false,
reason: "Cannot cancel rental that hasn't been paid",
@@ -157,13 +163,14 @@ class RefundService {
stripeRefundId = refund.id;
refundProcessedAt = new Date();
} catch (error) {
console.error("Error processing Stripe refund:", error);
logger.error("Error processing Stripe refund", { error });
throw new Error(`Failed to process refund: ${error.message}`);
}
} else if (refundCalculation.refundAmount > 0) {
// Log warning if we should refund but don't have payment intent
console.warn(
`Refund amount calculated but no payment intent ID for rental ${rentalId}`
logger.warn(
"Refund amount calculated but no payment intent ID for rental",
{ rentalId }
);
}
@@ -181,6 +188,17 @@ class RefundService {
payoutStatus: "pending",
});
// Delete condition check schedules since rental is cancelled
try {
await EventBridgeSchedulerService.deleteConditionCheckSchedules(updatedRental);
} catch (schedulerError) {
logger.error("Failed to delete condition check schedules", {
error: schedulerError.message,
rentalId: updatedRental.id,
});
// Don't fail the cancellation - schedule cleanup is non-critical
}
return {
rental: updatedRental,
refund: {

View File

@@ -106,6 +106,15 @@ class S3Service {
}
}
/**
* Check if image processing (metadata stripping) is enabled
* When enabled, uploads go to staging/ prefix and Lambda processes them
* @returns {boolean}
*/
isImageProcessingEnabled() {
return process.env.IMAGE_PROCESSING_ENABLED === "true";
}
/**
* Get a presigned URL for uploading a file directly to S3
* @param {string} uploadType - Type of upload (profile, item, message, forum, condition-check)
@@ -113,7 +122,7 @@ class S3Service {
* @param {string} fileName - Original filename (used for extension)
* @param {number} fileSize - File size in bytes (required for size enforcement)
* @param {string} [baseKey] - Optional base key (UUID) for coordinated variant uploads
* @returns {Promise<{uploadUrl: string, key: string, publicUrl: string, expiresAt: Date}>}
* @returns {Promise<{uploadUrl: string, key: string, stagingKey: string|null, publicUrl: string, expiresAt: Date}>}
*/
async getPresignedUploadUrl(uploadType, contentType, fileName, fileSize, baseKey = null) {
if (!this.enabled) {
@@ -150,12 +159,19 @@ class S3Service {
// Use provided baseKey or generate new UUID
const uuid = baseKey || uuidv4();
const key = `${config.folder}/${uuid}${suffix}${ext}`;
// Final key is where the processed image will be (what frontend stores in DB)
const finalKey = `${config.folder}/${uuid}${suffix}${ext}`;
// When image processing is enabled, upload to staging/ prefix
// Lambda will process and move to final location
const useStaging = this.isImageProcessingEnabled();
const uploadKey = useStaging ? `staging/${finalKey}` : finalKey;
const cacheDirective = config.public ? "public" : "private";
const command = new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Key: uploadKey,
ContentType: contentType,
ContentLength: fileSize, // Enforce exact file size
CacheControl: `${cacheDirective}, max-age=${config.cacheMaxAge}`,
@@ -167,9 +183,10 @@ class S3Service {
return {
uploadUrl,
key,
key: finalKey, // Frontend stores this in database
stagingKey: useStaging ? uploadKey : null, // Actual upload location (if staging enabled)
publicUrl: config.public
? `https://${this.bucket}.s3.${this.region}.amazonaws.com/${key}`
? `https://${this.bucket}.s3.${this.region}.amazonaws.com/${finalKey}`
: null,
expiresAt: new Date(Date.now() + PRESIGN_EXPIRY * 1000),
};

View File

@@ -1,16 +1,20 @@
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
const logger = require("../utils/logger");
const { parseStripeError, PaymentError } = require("../utils/stripeErrors");
const { User } = require("../models");
const emailServices = require("./email");
class StripeService {
static async getCheckoutSession(sessionId) {
try {
return await stripe.checkout.sessions.retrieve(sessionId, {
expand: ['setup_intent', 'setup_intent.payment_method']
expand: ["setup_intent", "setup_intent.payment_method"],
});
} catch (error) {
logger.error("Error retrieving checkout session", { error: error.message, stack: error.stack });
logger.error("Error retrieving checkout session", {
error: error.message,
stack: error.stack,
});
throw error;
}
}
@@ -28,7 +32,10 @@ class StripeService {
return account;
} catch (error) {
logger.error("Error creating connected account", { error: error.message, stack: error.stack });
logger.error("Error creating connected account", {
error: error.message,
stack: error.stack,
});
throw error;
}
}
@@ -44,7 +51,10 @@ class StripeService {
return accountLink;
} catch (error) {
logger.error("Error creating account link", { error: error.message, stack: error.stack });
logger.error("Error creating account link", {
error: error.message,
stack: error.stack,
});
throw error;
}
}
@@ -60,7 +70,10 @@ class StripeService {
requirements: account.requirements,
};
} catch (error) {
logger.error("Error retrieving account status", { error: error.message, stack: error.stack });
logger.error("Error retrieving account status", {
error: error.message,
stack: error.stack,
});
throw error;
}
}
@@ -76,7 +89,10 @@ class StripeService {
return accountSession;
} catch (error) {
logger.error("Error creating account session", { error: error.message, stack: error.stack });
logger.error("Error creating account session", {
error: error.message,
stack: error.stack,
});
throw error;
}
}
@@ -88,20 +104,119 @@ class StripeService {
metadata = {},
}) {
try {
const transfer = await stripe.transfers.create({
// Generate idempotency key from rental ID to prevent duplicate transfers
const idempotencyKey = metadata?.rentalId
? `transfer_rental_${metadata.rentalId}`
: undefined;
const transfer = await stripe.transfers.create(
{
amount: Math.round(amount * 100), // Convert to cents
currency,
destination,
metadata,
});
},
idempotencyKey ? { idempotencyKey } : undefined,
);
return transfer;
} catch (error) {
logger.error("Error creating transfer", { error: error.message, stack: error.stack });
// Check if this is a disconnected account error (fallback for missed webhooks)
if (this.isAccountDisconnectedError(error)) {
logger.warn("Transfer failed - account appears disconnected", {
destination,
errorCode: error.code,
errorType: error.type,
});
// Clean up stale connection data asynchronously (don't block the error)
this.handleDisconnectedAccount(destination).catch((cleanupError) => {
logger.error("Failed to clean up disconnected account", {
destination,
error: cleanupError.message,
});
});
}
logger.error("Error creating transfer", {
error: error.message,
stack: error.stack,
});
throw error;
}
}
/**
* Check if error indicates the connected account is disconnected.
* Used as fallback detection when webhook was missed.
* @param {Error} error - Stripe error object
* @returns {boolean} - True if error indicates disconnected account
*/
static isAccountDisconnectedError(error) {
// Stripe returns these error codes when account is disconnected or invalid
const disconnectedCodes = ["account_invalid", "platform_api_key_expired"];
// Error messages that indicate disconnection
const disconnectedMessages = [
"cannot transfer",
"not connected",
"no longer connected",
"account has been deauthorized",
];
if (disconnectedCodes.includes(error.code)) {
return true;
}
const message = (error.message || "").toLowerCase();
return disconnectedMessages.some((msg) => message.includes(msg));
}
/**
* Handle disconnected account - cleanup and notify.
* Called as fallback when webhook was missed.
* @param {string} accountId - The disconnected Stripe account ID
*/
static async handleDisconnectedAccount(accountId) {
try {
const user = await User.findOne({
where: { stripeConnectedAccountId: accountId },
});
if (!user) {
return;
}
logger.warn("Cleaning up disconnected account (webhook likely missed)", {
userId: user.id,
accountId,
});
// Clear connection
await user.update({
stripeConnectedAccountId: null,
stripePayoutsEnabled: false,
});
// Send notification
await emailServices.payment.sendAccountDisconnectedEmail(user.email, {
ownerName: user.firstName || user.lastName,
hasPendingPayouts: true, // We're in a transfer, so there's at least one
pendingPayoutCount: 1,
});
logger.info("Sent account disconnected notification (fallback)", {
userId: user.id,
});
} catch (cleanupError) {
logger.error("Failed to clean up disconnected account", {
accountId,
error: cleanupError.message,
});
// Don't throw - let original error propagate
}
}
static async createRefund({
paymentIntentId,
amount,
@@ -109,16 +224,27 @@ class StripeService {
reason = "requested_by_customer",
}) {
try {
const refund = await stripe.refunds.create({
// Generate idempotency key - include amount to allow multiple partial refunds
const idempotencyKey = metadata?.rentalId
? `refund_rental_${metadata.rentalId}_${Math.round(amount * 100)}`
: undefined;
const refund = await stripe.refunds.create(
{
payment_intent: paymentIntentId,
amount: Math.round(amount * 100), // Convert to cents
metadata,
reason,
});
},
idempotencyKey ? { idempotencyKey } : undefined,
);
return refund;
} catch (error) {
logger.error("Error creating refund", { error: error.message, stack: error.stack });
logger.error("Error creating refund", {
error: error.message,
stack: error.stack,
});
throw error;
}
}
@@ -127,64 +253,100 @@ class StripeService {
try {
return await stripe.refunds.retrieve(refundId);
} catch (error) {
logger.error("Error retrieving refund", { error: error.message, stack: error.stack });
logger.error("Error retrieving refund", {
error: error.message,
stack: error.stack,
});
throw error;
}
}
static async chargePaymentMethod(paymentMethodId, amount, customerId, metadata = {}) {
static async chargePaymentMethod(
paymentMethodId,
amount,
customerId,
metadata = {},
) {
try {
// Generate idempotency key to prevent duplicate charges for same rental
const idempotencyKey = metadata?.rentalId
? `charge_rental_${metadata.rentalId}`
: undefined;
// Create a payment intent with the stored payment method
const paymentIntent = await stripe.paymentIntents.create({
const paymentIntent = await stripe.paymentIntents.create(
{
amount: Math.round(amount * 100), // Convert to cents
currency: "usd",
payment_method: paymentMethodId,
customer: customerId, // Include customer ID
confirm: true, // Automatically confirm the payment
off_session: true, // Indicate this is an off-session payment
return_url: `${process.env.FRONTEND_URL || 'http://localhost:3000'}/payment-complete`,
return_url: `${process.env.FRONTEND_URL}/complete-payment`,
metadata,
expand: ['charges.data.payment_method_details'], // Expand to get payment method details
});
expand: ["latest_charge.payment_method_details"], // Expand to get payment method details
},
idempotencyKey ? { idempotencyKey } : undefined,
);
// Extract payment method details from charges
const charge = paymentIntent.charges?.data?.[0];
// Check if additional authentication is required
if (paymentIntent.status === "requires_action") {
return {
status: "requires_action",
requiresAction: true,
paymentIntentId: paymentIntent.id,
clientSecret: paymentIntent.client_secret,
};
}
// Extract payment method details from latest_charge
const charge = paymentIntent.latest_charge;
const paymentMethodDetails = charge?.payment_method_details;
// Build payment method info object
let paymentMethod = null;
if (paymentMethodDetails) {
const type = paymentMethodDetails.type;
if (type === 'card') {
if (type === "card") {
paymentMethod = {
type: 'card',
brand: paymentMethodDetails.card?.brand || 'card',
last4: paymentMethodDetails.card?.last4 || '****',
type: "card",
brand: paymentMethodDetails.card?.brand || "card",
last4: paymentMethodDetails.card?.last4 || "****",
};
} else if (type === 'us_bank_account') {
} else if (type === "us_bank_account") {
paymentMethod = {
type: 'bank',
brand: 'bank_account',
last4: paymentMethodDetails.us_bank_account?.last4 || '****',
type: "bank",
brand: "bank_account",
last4: paymentMethodDetails.us_bank_account?.last4 || "****",
};
} else {
paymentMethod = {
type: type || 'unknown',
brand: type || 'payment',
type: type || "unknown",
brand: type || "payment",
last4: null,
};
}
}
return {
status: "succeeded",
paymentIntentId: paymentIntent.id,
status: paymentIntent.status,
clientSecret: paymentIntent.client_secret,
paymentMethod: paymentMethod,
chargedAt: new Date(paymentIntent.created * 1000), // Convert Unix timestamp to Date
amountCharged: amount, // Original amount in dollars
};
} catch (error) {
// Handle authentication_required error (thrown for off-session 3DS)
if (error.code === "authentication_required") {
return {
status: "requires_action",
requiresAction: true,
paymentIntentId: error.payment_intent?.id,
clientSecret: error.payment_intent?.client_secret,
};
}
// Parse Stripe error into structured format
const parsedError = parseStripeError(error);
@@ -213,17 +375,22 @@ class StripeService {
return customer;
} catch (error) {
logger.error("Error creating customer", { error: error.message, stack: error.stack });
logger.error("Error creating customer", {
error: error.message,
stack: error.stack,
});
throw error;
}
}
static async getPaymentMethod(paymentMethodId) {
try {
return await stripe.paymentMethods.retrieve(paymentMethodId);
} catch (error) {
logger.error("Error retrieving payment method", { error: error.message, paymentMethodId });
logger.error("Error retrieving payment method", {
error: error.message,
paymentMethodId,
});
throw error;
}
}
@@ -232,19 +399,28 @@ class StripeService {
try {
const session = await stripe.checkout.sessions.create({
customer: customerId,
payment_method_types: ['card', 'us_bank_account', 'link'],
mode: 'setup',
ui_mode: 'embedded',
redirect_on_completion: 'never',
payment_method_types: ["card", "link"],
mode: "setup",
ui_mode: "embedded",
redirect_on_completion: "never",
// Configure for off-session usage - triggers 3DS during setup
payment_method_options: {
card: {
request_three_d_secure: "any",
},
},
metadata: {
type: 'payment_method_setup',
...metadata
}
type: "payment_method_setup",
...metadata,
},
});
return session;
} catch (error) {
logger.error("Error creating setup checkout session", { error: error.message, stack: error.stack });
logger.error("Error creating setup checkout session", {
error: error.message,
stack: error.stack,
});
throw error;
}
}

View File

@@ -3,6 +3,8 @@ const { User, Rental, Item } = require("../models");
const PayoutService = require("./payoutService");
const logger = require("../utils/logger");
const { Op } = require("sequelize");
const { getPayoutFailureMessage } = require("../utils/payoutErrors");
const emailServices = require("./email");
class StripeWebhookService {
/**
@@ -14,19 +16,23 @@ class StripeWebhookService {
/**
* Handle account.updated webhook event.
* Triggers payouts for owner when payouts_enabled becomes true.
* Tracks requirements, triggers payouts when enabled, and notifies when disabled.
* @param {Object} account - The Stripe account object from the webhook
* @returns {Object} - { processed, payoutsTriggered, payoutResults }
* @returns {Object} - { processed, payoutsTriggered, payoutResults, notificationSent }
*/
static async handleAccountUpdated(account) {
const accountId = account.id;
const payoutsEnabled = account.payouts_enabled;
const requirements = account.requirements || {};
logger.info("Processing account.updated webhook", {
accountId,
payoutsEnabled,
chargesEnabled: account.charges_enabled,
detailsSubmitted: account.details_submitted,
currentlyDue: requirements.currently_due?.length || 0,
pastDue: requirements.past_due?.length || 0,
disabledReason: requirements.disabled_reason,
});
// Find user with this Stripe account
@@ -39,18 +45,33 @@ class StripeWebhookService {
return { processed: false, reason: "user_not_found" };
}
// Store previous state before update
const previousPayoutsEnabled = user.stripePayoutsEnabled;
// Update user's payouts_enabled status
await user.update({ stripePayoutsEnabled: payoutsEnabled });
// Update user with all account status fields
await user.update({
stripePayoutsEnabled: payoutsEnabled,
stripeRequirementsCurrentlyDue: requirements.currently_due || [],
stripeRequirementsPastDue: requirements.past_due || [],
stripeDisabledReason: requirements.disabled_reason || null,
stripeRequirementsLastUpdated: new Date(),
});
logger.info("Updated user stripePayoutsEnabled", {
logger.info("Updated user Stripe account status", {
userId: user.id,
accountId,
previousPayoutsEnabled,
newPayoutsEnabled: payoutsEnabled,
currentlyDue: requirements.currently_due?.length || 0,
pastDue: requirements.past_due?.length || 0,
});
const result = {
processed: true,
payoutsTriggered: false,
notificationSent: false,
};
// If payouts just became enabled (false -> true), process pending payouts
if (payoutsEnabled && !previousPayoutsEnabled) {
logger.info("Payouts enabled for user, processing pending payouts", {
@@ -58,15 +79,69 @@ class StripeWebhookService {
accountId,
});
const result = await this.processPayoutsForOwner(user.id);
return {
processed: true,
payoutsTriggered: true,
payoutResults: result,
};
result.payoutsTriggered = true;
result.payoutResults = await this.processPayoutsForOwner(user.id);
}
return { processed: true, payoutsTriggered: false };
// If payouts just became disabled (true -> false), notify the owner
if (!payoutsEnabled && previousPayoutsEnabled) {
logger.warn("Payouts disabled for user", {
userId: user.id,
accountId,
disabledReason: requirements.disabled_reason,
currentlyDue: requirements.currently_due,
});
try {
const disabledReason = this.formatDisabledReason(requirements.disabled_reason);
await emailServices.payment.sendPayoutsDisabledEmail(user.email, {
ownerName: user.firstName || user.lastName,
disabledReason,
});
result.notificationSent = true;
logger.info("Sent payouts disabled notification to owner", {
userId: user.id,
accountId,
disabledReason: requirements.disabled_reason,
});
} catch (emailError) {
logger.error("Failed to send payouts disabled notification", {
userId: user.id,
accountId,
error: emailError.message,
});
}
}
return result;
}
/**
* Format Stripe disabled_reason code to user-friendly message.
* @param {string} reason - Stripe disabled_reason code
* @returns {string} User-friendly message
*/
static formatDisabledReason(reason) {
const reasonMap = {
"requirements.past_due":
"Some required information is past due and must be provided to continue receiving payouts.",
"requirements.pending_verification":
"Your submitted information is being verified. This usually takes a few minutes.",
listed: "Your account has been listed for review due to potential policy concerns.",
platform_paused:
"Payouts have been temporarily paused by the platform.",
rejected_fraud: "Your account was flagged for potential fraudulent activity.",
rejected_listed: "Your account has been rejected due to policy concerns.",
rejected_terms_of_service:
"Your account was rejected due to a terms of service violation.",
rejected_other: "Your account was rejected. Please contact support for more information.",
under_review: "Your account is under review. We'll notify you when the review is complete.",
};
return reasonMap[reason] || "Additional verification is required for your account.";
}
/**
@@ -219,10 +294,10 @@ class StripeWebhookService {
/**
* Handle payout.failed webhook event.
* Updates rentals when bank deposit fails.
* Updates rentals when bank deposit fails and notifies the owner.
* @param {Object} payout - The Stripe payout object
* @param {string} connectedAccountId - The connected account ID (from event.account)
* @returns {Object} - { processed, rentalsUpdated }
* @returns {Object} - { processed, rentalsUpdated, notificationSent }
*/
static async handlePayoutFailed(payout, connectedAccountId) {
logger.info("Processing payout.failed webhook", {
@@ -259,7 +334,7 @@ class StripeWebhookService {
payoutId: payout.id,
connectedAccountId,
});
return { processed: true, rentalsUpdated: 0 };
return { processed: true, rentalsUpdated: 0, notificationSent: false };
}
// Update all rentals with matching stripeTransferId
@@ -282,7 +357,49 @@ class StripeWebhookService {
failureCode: payout.failure_code,
});
return { processed: true, rentalsUpdated: updatedCount };
// Find owner and send notification
const user = await User.findOne({
where: { stripeConnectedAccountId: connectedAccountId },
});
let notificationSent = false;
if (user) {
// Get user-friendly message
const failureInfo = getPayoutFailureMessage(payout.failure_code);
try {
await emailServices.payment.sendPayoutFailedNotification(user.email, {
ownerName: user.firstName || user.lastName,
payoutAmount: payout.amount / 100,
failureMessage: failureInfo.message,
actionRequired: failureInfo.action,
failureCode: payout.failure_code || "unknown",
requiresBankUpdate: failureInfo.requiresBankUpdate,
});
notificationSent = true;
logger.info("Sent payout failed notification to owner", {
userId: user.id,
payoutId: payout.id,
failureCode: payout.failure_code,
});
} catch (emailError) {
logger.error("Failed to send payout failed notification", {
userId: user.id,
payoutId: payout.id,
error: emailError.message,
});
}
} else {
logger.warn("No user found for connected account", {
connectedAccountId,
payoutId: payout.id,
});
}
return { processed: true, rentalsUpdated: updatedCount, notificationSent };
} catch (error) {
logger.error("Error processing payout.failed webhook", {
payoutId: payout.id,
@@ -294,21 +411,187 @@ class StripeWebhookService {
}
}
/**
* Handle payout.canceled webhook event.
* Stripe can cancel payouts if:
* - They are manually canceled via Dashboard/API before processing
* - The connected account is deactivated
* - Risk review cancels the payout
* @param {Object} payout - The Stripe payout object
* @param {string} connectedAccountId - The connected account ID
* @returns {Object} - { processed, rentalsUpdated }
*/
static async handlePayoutCanceled(payout, connectedAccountId) {
logger.info("Processing payout.canceled webhook", {
payoutId: payout.id,
connectedAccountId,
});
if (!connectedAccountId) {
logger.warn("payout.canceled webhook missing connected account ID", {
payoutId: payout.id,
});
return { processed: false, reason: "missing_account_id" };
}
try {
// Retrieve balance transactions to find associated transfers
const balanceTransactions = await stripe.balanceTransactions.list(
{
payout: payout.id,
type: "transfer",
limit: 100,
},
{ stripeAccount: connectedAccountId }
);
const transferIds = balanceTransactions.data
.map((bt) => bt.source)
.filter(Boolean);
if (transferIds.length === 0) {
logger.info("No transfers found for canceled payout", {
payoutId: payout.id,
});
return { processed: true, rentalsUpdated: 0 };
}
// Update all rentals associated with this payout
const [updatedCount] = await Rental.update(
{
bankDepositStatus: "canceled",
stripePayoutId: payout.id,
},
{
where: {
stripeTransferId: { [Op.in]: transferIds },
},
}
);
logger.info("Updated rentals for canceled payout", {
payoutId: payout.id,
rentalsUpdated: updatedCount,
});
return { processed: true, rentalsUpdated: updatedCount };
} catch (error) {
logger.error("Error processing payout.canceled webhook", {
payoutId: payout.id,
connectedAccountId,
error: error.message,
stack: error.stack,
});
throw error;
}
}
/**
* Handle account.application.deauthorized webhook event.
* Triggered when an owner disconnects their Stripe account from our platform.
* @param {string} accountId - The connected account ID that was deauthorized
* @returns {Object} - { processed, userId, pendingPayoutsCount, notificationSent }
*/
static async handleAccountDeauthorized(accountId) {
logger.warn("Processing account.application.deauthorized webhook", {
accountId,
});
if (!accountId) {
logger.warn("account.application.deauthorized webhook missing account ID");
return { processed: false, reason: "missing_account_id" };
}
try {
// Find the user by their connected account ID
const user = await User.findOne({
where: { stripeConnectedAccountId: accountId },
});
if (!user) {
logger.warn("No user found for deauthorized Stripe account", { accountId });
return { processed: false, reason: "user_not_found" };
}
// Clear Stripe connection fields
await user.update({
stripeConnectedAccountId: null,
stripePayoutsEnabled: false,
stripeRequirementsCurrentlyDue: [],
stripeRequirementsPastDue: [],
stripeDisabledReason: null,
stripeRequirementsLastUpdated: null,
});
logger.info("Cleared Stripe connection for deauthorized account", {
userId: user.id,
accountId,
});
// Check for pending payouts that will now fail
const pendingRentals = await Rental.findAll({
where: {
ownerId: user.id,
payoutStatus: "pending",
},
});
if (pendingRentals.length > 0) {
logger.warn("Owner disconnected account with pending payouts", {
userId: user.id,
pendingCount: pendingRentals.length,
pendingRentalIds: pendingRentals.map((r) => r.id),
});
}
// Send notification email
let notificationSent = false;
try {
await emailServices.payment.sendAccountDisconnectedEmail(user.email, {
ownerName: user.firstName || user.lastName,
hasPendingPayouts: pendingRentals.length > 0,
pendingPayoutCount: pendingRentals.length,
});
notificationSent = true;
logger.info("Sent account disconnected notification", { userId: user.id });
} catch (emailError) {
logger.error("Failed to send account disconnected notification", {
userId: user.id,
error: emailError.message,
});
}
return {
processed: true,
userId: user.id,
pendingPayoutsCount: pendingRentals.length,
notificationSent,
};
} catch (error) {
logger.error("Error processing account.application.deauthorized webhook", {
accountId,
error: error.message,
stack: error.stack,
});
throw error;
}
}
/**
* Reconcile payout statuses for an owner by checking Stripe for actual status.
* This handles cases where the payout.paid webhook was missed or failed.
* This handles cases where payout.paid, payout.failed, or payout.canceled webhooks were missed.
*
* Simplified approach: Since Stripe automatic payouts sweep the entire available
* balance, if there's been a paid payout after our transfer was created, our
* funds were included.
* Checks paid, failed, and canceled payouts to ensure accurate status tracking.
*
* @param {string} ownerId - The owner's user ID
* @returns {Object} - { reconciled, updated, errors }
* @returns {Object} - { reconciled, updated, failed, notificationsSent, errors }
*/
static async reconcilePayoutStatuses(ownerId) {
const results = {
reconciled: 0,
updated: 0,
failed: 0,
notificationsSent: 0,
errors: [],
};
@@ -325,7 +608,7 @@ class StripeWebhookService {
{
model: User,
as: "owner",
attributes: ["stripeConnectedAccountId"],
attributes: ["id", "email", "firstName", "lastName", "stripeConnectedAccountId"],
},
],
});
@@ -346,42 +629,158 @@ class StripeWebhookService {
return results;
}
// Fetch recent paid payouts once for all rentals
const paidPayouts = await stripe.payouts.list(
// Fetch recent paid, failed, and canceled payouts
const [paidPayouts, failedPayouts, canceledPayouts] = await Promise.all([
stripe.payouts.list(
{ status: "paid", limit: 20 },
{ stripeAccount: connectedAccountId }
);
),
stripe.payouts.list(
{ status: "failed", limit: 20 },
{ stripeAccount: connectedAccountId }
),
stripe.payouts.list(
{ status: "canceled", limit: 20 },
{ stripeAccount: connectedAccountId }
),
]);
if (paidPayouts.data.length === 0) {
logger.info("No paid payouts found for connected account", { connectedAccountId });
return results;
// Build a map of transfer IDs to failed payouts for quick lookup
const failedPayoutTransferMap = new Map();
for (const payout of failedPayouts.data) {
try {
const balanceTransactions = await stripe.balanceTransactions.list(
{ payout: payout.id, type: "transfer", limit: 100 },
{ stripeAccount: connectedAccountId }
);
for (const bt of balanceTransactions.data) {
if (bt.source) {
failedPayoutTransferMap.set(bt.source, payout);
}
}
} catch (btError) {
logger.warn("Error fetching balance transactions for failed payout", {
payoutId: payout.id,
error: btError.message,
});
}
}
// Build a map of transfer IDs to canceled payouts for quick lookup
const canceledPayoutTransferMap = new Map();
for (const payout of canceledPayouts.data) {
try {
const balanceTransactions = await stripe.balanceTransactions.list(
{ payout: payout.id, type: "transfer", limit: 100 },
{ stripeAccount: connectedAccountId }
);
for (const bt of balanceTransactions.data) {
if (bt.source) {
canceledPayoutTransferMap.set(bt.source, payout);
}
}
} catch (btError) {
logger.warn("Error fetching balance transactions for canceled payout", {
payoutId: payout.id,
error: btError.message,
});
}
}
const owner = rentalsToReconcile[0].owner;
for (const rental of rentalsToReconcile) {
results.reconciled++;
try {
// Get the transfer to find when it was created
const transfer = await stripe.transfers.retrieve(rental.stripeTransferId);
// First check if this transfer is in a failed payout
const failedPayout = failedPayoutTransferMap.get(rental.stripeTransferId);
// Find a payout that arrived after the transfer was created
const matchingPayout = paidPayouts.data.find(
if (failedPayout) {
// Update rental with failed status
await rental.update({
bankDepositStatus: "failed",
stripePayoutId: failedPayout.id,
bankDepositFailureCode: failedPayout.failure_code || "unknown",
});
results.failed++;
logger.warn("Reconciled rental with failed payout", {
rentalId: rental.id,
payoutId: failedPayout.id,
failureCode: failedPayout.failure_code,
});
// Send failure notification
if (owner?.email) {
try {
const failureInfo = getPayoutFailureMessage(failedPayout.failure_code);
await emailServices.payment.sendPayoutFailedNotification(owner.email, {
ownerName: owner.firstName || owner.lastName,
payoutAmount: failedPayout.amount / 100,
failureMessage: failureInfo.message,
actionRequired: failureInfo.action,
failureCode: failedPayout.failure_code || "unknown",
requiresBankUpdate: failureInfo.requiresBankUpdate,
});
results.notificationsSent++;
logger.info("Sent reconciled payout failure notification", {
userId: owner.id,
rentalId: rental.id,
payoutId: failedPayout.id,
});
} catch (emailError) {
logger.error("Failed to send reconciled payout failure notification", {
userId: owner.id,
rentalId: rental.id,
error: emailError.message,
});
}
}
continue; // Move to next rental
}
// Check if this transfer is in a canceled payout
const canceledPayout = canceledPayoutTransferMap.get(rental.stripeTransferId);
if (canceledPayout) {
await rental.update({
bankDepositStatus: "canceled",
stripePayoutId: canceledPayout.id,
});
results.canceled = (results.canceled || 0) + 1;
logger.info("Reconciled rental with canceled payout", {
rentalId: rental.id,
payoutId: canceledPayout.id,
});
continue; // Move to next rental
}
// Check for paid payout
const transfer = await stripe.transfers.retrieve(rental.stripeTransferId);
const matchingPaidPayout = paidPayouts.data.find(
(payout) => payout.arrival_date >= transfer.created
);
if (matchingPayout) {
if (matchingPaidPayout) {
await rental.update({
bankDepositStatus: "paid",
bankDepositAt: new Date(matchingPayout.arrival_date * 1000),
stripePayoutId: matchingPayout.id,
bankDepositAt: new Date(matchingPaidPayout.arrival_date * 1000),
stripePayoutId: matchingPaidPayout.id,
});
results.updated++;
logger.info("Reconciled rental payout status", {
logger.info("Reconciled rental payout status to paid", {
rentalId: rental.id,
payoutId: matchingPayout.id,
arrivalDate: matchingPayout.arrival_date,
payoutId: matchingPaidPayout.id,
arrivalDate: matchingPaidPayout.arrival_date,
});
}
} catch (rentalError) {
@@ -401,6 +800,9 @@ class StripeWebhookService {
ownerId,
reconciled: results.reconciled,
updated: results.updated,
failed: results.failed,
canceled: results.canceled || 0,
notificationsSent: results.notificationsSent,
errors: results.errors.length,
});

View File

@@ -8,7 +8,7 @@ const cookie = require("cookie");
* Verifies JWT token and attaches user to socket
* Tokens can be provided via:
* 1. Cookie (accessToken) - preferred for browser clients
* 2. Query parameter (token) - fallback for mobile/other clients
* 2. Auth object (auth.token) - for mobile/native clients
*/
const authenticateSocket = async (socket, next) => {
try {
@@ -20,16 +20,11 @@ const authenticateSocket = async (socket, next) => {
token = cookies.accessToken;
}
// Fallback to query parameter (mobile/other clients)
// Auth object for mobile/native clients
if (!token && socket.handshake.auth?.token) {
token = socket.handshake.auth.token;
}
// Fallback to legacy query parameter
if (!token && socket.handshake.query?.token) {
token = socket.handshake.query.token;
}
if (!token) {
logger.warn("Socket connection rejected - no token provided", {
socketId: socket.id,
@@ -69,7 +64,9 @@ const authenticateSocket = async (socket, next) => {
userVersion: user.jwtVersion,
});
return next(
new Error("Session expired due to password change. Please log in again.")
new Error(
"Session expired due to password change. Please log in again."
)
);
}

View File

@@ -0,0 +1,321 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Payout Account Disconnected - 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;
}
/* Base styles */
body {
margin: 0;
padding: 0;
width: 100% !important;
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
/* Container */
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Header */
.header {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #f8d7da;
font-size: 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 h2 {
font-size: 20px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Alert box */
.alert-box {
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.alert-box p {
margin: 0 0 10px 0;
color: #721c24;
}
.alert-box p:last-child {
margin-bottom: 0;
}
/* Warning display */
.warning-display {
background: linear-gradient(135deg, #ffc107 0%, #e0a800 100%);
padding: 30px;
border-radius: 8px;
text-align: center;
margin: 30px 0;
box-shadow: 0 4px 12px rgba(255, 193, 7, 0.3);
}
.warning-label {
color: #212529;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 10px;
}
.warning-icon {
font-size: 48px;
margin: 0;
line-height: 1;
}
.warning-subtitle {
color: #212529;
font-size: 14px;
margin-top: 10px;
opacity: 0.8;
}
/* 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 #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0 0 10px 0;
color: #004085;
}
.info-box p:last-child {
margin-bottom: 0;
}
/* Footer */
.footer {
background-color: #f8f9fa;
padding: 30px;
text-align: center;
border-top: 1px solid #e9ecef;
}
.footer p {
margin: 0 0 10px 0;
font-size: 14px;
color: #6c757d;
}
.footer a {
color: #667eea;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
/* Responsive */
@media only screen and (max-width: 600px) {
.email-container {
margin: 0;
border-radius: 0;
}
.header,
.content,
.footer {
padding: 20px;
}
.logo {
font-size: 28px;
}
.content h1 {
font-size: 22px;
}
.warning-icon {
font-size: 36px;
}
.button {
display: block;
width: 100%;
box-sizing: border-box;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Payout Account Disconnected</div>
</div>
<div class="content">
<p>Hi {{ownerName}},</p>
<p>
Your Stripe payout account has been disconnected from Village Share.
This means we are no longer able to send your earnings to your bank
account.
</p>
<div class="warning-display">
<div class="warning-label">Account Status</div>
<div class="warning-icon">&#9888;</div>
<div class="warning-subtitle">Payout account disconnected</div>
</div>
{{#if hasPendingPayouts}}
<div class="alert-box">
<p><strong>Important:</strong></p>
<p>
You have {{pendingPayoutCount}} pending
payout{{#if (gt pendingPayoutCount 1)}}s{{/if}} that cannot be
processed until you reconnect your payout account.
</p>
</div>
{{/if}}
<h2>What This Means</h2>
<p>
Without a connected payout account, you will not be able to receive
earnings from rentals. Your listings will remain active, but payments
cannot be deposited to your bank account.
</p>
<h2>What To Do</h2>
<p>
If you disconnected your account by mistake, or would like to continue
receiving payouts, please reconnect your Stripe account.
</p>
<div style="text-align: center">
<a href="{{reconnectUrl}}" class="button">Reconnect Payout Account</a>
</div>
<div class="info-box">
<p><strong>Need help?</strong></p>
<p>
If you're having trouble reconnecting your account, or didn't
disconnect it yourself, please contact our support team right away.
</p>
</div>
<p>
We're here to help you continue earning on Village Share. Don't
hesitate to reach out if you have any questions.
</p>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>
This is an important notification about your payout account. You
received this message because your Stripe account was disconnected
from our platform.
</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -1,17 +1,24 @@
<!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">
<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 {
body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
table,
td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
@@ -27,7 +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;
}
@@ -104,7 +113,7 @@
}
.code {
font-family: 'Courier New', Courier, monospace;
font-family: "Courier New", Courier, monospace;
font-size: 32px;
font-weight: 700;
color: #ffffff;
@@ -192,7 +201,9 @@
border-radius: 0;
}
.header, .content, .footer {
.header,
.content,
.footer {
padding: 20px;
}
@@ -227,24 +238,40 @@
<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>
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>
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>
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>
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 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>
@@ -259,23 +286,35 @@
</div>
<p><strong>Important notes:</strong></p>
<ul style="color: #6c757d; font-size: 14px;">
<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>
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>
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:support@villageshare.app">support@villageshare.app</a></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>

View File

@@ -0,0 +1,296 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Action Required - 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;
}
/* Base styles */
body {
margin: 0;
padding: 0;
width: 100% !important;
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
/* Container */
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Header */
.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: #e9ecef;
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 h2 {
font-size: 20px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Warning box */
.warning-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.warning-box p {
margin: 0 0 10px 0;
color: #856404;
}
.warning-box p:last-child {
margin-bottom: 0;
}
/* 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 0 10px 0;
color: #004085;
}
.info-box p:last-child {
margin-bottom: 0;
}
/* Info table */
.info-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
background-color: #f8f9fa;
border-radius: 6px;
overflow: hidden;
}
.info-table th,
.info-table td {
padding: 15px;
text-align: left;
border-bottom: 1px solid #dee2e6;
}
.info-table th {
background-color: #e9ecef;
font-weight: 600;
color: #495057;
}
.info-table td {
color: #6c757d;
}
.info-table tr:last-child td {
border-bottom: none;
}
/* 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;
}
.info-table th,
.info-table td {
padding: 10px;
font-size: 14px;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Action Required: Complete Your Payment</div>
</div>
<div class="content">
<p>Hi {{renterName}},</p>
<p>
Great news! <strong>{{ownerName}}</strong> has approved your rental
request for <strong>{{itemName}}</strong>.
</p>
<div class="warning-box">
<p><strong>Your bank requires additional verification</strong></p>
<p>
To complete the payment and confirm your rental, your bank needs you
to verify your identity. This is a security measure to protect your
account.
</p>
</div>
<h2>Rental Details</h2>
<table class="info-table">
<tr>
<th>Item</th>
<td>{{itemName}}</td>
</tr>
<tr>
<th>Amount</th>
<td>${{amount}}</td>
</tr>
</table>
<div class="info-box">
<p><strong>How to Complete Your Payment</strong></p>
<ol style="margin: 10px 0; padding-left: 20px; color: #004085">
<li>
Go directly to <strong>village-share.com</strong> in your browser
</li>
<li>Log in to your account</li>
<li>Navigate to <strong>My Rentals</strong> from the navigation menu</li>
<li>
Find the rental for <strong>{{itemName}}</strong>
</li>
<li>Click <strong>"Complete Payment"</strong> and follow your bank's verification steps</li>
</ol>
</div>
<p>
The verification process usually takes less than a minute. Once
complete, your rental will be confirmed and you'll receive a
confirmation email.
</p>
<p>
If you did not request this rental, please ignore this email.
</p>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>
This is a notification about your rental request. You received this
message because the owner approved your rental and your bank requires
additional verification.
</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</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

@@ -0,0 +1,354 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Payment Dispute Alert - 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;
}
/* Base styles */
body {
margin: 0;
padding: 0;
width: 100% !important;
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
/* Container */
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Header */
.header {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #f8d7da;
font-size: 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 h2 {
font-size: 20px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Alert box */
.alert-box {
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.alert-box p {
margin: 0 0 10px 0;
color: #721c24;
}
.alert-box p:last-child {
margin-bottom: 0;
}
/* Warning display */
.warning-display {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
padding: 30px;
border-radius: 8px;
text-align: center;
margin: 30px 0;
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.3);
}
.warning-label {
color: #ffffff;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 10px;
}
.warning-amount {
color: #ffffff;
font-size: 48px;
font-weight: 700;
margin: 0;
line-height: 1;
}
.warning-subtitle {
color: #f8d7da;
font-size: 14px;
margin-top: 10px;
}
/* Deadline display */
.deadline-display {
background: linear-gradient(135deg, #ffc107 0%, #e0a800 100%);
padding: 20px;
border-radius: 8px;
text-align: center;
margin: 20px 0;
box-shadow: 0 4px 12px rgba(255, 193, 7, 0.3);
}
.deadline-label {
color: #212529;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 5px;
}
.deadline-date {
color: #212529;
font-size: 24px;
font-weight: 700;
margin: 0;
}
/* Info box */
.info-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0 0 10px 0;
color: #856404;
}
.info-box p:last-child {
margin-bottom: 0;
}
/* Info table */
.info-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
background-color: #f8f9fa;
border-radius: 6px;
overflow: hidden;
}
.info-table th,
.info-table td {
padding: 15px;
text-align: left;
border-bottom: 1px solid #dee2e6;
}
.info-table th {
background-color: #e9ecef;
font-weight: 600;
color: #495057;
width: 40%;
}
.info-table td {
color: #6c757d;
}
.info-table tr:last-child td {
border-bottom: none;
}
/* 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;
}
.warning-amount {
font-size: 36px;
}
.info-table th,
.info-table td {
padding: 10px;
font-size: 14px;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Payment Dispute Alert</div>
</div>
<div class="content">
<h1>Payment Dispute Opened</h1>
<p>
A payment dispute has been filed that requires immediate attention.
Evidence must be submitted before the deadline to contest this
dispute.
</p>
<div class="warning-display">
<div class="warning-label">Disputed Amount</div>
<div class="warning-amount">${{amount}}</div>
<div class="warning-subtitle">Funds withdrawn from platform balance</div>
</div>
<div class="deadline-display">
<div class="deadline-label">Evidence Due By</div>
<div class="deadline-date">{{evidenceDueBy}}</div>
</div>
<h2>Dispute Details</h2>
<table class="info-table">
<tr>
<th>Rental ID</th>
<td>{{rentalId}}</td>
</tr>
<tr>
<th>Item</th>
<td>{{itemName}}</td>
</tr>
<tr>
<th>Dispute Reason</th>
<td>{{reason}}</td>
</tr>
</table>
<h2>Parties Involved</h2>
<div class="info-box">
<p><strong>Renter:</strong> {{renterName}} ({{renterEmail}})</p>
<p><strong>Owner:</strong> {{ownerName}} ({{ownerEmail}})</p>
</div>
<div class="alert-box">
<p><strong>Action Required:</strong></p>
<p>
Log into the Stripe Dashboard to review and respond to this dispute.
Submit evidence such as rental agreements, communication logs,
delivery confirmation, and condition check photos before the
deadline.
</p>
</div>
</div>
<div class="footer">
<p><strong>Village Share - Admin Alert</strong></p>
<p>
This is an automated notification about a payment dispute requiring
immediate attention.
</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,359 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Dispute Lost - Manual Review Required - 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;
}
/* Base styles */
body {
margin: 0;
padding: 0;
width: 100% !important;
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
/* Container */
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Header */
.header {
background: linear-gradient(135deg, #721c24 0%, #491217 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #f8d7da;
font-size: 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 h2 {
font-size: 20px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Critical alert box */
.critical-alert {
background-color: #f8d7da;
border: 2px solid #dc3545;
padding: 20px;
margin: 20px 0;
border-radius: 6px;
text-align: center;
}
.critical-alert p {
margin: 0;
color: #721c24;
font-weight: 600;
font-size: 16px;
}
/* Loss display */
.loss-display {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
padding: 30px;
border-radius: 8px;
text-align: center;
margin: 30px 0;
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.3);
}
.loss-label {
color: #ffffff;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 10px;
}
.loss-amount {
color: #ffffff;
font-size: 48px;
font-weight: 700;
margin: 0;
line-height: 1;
}
.loss-subtitle {
color: #f8d7da;
font-size: 14px;
margin-top: 10px;
}
/* Info box */
.info-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0 0 10px 0;
color: #856404;
}
.info-box p:last-child {
margin-bottom: 0;
}
/* Info table */
.info-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
background-color: #f8f9fa;
border-radius: 6px;
overflow: hidden;
}
.info-table th,
.info-table td {
padding: 15px;
text-align: left;
border-bottom: 1px solid #dee2e6;
}
.info-table th {
background-color: #e9ecef;
font-weight: 600;
color: #495057;
width: 50%;
}
.info-table td {
color: #6c757d;
}
.info-table tr:last-child td {
border-bottom: none;
}
/* Action list */
.action-list {
background-color: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.action-list h3 {
margin: 0 0 15px 0;
color: #004085;
font-size: 16px;
}
.action-list ol {
margin: 0;
padding-left: 20px;
color: #004085;
}
.action-list li {
margin-bottom: 10px;
}
.action-list li:last-child {
margin-bottom: 0;
}
/* Footer */
.footer {
background-color: #f8f9fa;
padding: 30px;
text-align: center;
border-top: 1px solid #e9ecef;
}
.footer p {
margin: 0 0 10px 0;
font-size: 14px;
color: #6c757d;
}
.footer a {
color: #667eea;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
/* Responsive */
@media only screen and (max-width: 600px) {
.email-container {
margin: 0;
border-radius: 0;
}
.header,
.content,
.footer {
padding: 20px;
}
.logo {
font-size: 28px;
}
.content h1 {
font-size: 22px;
}
.loss-amount {
font-size: 36px;
}
.info-table th,
.info-table td {
padding: 10px;
font-size: 14px;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Dispute Lost - Manual Review Required</div>
</div>
<div class="content">
<h1>Dispute Lost - Owner Already Paid</h1>
<div class="critical-alert">
<p>
MANUAL REVIEW REQUIRED: The platform has lost this dispute, but the
owner has already received their payout.
</p>
</div>
<div class="loss-display">
<div class="loss-label">Platform Loss</div>
<div class="loss-amount">${{amount}}</div>
<div class="loss-subtitle">Dispute lost - funds returned to renter</div>
</div>
<h2>Financial Impact</h2>
<table class="info-table">
<tr>
<th>Rental ID</th>
<td>{{rentalId}}</td>
</tr>
<tr>
<th>Disputed Amount Lost</th>
<td style="color: #dc3545"><strong>${{amount}}</strong></td>
</tr>
<tr>
<th>Owner Payout (already sent)</th>
<td>${{ownerPayoutAmount}}</td>
</tr>
</table>
<h2>Owner Information</h2>
<div class="info-box">
<p><strong>Name:</strong> {{ownerName}}</p>
<p><strong>Email:</strong> {{ownerEmail}}</p>
</div>
<div class="action-list">
<h3>Recommended Actions</h3>
<ol>
<li>Review the dispute details in Stripe Dashboard</li>
<li>Assess whether to pursue clawback from owner's future payouts</li>
<li>Document the decision and reasoning</li>
<li>If clawback approved, contact owner before deducting</li>
</ol>
</div>
<p style="color: #6c757d; font-size: 14px">
This alert was sent because the dispute was lost after the owner had
already received their bank deposit. Manual review is required to
determine next steps.
</p>
</div>
<div class="footer">
<p><strong>Village Share - Admin Alert</strong></p>
<p>
This is an automated notification about a dispute loss requiring
manual review.
</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</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;
}
@@ -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>
@@ -255,7 +256,7 @@
<p>
<strong>Security reminder:</strong> Keep your password secure and
never share it with anyone. We recommend using a strong, unique
password and enabling two-factor authentication when available.
password and enabling multi-factor authentication when available.
</p>
</div>

View File

@@ -0,0 +1,366 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Payout Issue - 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;
}
/* Base styles */
body {
margin: 0;
padding: 0;
width: 100% !important;
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
/* Container */
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Header */
.header {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #f8d7da;
font-size: 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 h2 {
font-size: 20px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Alert box */
.alert-box {
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.alert-box p {
margin: 0 0 10px 0;
color: #721c24;
}
.alert-box p:last-child {
margin-bottom: 0;
}
/* Warning display */
.warning-display {
background: linear-gradient(135deg, #ffc107 0%, #e0a800 100%);
padding: 30px;
border-radius: 8px;
text-align: center;
margin: 30px 0;
box-shadow: 0 4px 12px rgba(255, 193, 7, 0.3);
}
.warning-label {
color: #212529;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 10px;
}
.warning-amount {
color: #212529;
font-size: 48px;
font-weight: 700;
margin: 0;
line-height: 1;
}
.warning-subtitle {
color: #212529;
font-size: 14px;
margin-top: 10px;
opacity: 0.8;
}
/* 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 #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0 0 10px 0;
color: #004085;
}
.info-box p:last-child {
margin-bottom: 0;
}
/* Info table */
.info-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
background-color: #f8f9fa;
border-radius: 6px;
overflow: hidden;
}
.info-table th,
.info-table td {
padding: 15px;
text-align: left;
border-bottom: 1px solid #dee2e6;
}
.info-table th {
background-color: #e9ecef;
font-weight: 600;
color: #495057;
width: 40%;
}
.info-table td {
color: #6c757d;
}
.info-table tr:last-child td {
border-bottom: none;
}
/* 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;
}
.warning-amount {
font-size: 36px;
}
.button {
display: block;
width: 100%;
box-sizing: border-box;
}
.info-table th,
.info-table td {
padding: 10px;
font-size: 14px;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Payout Issue</div>
</div>
<div class="content">
<p>Hi {{ownerName}},</p>
<p>
We encountered an issue depositing your earnings to your bank account.
Don't worry - your funds are safe and we'll help you resolve this.
</p>
<div class="warning-display">
<div class="warning-label">Pending Payout</div>
<div class="warning-amount">${{payoutAmount}}</div>
<div class="warning-subtitle">Action required to receive funds</div>
</div>
<div class="alert-box">
<p><strong>What happened:</strong></p>
<p>{{failureMessage}}</p>
<p><strong>What to do:</strong></p>
<p>{{actionRequired}}</p>
</div>
<h2>Payout Details</h2>
<table class="info-table">
<tr>
<th>Amount</th>
<td>${{payoutAmount}}</td>
</tr>
<tr>
<th>Status</th>
<td style="color: #dc3545">
<strong>Failed - Action Required</strong>
</td>
</tr>
<tr>
<th>Failure Reason</th>
<td>{{failureCode}}</td>
</tr>
</table>
{{#if requiresBankUpdate}}
<div style="text-align: center">
<a href="{{payoutSettingsUrl}}" class="button"
>Update Bank Account</a
>
</div>
{{/if}}
<div class="info-box">
<p><strong>What happens next?</strong></p>
<p>
Once you resolve this issue, your payout will be retried
automatically. If you need assistance, please contact our support
team.
</p>
</div>
<p>
We apologize for any inconvenience. Your earnings are safe and will be
deposited as soon as the issue is resolved.
</p>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>
This is an important notification about your earnings. You received
this message because a payout to your bank account could not be
completed.
</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,311 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Action Required - 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;
}
/* Base styles */
body {
margin: 0;
padding: 0;
width: 100% !important;
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
/* Container */
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Header */
.header {
background: linear-gradient(135deg, #ffc107 0%, #e0a800 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #212529;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #495057;
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 h2 {
font-size: 20px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Alert box */
.alert-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.alert-box p {
margin: 0 0 10px 0;
color: #856404;
}
.alert-box p:last-child {
margin-bottom: 0;
}
/* Warning display */
.warning-display {
background: linear-gradient(135deg, #ffc107 0%, #e0a800 100%);
padding: 30px;
border-radius: 8px;
text-align: center;
margin: 30px 0;
box-shadow: 0 4px 12px rgba(255, 193, 7, 0.3);
}
.warning-label {
color: #212529;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 10px;
}
.warning-title {
color: #212529;
font-size: 28px;
font-weight: 700;
margin: 0;
line-height: 1.2;
}
.warning-subtitle {
color: #212529;
font-size: 14px;
margin-top: 10px;
opacity: 0.8;
}
/* 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 #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0 0 10px 0;
color: #004085;
}
.info-box p:last-child {
margin-bottom: 0;
}
/* Footer */
.footer {
background-color: #f8f9fa;
padding: 30px;
text-align: center;
border-top: 1px solid #e9ecef;
}
.footer p {
margin: 0 0 10px 0;
font-size: 14px;
color: #6c757d;
}
.footer a {
color: #667eea;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
/* Responsive */
@media only screen and (max-width: 600px) {
.email-container {
margin: 0;
border-radius: 0;
}
.header,
.content,
.footer {
padding: 20px;
}
.logo {
font-size: 28px;
}
.content h1 {
font-size: 22px;
}
.warning-title {
font-size: 24px;
}
.button {
display: block;
width: 100%;
box-sizing: border-box;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Action Required</div>
</div>
<div class="content">
<p>Hi {{ownerName}},</p>
<p>
Your payouts have been temporarily paused because additional
verification is needed for your account.
</p>
<div class="warning-display">
<div class="warning-label">Account Status</div>
<div class="warning-title">Payouts Paused</div>
<div class="warning-subtitle">Complete verification to resume</div>
</div>
<div class="alert-box">
<p><strong>What happened:</strong></p>
<p>
{{disabledReason}}
</p>
<p><strong>What to do:</strong></p>
<p>
Visit your Earnings page to complete the required verification
steps. This usually only takes a few minutes.
</p>
</div>
<div style="text-align: center">
<a href="{{earningsUrl}}" class="button">Go to Earnings</a>
</div>
<div class="info-box">
<p><strong>What happens next?</strong></p>
<p>
Once you complete the verification, your payouts will resume
automatically. Any pending earnings will be deposited to your bank
account on the normal schedule.
</p>
</div>
<p>
We apologize for any inconvenience. Your earnings are safe and will be
deposited as soon as verification is complete.
</p>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>
This is an important notification about your account. You received
this message because your payouts have been paused pending
verification.
</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -1,17 +1,24 @@
<!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">
<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 {
body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
table,
td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
@@ -27,7 +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;
}
@@ -182,7 +191,9 @@
border-radius: 0;
}
.header, .content, .footer {
.header,
.content,
.footer {
padding: 20px;
}
@@ -209,10 +220,18 @@
<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>
<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>
<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>
@@ -226,11 +245,21 @@
</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>
<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="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>
<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>
@@ -238,7 +267,11 @@
<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>
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>

View File

@@ -0,0 +1,232 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Recovery Code Used</title>
<style>
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;
}
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;
}
.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 {
background: linear-gradient(135deg, #fd7e14 0%, #e55300 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #ffecd2;
font-size: 14px;
margin-top: 8px;
}
.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;
}
.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;
}
.warning-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.warning-box p {
margin: 0;
color: #856404;
font-size: 14px;
}
.alert-box {
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.alert-box p {
margin: 0;
color: #721c24;
font-size: 14px;
}
.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;
}
@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;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Security Notice</div>
</div>
<div class="content">
<p>Hi {{recipientName}},</p>
<h1>Recovery Code Used</h1>
<div class="info-box">
<p>
A recovery code was just used to verify your identity on your
Village Share account.
</p>
</div>
<p>
Recovery codes are one-time use codes that allow you to access your
account when you don't have access to your authenticator app.
</p>
<div
style="
text-align: center;
margin: 30px 0;
padding: 20px;
background-color: #f8f9fa;
border-radius: 8px;
"
>
<p style="margin: 0 0 10px 0; color: #6c757d; font-size: 14px">
Remaining recovery codes:
</p>
<span
style="
font-size: 48px;
font-weight: 700;
color: {{remainingCodesColor}};
"
>{{remainingCodes}}</span
>
<span style="font-size: 24px; color: #6c757d"> / 10</span>
</div>
{{#if lowCodesWarning}}
<div class="alert-box">
<p>
<strong>Warning:</strong> You're running low on recovery codes! We
strongly recommend generating new recovery codes from your account
settings to avoid being locked out.
</p>
</div>
{{/if}}
<div class="warning-box">
<p>
<strong>Didn't use a recovery code?</strong> If you didn't initiate
this action, someone may have access to your recovery codes. Please
secure your account immediately by:
</p>
<ul style="margin: 10px 0 0 0; padding-left: 20px">
<li>Changing your password</li>
<li>Generating new recovery codes</li>
<li>Contacting our support team</li>
</ul>
</div>
<p>
<strong>Timestamp:</strong> {{timestamp}}
</p>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>
This is a security notification. You received this message because a
recovery code was used on your account.
</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Multi-Factor Authentication Disabled</title>
<style>
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;
}
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;
}
.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 {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #f8d7da;
font-size: 14px;
margin-top: 8px;
}
.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;
}
.warning-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.warning-box p {
margin: 0;
color: #856404;
font-size: 14px;
}
.alert-box {
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.alert-box p {
margin: 0;
color: #721c24;
font-size: 14px;
}
.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;
}
@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;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Security Alert</div>
</div>
<div class="content">
<p>Hi {{recipientName}},</p>
<h1>Multi-Factor Authentication Disabled</h1>
<div class="alert-box">
<p>
<strong>Important:</strong> Multi-factor authentication has been
disabled on your Village Share account.
</p>
</div>
<p>
Your account no longer requires multi-factor verification for sensitive
actions. While this may be more convenient, your account is now less
protected against unauthorized access.
</p>
<div class="warning-box">
<p>
<strong>We recommend keeping 2FA enabled</strong> to protect your
account, especially if you have payment methods saved or are
receiving payouts from rentals.
</p>
</div>
<p>
<strong>Didn't disable this?</strong> If you didn't make this change,
your account may have been compromised. Please take the following
steps immediately:
</p>
<ol style="color: #6c757d; margin: 16px 0; padding-left: 20px">
<li>Change your password</li>
<li>Re-enable multi-factor authentication</li>
<li>Contact our support team</li>
</ol>
<p>
<strong>Timestamp:</strong> {{timestamp}}
</p>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>
This is a security notification. You received this message because
multi-factor authentication was disabled on your account.
</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Multi-Factor Authentication Enabled</title>
<style>
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;
}
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;
}
.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 {
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 {
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 {
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 {
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;
}
.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;
}
@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;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Security Update</div>
</div>
<div class="content">
<p>Hi {{recipientName}},</p>
<h1>Multi-Factor Authentication Enabled</h1>
<div class="success-box">
<p>
<strong>Great news!</strong> Multi-factor authentication has been
successfully enabled on your Village Share account.
</p>
</div>
<p>
Your account is now more secure. From now on, you'll need to verify
your identity using your authenticator app or email when performing
sensitive actions like:
</p>
<ul style="color: #6c757d; margin: 16px 0; padding-left: 20px">
<li>Changing your password</li>
<li>Updating your email address</li>
<li>Requesting payouts</li>
<li>Disabling multi-factor authentication</li>
</ul>
<div class="info-box">
<p>
<strong>Recovery Codes:</strong> Make sure you've saved your
recovery codes in a secure location. These codes can be used to
access your account if you lose access to your authenticator app.
</p>
</div>
<p>
<strong>Didn't enable this?</strong> If you didn't make this change,
please contact our support team immediately and secure your account.
</p>
<p>
<strong>Timestamp:</strong> {{timestamp}}
</p>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>
This is a security notification. You received this message because
multi-factor authentication was enabled on your account.
</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,193 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Your Verification Code</title>
<style>
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;
}
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;
}
.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 {
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: #e0d4f7;
font-size: 14px;
margin-top: 8px;
}
.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;
}
.warning-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 15px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.warning-box p {
margin: 0;
color: #856404;
font-size: 14px;
}
.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;
}
@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;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Security Verification</div>
</div>
<div class="content">
<p>Hi {{recipientName}},</p>
<h1>Your Verification Code</h1>
<p>
You requested a verification code to complete a secure action on your
Village Share account.
</p>
<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 #667eea;
"
>
<span
style="
font-size: 36px;
font-weight: 700;
letter-spacing: 8px;
color: #667eea;
font-family: 'Courier New', monospace;
"
>{{otpCode}}</span
>
</div>
<p style="margin-top: 10px; font-size: 14px; color: #6c757d">
Enter this code to verify your identity
</p>
</div>
<div class="warning-box">
<p>
<strong>This code will expire in 10 minutes.</strong> If you didn't
request this code, please secure your account immediately.
</p>
</div>
<p>
<strong>Didn't request this code?</strong> If you didn't initiate this
request, someone may be trying to access your account. We recommend
changing your password immediately.
</p>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>
This is a security email sent to protect your account. Never share
this code with anyone.
</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -1,13 +1,30 @@
// Integration test setup
// Integration tests use a real database, so we don't mock DATABASE_URL
process.env.NODE_ENV = 'test';
const path = require("path");
require("dotenv").config({ path: path.join(__dirname, "..", ".env.test") });
// Ensure JWT secrets are set for integration tests
process.env.JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || 'test-access-secret';
process.env.JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'test-refresh-secret';
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret';
process.env.NODE_ENV = "test";
// Set other required env vars if not already set
process.env.GOOGLE_MAPS_API_KEY = process.env.GOOGLE_MAPS_API_KEY || 'test-key';
process.env.STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY || 'sk_test_key';
// Required environment variables - fail fast if missing
const requiredEnvVars = [
"JWT_ACCESS_SECRET",
"JWT_REFRESH_SECRET",
"CSRF_SECRET",
"TOTP_ENCRYPTION_KEY",
];
const missingVars = requiredEnvVars.filter((v) => !process.env[v]);
if (missingVars.length > 0) {
throw new Error(
`Missing required environment variables for integration tests: ${missingVars.join(", ")}\n` +
`Please ensure these are set in your .env.test file.`,
);
}
// Optional variables with safe defaults
process.env.JWT_SECRET =
process.env.JWT_SECRET || process.env.JWT_ACCESS_SECRET;
process.env.EMAIL_ENABLED = "false";
process.env.FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:3000";
process.env.GOOGLE_MAPS_API_KEY = process.env.GOOGLE_MAPS_API_KEY || "test-key";
process.env.STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY || "sk_test_key";

View File

@@ -6,6 +6,17 @@
* and password reset functionality.
*/
// Mock email services before importing routes
jest.mock('../../services/email', () => ({
auth: {
sendVerificationEmail: jest.fn().mockResolvedValue({ success: true }),
sendPasswordResetEmail: jest.fn().mockResolvedValue({ success: true }),
sendPasswordChangedEmail: jest.fn().mockResolvedValue({ success: true }),
},
initialize: jest.fn().mockResolvedValue(),
initialized: true,
}));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
@@ -32,6 +43,63 @@ jest.mock('../../middleware/csrf', () => ({
},
}));
// Mock sanitizeInput to avoid req.query setter issue in supertest
// Keep the actual validation rules but skip DOMPurify sanitization
jest.mock('../../middleware/validation', () => {
const { body, validationResult } = require('express-validator');
// Validation error handler
const handleValidationErrors = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
error: 'Validation failed',
details: errors.array().map((err) => ({
field: err.path,
message: err.msg,
})),
});
}
next();
};
// Password strength validation
const passwordStrengthRegex = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z])(?=.*[-@$!%*?&#^]).{8,}$/;
return {
sanitizeInput: (req, res, next) => next(), // Skip sanitization in tests
validateRegistration: [
body('email').isEmail().normalizeEmail().withMessage('Please provide a valid email address'),
body('password').isLength({ min: 8, max: 128 }).matches(passwordStrengthRegex).withMessage('Password does not meet requirements'),
body('firstName').trim().isLength({ min: 1, max: 50 }).withMessage('First name is required'),
body('lastName').trim().isLength({ min: 1, max: 50 }).withMessage('Last name is required'),
handleValidationErrors,
],
validateLogin: [
body('email').isEmail().normalizeEmail().withMessage('Please provide a valid email address'),
body('password').notEmpty().withMessage('Password is required'),
handleValidationErrors,
],
validateGoogleAuth: [
body('code').notEmpty().withMessage('Authorization code is required'),
handleValidationErrors,
],
validateForgotPassword: [
body('email').isEmail().normalizeEmail().withMessage('Please provide a valid email address'),
handleValidationErrors,
],
validateResetPassword: [
body('token').notEmpty().withMessage('Token is required').isLength({ min: 64, max: 64 }).withMessage('Invalid token format'),
body('newPassword').isLength({ min: 8, max: 128 }).matches(passwordStrengthRegex).withMessage('Password does not meet requirements'),
handleValidationErrors,
],
validateVerifyResetToken: [
body('token').notEmpty().withMessage('Token is required'),
handleValidationErrors,
],
};
});
const { sequelize, User, AlphaInvitation } = require('../../models');
const authRoutes = require('../../routes/auth');
@@ -48,6 +116,14 @@ const createTestApp = () => {
});
app.use('/auth', authRoutes);
// Error handler for tests
app.use((err, req, res, next) => {
res.status(err.status || 500).json({
error: err.message || 'Internal Server Error',
});
});
return app;
};
@@ -98,9 +174,9 @@ describe('Auth Integration Tests', () => {
});
beforeEach(async () => {
// Clean up users before each test
await User.destroy({ where: {}, truncate: true, cascade: true });
await AlphaInvitation.destroy({ where: {}, truncate: true, cascade: true });
// Use destroy without truncate for safer cleanup with foreign keys
await User.destroy({ where: {}, force: true });
await AlphaInvitation.destroy({ where: {}, force: true });
});
describe('POST /auth/register', () => {
@@ -226,7 +302,7 @@ describe('Auth Integration Tests', () => {
})
.expect(401);
expect(response.body.error).toBe('Unable to log in. Please check your email and password, or create an account.');
expect(response.body.error).toBe('Please check your email and password, or create an account.');
});
it('should reject login with non-existent email', async () => {
@@ -238,7 +314,7 @@ describe('Auth Integration Tests', () => {
})
.expect(401);
expect(response.body.error).toBe('Unable to log in. Please check your email and password, or create an account.');
expect(response.body.error).toBe('Please check your email and password, or create an account.');
});
it('should increment login attempts on failed login', async () => {
@@ -255,8 +331,8 @@ describe('Auth Integration Tests', () => {
});
it('should lock account after too many failed attempts', async () => {
// Make 5 failed login attempts
for (let i = 0; i < 5; i++) {
// Make 10 failed login attempts (MAX_LOGIN_ATTEMPTS is 10)
for (let i = 0; i < 10; i++) {
await request(app)
.post('/auth/login')
.send({
@@ -265,7 +341,7 @@ describe('Auth Integration Tests', () => {
});
}
// 6th attempt should return locked error
// 11th attempt should return locked error
const response = await request(app)
.post('/auth/login')
.send({

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 becomes active (typically done by system/webhook)
await rental.update({ status: 'active' });
// Verify status
// 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('active');
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
// Step 3: Owner marks rental as completed (via mark-return with status='returned')
response = await request(app)
.post(`/rentals/${rental.id}/mark-completed`)
.set('Cookie', [`accessToken=${ownerToken}`])
.post(`/rentals/${rental.id}/mark-return`)
.set("Cookie", [`accessToken=${ownerToken}`])
.send({ status: "returned" })
.expect(200);
expect(response.body.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

@@ -1,13 +1,14 @@
process.env.NODE_ENV = 'test';
process.env.JWT_SECRET = 'test-secret';
process.env.DATABASE_URL = 'postgresql://test';
process.env.GOOGLE_MAPS_API_KEY = 'test-key';
process.env.STRIPE_SECRET_KEY = 'sk_test_key';
process.env.NODE_ENV = "test";
process.env.JWT_SECRET = "test-secret";
process.env.DATABASE_URL = "db://test";
process.env.GOOGLE_MAPS_API_KEY = "test-key";
process.env.STRIPE_SECRET_KEY = "sk_test_key";
process.env.CSRF_SECRET = "test-csrf-secret-that-is-at-least-32-chars-long";
// Silence console
global.console = {
...console,
log: jest.fn(),
error: jest.fn(),
warn: jest.fn()
warn: jest.fn(),
};

View File

@@ -1,4 +1,4 @@
const { authenticateToken, requireVerifiedEmail } = require('../../../middleware/auth');
const { authenticateToken, optionalAuth, requireVerifiedEmail, requireAdmin } = require('../../../middleware/auth');
const jwt = require('jsonwebtoken');
jest.mock('jsonwebtoken');
@@ -349,3 +349,392 @@ describe('requireVerifiedEmail Middleware', () => {
});
});
});
describe('optionalAuth Middleware', () => {
let req, res, next;
beforeEach(() => {
req = {
cookies: {}
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};
next = jest.fn();
jest.clearAllMocks();
process.env.JWT_ACCESS_SECRET = 'test-secret';
});
describe('No token present', () => {
it('should set req.user to null when no token present', async () => {
req.cookies = {};
await optionalAuth(req, res, next);
expect(req.user).toBeNull();
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should set req.user to null when cookies is undefined', async () => {
req.cookies = undefined;
await optionalAuth(req, res, next);
expect(req.user).toBeNull();
expect(next).toHaveBeenCalled();
});
it('should set req.user to null for empty string token', async () => {
req.cookies.accessToken = '';
await optionalAuth(req, res, next);
expect(req.user).toBeNull();
expect(next).toHaveBeenCalled();
});
});
describe('Valid token present', () => {
it('should set req.user when valid token present', async () => {
const mockUser = { id: 1, email: 'test@test.com', jwtVersion: 1 };
req.cookies.accessToken = 'validtoken';
jwt.verify.mockReturnValue({ id: 1, jwtVersion: 1 });
User.findByPk.mockResolvedValue(mockUser);
await optionalAuth(req, res, next);
expect(jwt.verify).toHaveBeenCalledWith('validtoken', process.env.JWT_ACCESS_SECRET);
expect(User.findByPk).toHaveBeenCalledWith(1);
expect(req.user).toEqual(mockUser);
expect(next).toHaveBeenCalled();
});
});
describe('Invalid token handling', () => {
it('should set req.user to null for invalid token (no error returned)', async () => {
req.cookies.accessToken = 'invalidtoken';
jwt.verify.mockImplementation(() => {
throw new Error('Invalid token');
});
await optionalAuth(req, res, next);
expect(req.user).toBeNull();
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should set req.user to null for expired token (no error returned)', async () => {
req.cookies.accessToken = 'expiredtoken';
const error = new Error('jwt expired');
error.name = 'TokenExpiredError';
jwt.verify.mockImplementation(() => {
throw error;
});
await optionalAuth(req, res, next);
expect(req.user).toBeNull();
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should set req.user to null when token has no user id', async () => {
req.cookies.accessToken = 'tokenwithnoid';
jwt.verify.mockReturnValue({ email: 'test@test.com' }); // Missing id
await optionalAuth(req, res, next);
expect(req.user).toBeNull();
expect(next).toHaveBeenCalled();
});
});
describe('User state handling', () => {
it('should set req.user to null for banned user', async () => {
const mockUser = { id: 1, email: 'banned@test.com', isBanned: true, jwtVersion: 1 };
req.cookies.accessToken = 'validtoken';
jwt.verify.mockReturnValue({ id: 1, jwtVersion: 1 });
User.findByPk.mockResolvedValue(mockUser);
await optionalAuth(req, res, next);
expect(req.user).toBeNull();
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should set req.user to null for JWT version mismatch', async () => {
const mockUser = { id: 1, email: 'test@test.com', jwtVersion: 2 };
req.cookies.accessToken = 'validtoken';
jwt.verify.mockReturnValue({ id: 1, jwtVersion: 1 }); // Old version
User.findByPk.mockResolvedValue(mockUser);
await optionalAuth(req, res, next);
expect(req.user).toBeNull();
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should set req.user to null when user not found', async () => {
req.cookies.accessToken = 'validtoken';
jwt.verify.mockReturnValue({ id: 999, jwtVersion: 1 });
User.findByPk.mockResolvedValue(null);
await optionalAuth(req, res, next);
expect(req.user).toBeNull();
expect(next).toHaveBeenCalled();
});
});
describe('Edge cases', () => {
it('should handle database error gracefully', async () => {
req.cookies.accessToken = 'validtoken';
jwt.verify.mockReturnValue({ id: 1, jwtVersion: 1 });
User.findByPk.mockRejectedValue(new Error('Database error'));
await optionalAuth(req, res, next);
expect(req.user).toBeNull();
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
});
describe('requireAdmin Middleware', () => {
let req, res, next;
beforeEach(() => {
req = {
user: null
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};
next = jest.fn();
jest.clearAllMocks();
});
describe('Admin users', () => {
it('should call next() for admin user', () => {
req.user = {
id: 1,
email: 'admin@test.com',
role: 'admin'
};
requireAdmin(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
expect(res.json).not.toHaveBeenCalled();
});
it('should call next() for admin user with additional properties', () => {
req.user = {
id: 1,
email: 'admin@test.com',
role: 'admin',
firstName: 'Admin',
lastName: 'User',
isVerified: true
};
requireAdmin(req, res, next);
expect(next).toHaveBeenCalled();
});
});
describe('Non-admin users', () => {
it('should return 403 for non-admin user', () => {
req.user = {
id: 1,
email: 'user@test.com',
role: 'user'
};
requireAdmin(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Admin access required',
code: 'INSUFFICIENT_PERMISSIONS'
});
expect(next).not.toHaveBeenCalled();
});
it('should return 403 for host role', () => {
req.user = {
id: 1,
email: 'host@test.com',
role: 'host'
};
requireAdmin(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Admin access required',
code: 'INSUFFICIENT_PERMISSIONS'
});
});
it('should return 403 for user with no role property', () => {
req.user = {
id: 1,
email: 'user@test.com'
// role is missing
};
requireAdmin(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Admin access required',
code: 'INSUFFICIENT_PERMISSIONS'
});
expect(next).not.toHaveBeenCalled();
});
it('should return 403 for user with empty string role', () => {
req.user = {
id: 1,
email: 'user@test.com',
role: ''
};
requireAdmin(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
});
});
describe('No user', () => {
it('should return 401 when user is null', () => {
req.user = null;
requireAdmin(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: 'Authentication required',
code: 'NO_AUTH'
});
expect(next).not.toHaveBeenCalled();
});
it('should return 401 when user is undefined', () => {
req.user = undefined;
requireAdmin(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: 'Authentication required',
code: 'NO_AUTH'
});
expect(next).not.toHaveBeenCalled();
});
});
describe('Edge cases', () => {
it('should handle case-sensitive role comparison', () => {
req.user = {
id: 1,
email: 'user@test.com',
role: 'Admin' // Capital A
};
requireAdmin(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('should handle role with whitespace', () => {
req.user = {
id: 1,
email: 'user@test.com',
role: ' admin ' // With spaces
};
requireAdmin(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
});
});
describe('authenticateToken - Additional Tests', () => {
let req, res, next;
beforeEach(() => {
req = {
cookies: {},
id: 'request-123'
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};
next = jest.fn();
jest.clearAllMocks();
process.env.JWT_ACCESS_SECRET = 'test-secret';
});
describe('Banned user', () => {
it('should return 403 USER_BANNED for banned user', async () => {
const mockUser = { id: 1, email: 'banned@test.com', isBanned: true, jwtVersion: 1 };
req.cookies.accessToken = 'validtoken';
jwt.verify.mockReturnValue({ id: 1, jwtVersion: 1 });
User.findByPk.mockResolvedValue(mockUser);
await authenticateToken(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Your account has been suspended. Please contact support for more information.',
code: 'USER_BANNED'
});
expect(next).not.toHaveBeenCalled();
});
});
describe('JWT version mismatch', () => {
it('should return 401 JWT_VERSION_MISMATCH for version mismatch', async () => {
const mockUser = { id: 1, email: 'test@test.com', jwtVersion: 2 };
req.cookies.accessToken = 'validtoken';
jwt.verify.mockReturnValue({ id: 1, jwtVersion: 1 }); // Old version in token
User.findByPk.mockResolvedValue(mockUser);
await authenticateToken(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: 'Session expired due to password change. Please log in again.',
code: 'JWT_VERSION_MISMATCH'
});
expect(next).not.toHaveBeenCalled();
});
it('should pass when JWT version matches', async () => {
const mockUser = { id: 1, email: 'test@test.com', jwtVersion: 5 };
req.cookies.accessToken = 'validtoken';
jwt.verify.mockReturnValue({ id: 1, jwtVersion: 5 });
User.findByPk.mockResolvedValue(mockUser);
await authenticateToken(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.user).toEqual(mockUser);
});
});
});

View File

@@ -1,29 +1,47 @@
// Set CSRF_SECRET before requiring the middleware
process.env.CSRF_SECRET = "test-csrf-secret";
const mockTokensInstance = {
secretSync: jest.fn().mockReturnValue('mock-secret'),
create: jest.fn().mockReturnValue('mock-token-123'),
verify: jest.fn().mockReturnValue(true)
secretSync: jest.fn().mockReturnValue(process.env.CSRF_SECRET),
create: jest.fn().mockReturnValue("mock-token-123"),
verify: jest.fn().mockReturnValue(true),
};
jest.mock('csrf', () => {
jest.mock("csrf", () => {
return jest.fn().mockImplementation(() => mockTokensInstance);
});
jest.mock('cookie-parser', () => {
jest.mock("cookie-parser", () => {
return jest.fn().mockReturnValue((req, res, next) => next());
});
const { csrfProtection, generateCSRFToken, getCSRFToken } = require('../../../middleware/csrf');
jest.mock("../../../utils/logger", () => ({
error: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
withRequestId: jest.fn(() => ({
error: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
})),
}));
describe('CSRF Middleware', () => {
const {
csrfProtection,
generateCSRFToken,
getCSRFToken,
} = require("../../../middleware/csrf");
describe("CSRF Middleware", () => {
let req, res, next;
beforeEach(() => {
req = {
method: 'POST',
method: "POST",
headers: {},
body: {},
query: {},
cookies: {}
cookies: {},
};
res = {
status: jest.fn().mockReturnThis(),
@@ -31,16 +49,16 @@ describe('CSRF Middleware', () => {
send: jest.fn(),
cookie: jest.fn(),
set: jest.fn(),
locals: {}
locals: {},
};
next = jest.fn();
jest.clearAllMocks();
});
describe('csrfProtection', () => {
describe('Safe methods', () => {
it('should skip CSRF protection for GET requests', () => {
req.method = 'GET';
describe("csrfProtection", () => {
describe("Safe methods", () => {
it("should skip CSRF protection for GET requests", () => {
req.method = "GET";
csrfProtection(req, res, next);
@@ -48,8 +66,8 @@ describe('CSRF Middleware', () => {
expect(res.status).not.toHaveBeenCalled();
});
it('should skip CSRF protection for HEAD requests', () => {
req.method = 'HEAD';
it("should skip CSRF protection for HEAD requests", () => {
req.method = "HEAD";
csrfProtection(req, res, next);
@@ -57,8 +75,8 @@ describe('CSRF Middleware', () => {
expect(res.status).not.toHaveBeenCalled();
});
it('should skip CSRF protection for OPTIONS requests', () => {
req.method = 'OPTIONS';
it("should skip CSRF protection for OPTIONS requests", () => {
req.method = "OPTIONS";
csrfProtection(req, res, next);
@@ -67,418 +85,427 @@ describe('CSRF Middleware', () => {
});
});
describe('Token validation', () => {
describe("Token validation", () => {
beforeEach(() => {
req.cookies = { 'csrf-token': 'mock-token-123' };
req.cookies = { "csrf-token": "mock-token-123" };
});
it('should validate token from x-csrf-token header', () => {
req.headers['x-csrf-token'] = 'mock-token-123';
it("should validate token from x-csrf-token header", () => {
req.headers["x-csrf-token"] = "mock-token-123";
csrfProtection(req, res, next);
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
expect(mockTokensInstance.verify).toHaveBeenCalledWith(
process.env.CSRF_SECRET,
"mock-token-123",
);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should validate token from request body', () => {
req.body.csrfToken = 'mock-token-123';
it("should validate token from request body", () => {
req.body.csrfToken = "mock-token-123";
csrfProtection(req, res, next);
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
expect(mockTokensInstance.verify).toHaveBeenCalledWith(
process.env.CSRF_SECRET,
"mock-token-123",
);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should validate token from query parameters', () => {
req.query.csrfToken = 'mock-token-123';
it("should prefer header token over body token", () => {
req.headers["x-csrf-token"] = "mock-token-123";
req.body.csrfToken = "different-token";
csrfProtection(req, res, next);
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should prefer header token over body token', () => {
req.headers['x-csrf-token'] = 'mock-token-123';
req.body.csrfToken = 'different-token';
csrfProtection(req, res, next);
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
expect(next).toHaveBeenCalled();
});
it('should prefer header token over query token', () => {
req.headers['x-csrf-token'] = 'mock-token-123';
req.query.csrfToken = 'different-token';
csrfProtection(req, res, next);
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
expect(next).toHaveBeenCalled();
});
it('should prefer body token over query token', () => {
req.body.csrfToken = 'mock-token-123';
req.query.csrfToken = 'different-token';
csrfProtection(req, res, next);
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
expect(mockTokensInstance.verify).toHaveBeenCalledWith(
process.env.CSRF_SECRET,
"mock-token-123",
);
expect(next).toHaveBeenCalled();
});
});
describe('Missing tokens', () => {
it('should return 403 when no token provided', () => {
req.cookies = { 'csrf-token': 'mock-token-123' };
describe("Missing tokens", () => {
it("should return 403 when no token provided", () => {
req.cookies = { "csrf-token": "mock-token-123" };
csrfProtection(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Invalid CSRF token',
code: 'CSRF_TOKEN_MISMATCH'
error: "Invalid CSRF token",
code: "CSRF_TOKEN_MISMATCH",
});
expect(next).not.toHaveBeenCalled();
});
it('should return 403 when no cookie token provided', () => {
req.headers['x-csrf-token'] = 'mock-token-123';
it("should return 403 when no cookie token provided", () => {
req.headers["x-csrf-token"] = "mock-token-123";
req.cookies = {};
csrfProtection(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Invalid CSRF token',
code: 'CSRF_TOKEN_MISMATCH'
error: "Invalid CSRF token",
code: "CSRF_TOKEN_MISMATCH",
});
expect(next).not.toHaveBeenCalled();
});
it('should return 403 when cookies object is missing', () => {
req.headers['x-csrf-token'] = 'mock-token-123';
it("should return 403 when cookies object is missing", () => {
req.headers["x-csrf-token"] = "mock-token-123";
req.cookies = undefined;
csrfProtection(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Invalid CSRF token',
code: 'CSRF_TOKEN_MISMATCH'
error: "Invalid CSRF token",
code: "CSRF_TOKEN_MISMATCH",
});
expect(next).not.toHaveBeenCalled();
});
it('should return 403 when both tokens are missing', () => {
it("should return 403 when both tokens are missing", () => {
req.cookies = {};
csrfProtection(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Invalid CSRF token',
code: 'CSRF_TOKEN_MISMATCH'
error: "Invalid CSRF token",
code: "CSRF_TOKEN_MISMATCH",
});
expect(next).not.toHaveBeenCalled();
});
});
describe('Token mismatch', () => {
it('should return 403 when tokens do not match', () => {
req.headers['x-csrf-token'] = 'token-from-header';
req.cookies = { 'csrf-token': 'token-from-cookie' };
describe("Token mismatch", () => {
it("should return 403 when tokens do not match", () => {
req.headers["x-csrf-token"] = "token-from-header";
req.cookies = { "csrf-token": "token-from-cookie" };
csrfProtection(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Invalid CSRF token',
code: 'CSRF_TOKEN_MISMATCH'
error: "Invalid CSRF token",
code: "CSRF_TOKEN_MISMATCH",
});
expect(next).not.toHaveBeenCalled();
});
it('should return 403 when header token is empty but cookie exists', () => {
req.headers['x-csrf-token'] = '';
req.cookies = { 'csrf-token': 'mock-token-123' };
it("should return 403 when header token is empty but cookie exists", () => {
req.headers["x-csrf-token"] = "";
req.cookies = { "csrf-token": "mock-token-123" };
csrfProtection(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Invalid CSRF token',
code: 'CSRF_TOKEN_MISMATCH'
error: "Invalid CSRF token",
code: "CSRF_TOKEN_MISMATCH",
});
expect(next).not.toHaveBeenCalled();
});
it('should return 403 when cookie token is empty but header exists', () => {
req.headers['x-csrf-token'] = 'mock-token-123';
req.cookies = { 'csrf-token': '' };
it("should return 403 when cookie token is empty but header exists", () => {
req.headers["x-csrf-token"] = "mock-token-123";
req.cookies = { "csrf-token": "" };
csrfProtection(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Invalid CSRF token',
code: 'CSRF_TOKEN_MISMATCH'
error: "Invalid CSRF token",
code: "CSRF_TOKEN_MISMATCH",
});
expect(next).not.toHaveBeenCalled();
});
});
describe('Token verification', () => {
describe("Token verification", () => {
beforeEach(() => {
req.headers['x-csrf-token'] = 'mock-token-123';
req.cookies = { 'csrf-token': 'mock-token-123' };
req.headers["x-csrf-token"] = "mock-token-123";
req.cookies = { "csrf-token": "mock-token-123" };
});
it('should return 403 when token verification fails', () => {
it("should return 403 when token verification fails", () => {
mockTokensInstance.verify.mockReturnValue(false);
csrfProtection(req, res, next);
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
expect(mockTokensInstance.verify).toHaveBeenCalledWith(
process.env.CSRF_SECRET,
"mock-token-123",
);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Invalid CSRF token',
code: 'CSRF_TOKEN_INVALID'
error: "Invalid CSRF token",
code: "CSRF_TOKEN_INVALID",
});
expect(next).not.toHaveBeenCalled();
});
it('should call next when token verification succeeds', () => {
it("should call next when token verification succeeds", () => {
mockTokensInstance.verify.mockReturnValue(true);
csrfProtection(req, res, next);
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
expect(mockTokensInstance.verify).toHaveBeenCalledWith(
process.env.CSRF_SECRET,
"mock-token-123",
);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
describe('Edge cases', () => {
it('should handle case-insensitive HTTP methods', () => {
req.method = 'post';
req.headers['x-csrf-token'] = 'mock-token-123';
req.cookies = { 'csrf-token': 'mock-token-123' };
describe("Edge cases", () => {
it("should handle case-insensitive HTTP methods", () => {
req.method = "post";
req.headers["x-csrf-token"] = "mock-token-123";
req.cookies = { "csrf-token": "mock-token-123" };
csrfProtection(req, res, next);
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
expect(mockTokensInstance.verify).toHaveBeenCalledWith(
process.env.CSRF_SECRET,
"mock-token-123",
);
expect(next).toHaveBeenCalled();
});
it('should handle PUT requests', () => {
req.method = 'PUT';
req.headers['x-csrf-token'] = 'mock-token-123';
req.cookies = { 'csrf-token': 'mock-token-123' };
it("should handle PUT requests", () => {
req.method = "PUT";
req.headers["x-csrf-token"] = "mock-token-123";
req.cookies = { "csrf-token": "mock-token-123" };
csrfProtection(req, res, next);
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
expect(mockTokensInstance.verify).toHaveBeenCalledWith(
process.env.CSRF_SECRET,
"mock-token-123",
);
expect(next).toHaveBeenCalled();
});
it('should handle DELETE requests', () => {
req.method = 'DELETE';
req.headers['x-csrf-token'] = 'mock-token-123';
req.cookies = { 'csrf-token': 'mock-token-123' };
it("should handle DELETE requests", () => {
req.method = "DELETE";
req.headers["x-csrf-token"] = "mock-token-123";
req.cookies = { "csrf-token": "mock-token-123" };
csrfProtection(req, res, next);
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
expect(mockTokensInstance.verify).toHaveBeenCalledWith(
process.env.CSRF_SECRET,
"mock-token-123",
);
expect(next).toHaveBeenCalled();
});
it('should handle PATCH requests', () => {
req.method = 'PATCH';
req.headers['x-csrf-token'] = 'mock-token-123';
req.cookies = { 'csrf-token': 'mock-token-123' };
it("should handle PATCH requests", () => {
req.method = "PATCH";
req.headers["x-csrf-token"] = "mock-token-123";
req.cookies = { "csrf-token": "mock-token-123" };
csrfProtection(req, res, next);
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
expect(mockTokensInstance.verify).toHaveBeenCalledWith(
process.env.CSRF_SECRET,
"mock-token-123",
);
expect(next).toHaveBeenCalled();
});
});
});
describe('generateCSRFToken', () => {
it('should generate token and set cookie with proper options', () => {
process.env.NODE_ENV = 'production';
describe("generateCSRFToken", () => {
it("should generate token and set cookie with proper options", () => {
process.env.NODE_ENV = "production";
generateCSRFToken(req, res, next);
expect(mockTokensInstance.create).toHaveBeenCalledWith('mock-secret');
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
expect(mockTokensInstance.create).toHaveBeenCalledWith(
process.env.CSRF_SECRET,
);
expect(res.cookie).toHaveBeenCalledWith("csrf-token", "mock-token-123", {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 60 * 60 * 1000
sameSite: "strict",
maxAge: 60 * 60 * 1000,
});
expect(next).toHaveBeenCalled();
});
it('should set secure flag to false in dev environment', () => {
process.env.NODE_ENV = 'dev';
it("should set secure flag to false in dev environment", () => {
process.env.NODE_ENV = "dev";
generateCSRFToken(req, res, next);
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
expect(res.cookie).toHaveBeenCalledWith("csrf-token", "mock-token-123", {
httpOnly: true,
secure: false,
sameSite: 'strict',
maxAge: 60 * 60 * 1000
sameSite: "strict",
maxAge: 60 * 60 * 1000,
});
});
it('should set secure flag to true in non-dev environment', () => {
process.env.NODE_ENV = 'production';
it("should set secure flag to true in non-dev environment", () => {
process.env.NODE_ENV = "production";
generateCSRFToken(req, res, next);
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
expect(res.cookie).toHaveBeenCalledWith("csrf-token", "mock-token-123", {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 60 * 60 * 1000
sameSite: "strict",
maxAge: 60 * 60 * 1000,
});
});
it('should set token in response header', () => {
it("should set token in response header", () => {
generateCSRFToken(req, res, next);
expect(res.set).toHaveBeenCalledWith('X-CSRF-Token', 'mock-token-123');
expect(res.set).toHaveBeenCalledWith("X-CSRF-Token", "mock-token-123");
});
it('should make token available in res.locals', () => {
it("should make token available in res.locals", () => {
generateCSRFToken(req, res, next);
expect(res.locals.csrfToken).toBe('mock-token-123');
expect(res.locals.csrfToken).toBe("mock-token-123");
});
it('should call next after setting up token', () => {
it("should call next after setting up token", () => {
generateCSRFToken(req, res, next);
expect(next).toHaveBeenCalled();
});
it('should handle test environment', () => {
process.env.NODE_ENV = 'test';
it("should handle test environment", () => {
process.env.NODE_ENV = "test";
generateCSRFToken(req, res, next);
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
expect(res.cookie).toHaveBeenCalledWith("csrf-token", "mock-token-123", {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 60 * 60 * 1000
secure: false,
sameSite: "strict",
maxAge: 60 * 60 * 1000,
});
});
it('should handle undefined NODE_ENV', () => {
it("should handle undefined NODE_ENV", () => {
delete process.env.NODE_ENV;
generateCSRFToken(req, res, next);
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
expect(res.cookie).toHaveBeenCalledWith("csrf-token", "mock-token-123", {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 60 * 60 * 1000
secure: false,
sameSite: "strict",
maxAge: 60 * 60 * 1000,
});
});
});
describe('getCSRFToken', () => {
it('should generate token and return it in response', () => {
process.env.NODE_ENV = 'production';
describe("getCSRFToken", () => {
it("should generate token and return it in response", () => {
process.env.NODE_ENV = "production";
getCSRFToken(req, res);
expect(mockTokensInstance.create).toHaveBeenCalledWith('mock-secret');
expect(mockTokensInstance.create).toHaveBeenCalledWith(
process.env.CSRF_SECRET,
);
expect(res.status).toHaveBeenCalledWith(204);
expect(res.send).toHaveBeenCalled();
});
it('should set token in cookie with proper options', () => {
process.env.NODE_ENV = 'production';
it("should set token in cookie with proper options", () => {
process.env.NODE_ENV = "production";
getCSRFToken(req, res);
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
expect(res.cookie).toHaveBeenCalledWith("csrf-token", "mock-token-123", {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 60 * 60 * 1000
sameSite: "strict",
maxAge: 60 * 60 * 1000,
});
});
it('should set secure flag to false in dev environment', () => {
process.env.NODE_ENV = 'dev';
it("should set secure flag to false in dev environment", () => {
process.env.NODE_ENV = "dev";
getCSRFToken(req, res);
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
expect(res.cookie).toHaveBeenCalledWith("csrf-token", "mock-token-123", {
httpOnly: true,
secure: false,
sameSite: 'strict',
maxAge: 60 * 60 * 1000
sameSite: "strict",
maxAge: 60 * 60 * 1000,
});
});
it('should set secure flag to true in production environment', () => {
process.env.NODE_ENV = 'production';
it("should set secure flag to true in production environment", () => {
process.env.NODE_ENV = "production";
getCSRFToken(req, res);
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
expect(res.cookie).toHaveBeenCalledWith("csrf-token", "mock-token-123", {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 60 * 60 * 1000
sameSite: "strict",
maxAge: 60 * 60 * 1000,
});
});
it('should handle test environment', () => {
process.env.NODE_ENV = 'test';
it("should handle test environment", () => {
process.env.NODE_ENV = "test";
getCSRFToken(req, res);
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
expect(res.cookie).toHaveBeenCalledWith("csrf-token", "mock-token-123", {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 60 * 60 * 1000
secure: false,
sameSite: "strict",
maxAge: 60 * 60 * 1000,
});
});
it('should generate new token each time', () => {
it("should generate new token each time", () => {
mockTokensInstance.create
.mockReturnValueOnce('token-1')
.mockReturnValueOnce('token-2');
.mockReturnValueOnce("token-1")
.mockReturnValueOnce("token-2");
getCSRFToken(req, res);
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'token-1', expect.any(Object));
expect(res.set).toHaveBeenCalledWith('X-CSRF-Token', 'token-1');
expect(res.cookie).toHaveBeenCalledWith(
"csrf-token",
"token-1",
expect.any(Object),
);
expect(res.set).toHaveBeenCalledWith("X-CSRF-Token", "token-1");
jest.clearAllMocks();
getCSRFToken(req, res);
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'token-2', expect.any(Object));
expect(res.set).toHaveBeenCalledWith('X-CSRF-Token', 'token-2');
expect(res.cookie).toHaveBeenCalledWith(
"csrf-token",
"token-2",
expect.any(Object),
);
expect(res.set).toHaveBeenCalledWith("X-CSRF-Token", "token-2");
});
});
describe('Integration scenarios', () => {
it('should handle complete CSRF flow', () => {
describe("Integration scenarios", () => {
it("should handle complete CSRF flow", () => {
// First, generate a token
generateCSRFToken(req, res, next);
const generatedToken = res.locals.csrfToken;
@@ -487,9 +514,9 @@ describe('CSRF Middleware', () => {
jest.clearAllMocks();
// Now test protection with the generated token
req.method = 'POST';
req.headers['x-csrf-token'] = generatedToken;
req.cookies = { 'csrf-token': generatedToken };
req.method = "POST";
req.headers["x-csrf-token"] = generatedToken;
req.cookies = { "csrf-token": generatedToken };
csrfProtection(req, res, next);
@@ -497,16 +524,16 @@ describe('CSRF Middleware', () => {
expect(res.status).not.toHaveBeenCalled();
});
it('should handle token generation endpoint flow', () => {
it("should handle token generation endpoint flow", () => {
getCSRFToken(req, res);
const cookieCall = res.cookie.mock.calls[0];
const headerCall = res.set.mock.calls[0];
expect(cookieCall[0]).toBe('csrf-token');
expect(cookieCall[1]).toBe('mock-token-123');
expect(headerCall[0]).toBe('X-CSRF-Token');
expect(headerCall[1]).toBe('mock-token-123');
expect(cookieCall[0]).toBe("csrf-token");
expect(cookieCall[1]).toBe("mock-token-123");
expect(headerCall[0]).toBe("X-CSRF-Token");
expect(headerCall[1]).toBe("mock-token-123");
expect(res.status).toHaveBeenCalledWith(204);
expect(res.send).toHaveBeenCalled();
});

View File

@@ -0,0 +1,355 @@
const { requireStepUpAuth } = require('../../../middleware/stepUpAuth');
// Mock TwoFactorService
jest.mock('../../../services/TwoFactorService', () => ({
validateStepUpSession: jest.fn(),
getRemainingRecoveryCodesCount: jest.fn()
}));
// Mock logger
jest.mock('../../../utils/logger', () => ({
info: jest.fn(),
error: jest.fn()
}));
const TwoFactorService = require('../../../services/TwoFactorService');
const logger = require('../../../utils/logger');
describe('stepUpAuth Middleware', () => {
let req, res, next;
beforeEach(() => {
req = {
user: {
id: 1,
email: 'test@test.com',
twoFactorEnabled: true,
twoFactorMethod: 'totp',
recoveryCodesHash: null
}
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};
next = jest.fn();
jest.clearAllMocks();
});
describe('requireStepUpAuth', () => {
describe('2FA Disabled Path', () => {
it('should call next() when user has 2FA disabled', async () => {
req.user.twoFactorEnabled = false;
const middleware = requireStepUpAuth('sensitive_action');
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(TwoFactorService.validateStepUpSession).not.toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should call next() when twoFactorEnabled is null/falsy', async () => {
req.user.twoFactorEnabled = null;
const middleware = requireStepUpAuth('sensitive_action');
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should call next() when twoFactorEnabled is undefined', async () => {
delete req.user.twoFactorEnabled;
const middleware = requireStepUpAuth('sensitive_action');
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
describe('Valid Step-Up Session', () => {
it('should call next() when validateStepUpSession returns true', async () => {
TwoFactorService.validateStepUpSession.mockReturnValue(true);
const middleware = requireStepUpAuth('password_change');
await middleware(req, res, next);
expect(TwoFactorService.validateStepUpSession).toHaveBeenCalledWith(req.user);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
expect(logger.info).not.toHaveBeenCalled();
});
it('should validate step-up session for different actions', async () => {
TwoFactorService.validateStepUpSession.mockReturnValue(true);
const actions = ['password_change', 'delete_account', 'change_email', 'export_data'];
for (const action of actions) {
jest.clearAllMocks();
const middleware = requireStepUpAuth(action);
await middleware(req, res, next);
expect(TwoFactorService.validateStepUpSession).toHaveBeenCalledWith(req.user);
expect(next).toHaveBeenCalled();
}
});
});
describe('Invalid/Expired Session', () => {
it('should return 403 with STEP_UP_REQUIRED code when session is invalid', async () => {
TwoFactorService.validateStepUpSession.mockReturnValue(false);
const middleware = requireStepUpAuth('password_change');
await middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
error: 'Multi-factor authentication required',
code: 'STEP_UP_REQUIRED',
action: 'password_change'
}));
expect(next).not.toHaveBeenCalled();
});
it('should include action name in response', async () => {
TwoFactorService.validateStepUpSession.mockReturnValue(false);
const actionName = 'delete_account';
const middleware = requireStepUpAuth(actionName);
await middleware(req, res, next);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
action: actionName
}));
});
it('should log step-up requirement with user ID and action', async () => {
TwoFactorService.validateStepUpSession.mockReturnValue(false);
const middleware = requireStepUpAuth('export_data');
await middleware(req, res, next);
expect(logger.info).toHaveBeenCalledWith(
`Step-up authentication required for user ${req.user.id}, action: export_data`
);
});
it('should include methods array in response', async () => {
TwoFactorService.validateStepUpSession.mockReturnValue(false);
const middleware = requireStepUpAuth('password_change');
await middleware(req, res, next);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
methods: expect.any(Array)
}));
});
});
describe('Available Methods Logic', () => {
beforeEach(() => {
TwoFactorService.validateStepUpSession.mockReturnValue(false);
});
it('should return totp and email for TOTP users', async () => {
req.user.twoFactorMethod = 'totp';
req.user.recoveryCodesHash = null;
const middleware = requireStepUpAuth('action');
await middleware(req, res, next);
const response = res.json.mock.calls[0][0];
expect(response.methods).toContain('totp');
expect(response.methods).toContain('email');
});
it('should return email only for email-2FA users', async () => {
req.user.twoFactorMethod = 'email';
req.user.recoveryCodesHash = null;
const middleware = requireStepUpAuth('action');
await middleware(req, res, next);
const response = res.json.mock.calls[0][0];
expect(response.methods).not.toContain('totp');
expect(response.methods).toContain('email');
});
it('should include recovery when recovery codes remain (count > 0)', async () => {
req.user.twoFactorMethod = 'totp';
req.user.recoveryCodesHash = JSON.stringify({ codes: ['code1', 'code2'] });
TwoFactorService.getRemainingRecoveryCodesCount.mockReturnValue(2);
const middleware = requireStepUpAuth('action');
await middleware(req, res, next);
const response = res.json.mock.calls[0][0];
expect(response.methods).toContain('recovery');
expect(TwoFactorService.getRemainingRecoveryCodesCount).toHaveBeenCalled();
});
it('should exclude recovery when all codes used (count = 0)', async () => {
req.user.twoFactorMethod = 'totp';
req.user.recoveryCodesHash = JSON.stringify({ codes: [] });
TwoFactorService.getRemainingRecoveryCodesCount.mockReturnValue(0);
const middleware = requireStepUpAuth('action');
await middleware(req, res, next);
const response = res.json.mock.calls[0][0];
expect(response.methods).not.toContain('recovery');
});
it('should exclude recovery when recoveryCodesHash is null', async () => {
req.user.twoFactorMethod = 'totp';
req.user.recoveryCodesHash = null;
const middleware = requireStepUpAuth('action');
await middleware(req, res, next);
const response = res.json.mock.calls[0][0];
expect(response.methods).not.toContain('recovery');
expect(TwoFactorService.getRemainingRecoveryCodesCount).not.toHaveBeenCalled();
});
it('should return correct methods for TOTP user with recovery codes', async () => {
req.user.twoFactorMethod = 'totp';
req.user.recoveryCodesHash = JSON.stringify({ codes: ['abc1-def2', 'ghi3-jkl4'] });
TwoFactorService.getRemainingRecoveryCodesCount.mockReturnValue(2);
const middleware = requireStepUpAuth('action');
await middleware(req, res, next);
const response = res.json.mock.calls[0][0];
expect(response.methods).toEqual(['totp', 'email', 'recovery']);
});
it('should return correct methods for email-2FA user with recovery codes', async () => {
req.user.twoFactorMethod = 'email';
req.user.recoveryCodesHash = JSON.stringify({ codes: ['abc1-def2'] });
TwoFactorService.getRemainingRecoveryCodesCount.mockReturnValue(1);
const middleware = requireStepUpAuth('action');
await middleware(req, res, next);
const response = res.json.mock.calls[0][0];
expect(response.methods).toEqual(['email', 'recovery']);
});
});
describe('Error Handling', () => {
it('should return 500 when TwoFactorService.validateStepUpSession throws', async () => {
TwoFactorService.validateStepUpSession.mockImplementation(() => {
throw new Error('Service error');
});
const middleware = requireStepUpAuth('action');
await middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
error: 'An error occurred during authentication'
});
expect(next).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(
'Step-up auth middleware error:',
expect.any(Error)
);
});
it('should return 500 when TwoFactorService.getRemainingRecoveryCodesCount throws', async () => {
TwoFactorService.validateStepUpSession.mockReturnValue(false);
req.user.recoveryCodesHash = JSON.stringify({ codes: [] });
TwoFactorService.getRemainingRecoveryCodesCount.mockImplementation(() => {
throw new Error('Service error');
});
const middleware = requireStepUpAuth('action');
await middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
error: 'An error occurred during authentication'
});
expect(logger.error).toHaveBeenCalled();
});
it('should handle malformed recoveryCodesHash JSON', async () => {
TwoFactorService.validateStepUpSession.mockReturnValue(false);
req.user.recoveryCodesHash = 'invalid json {{{';
const middleware = requireStepUpAuth('action');
await middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
error: 'An error occurred during authentication'
});
expect(logger.error).toHaveBeenCalled();
});
it('should log error details when exception occurs', async () => {
const testError = new Error('Test error message');
TwoFactorService.validateStepUpSession.mockImplementation(() => {
throw testError;
});
const middleware = requireStepUpAuth('action');
await middleware(req, res, next);
expect(logger.error).toHaveBeenCalledWith(
'Step-up auth middleware error:',
testError
);
});
});
describe('Edge Cases', () => {
it('should handle user with empty string twoFactorMethod', async () => {
TwoFactorService.validateStepUpSession.mockReturnValue(false);
req.user.twoFactorMethod = '';
const middleware = requireStepUpAuth('action');
await middleware(req, res, next);
const response = res.json.mock.calls[0][0];
expect(response.methods).toContain('email');
expect(response.methods).not.toContain('totp');
});
it('should handle action parameter as empty string', async () => {
TwoFactorService.validateStepUpSession.mockReturnValue(false);
const middleware = requireStepUpAuth('');
await middleware(req, res, next);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
action: ''
}));
});
it('should handle various user ID types', async () => {
TwoFactorService.validateStepUpSession.mockReturnValue(false);
// Test with string ID
req.user.id = 'user-uuid-123';
const middleware = requireStepUpAuth('action');
await middleware(req, res, next);
expect(logger.info).toHaveBeenCalledWith(
'Step-up authentication required for user user-uuid-123, action: action'
);
});
it('should be a factory function that returns middleware', () => {
const middleware = requireStepUpAuth('test_action');
expect(typeof middleware).toBe('function');
expect(middleware.length).toBe(3); // Should accept req, res, next
});
});
});
});

View File

@@ -13,7 +13,15 @@ jest.mock('express-validator', () => ({
trim: jest.fn().mockReturnThis(),
optional: jest.fn().mockReturnThis(),
isMobilePhone: jest.fn().mockReturnThis(),
notEmpty: jest.fn().mockReturnThis()
notEmpty: jest.fn().mockReturnThis(),
isFloat: jest.fn().mockReturnThis(),
toFloat: jest.fn().mockReturnThis()
})),
query: jest.fn(() => ({
optional: jest.fn().mockReturnThis(),
isFloat: jest.fn().mockReturnThis(),
withMessage: jest.fn().mockReturnThis(),
toFloat: jest.fn().mockReturnThis()
})),
validationResult: jest.fn()
}));
@@ -38,7 +46,16 @@ const {
validateLogin,
validateGoogleAuth,
validateProfileUpdate,
validatePasswordChange
validatePasswordChange,
validateForgotPassword,
validateResetPassword,
validateVerifyResetToken,
validateFeedback,
validateCoordinatesQuery,
validateCoordinatesBody,
validateTotpCode,
validateEmailOtp,
validateRecoveryCode
} = require('../../../middleware/validation');
describe('Validation Middleware', () => {
@@ -2058,4 +2075,392 @@ describe('Validation Middleware', () => {
});
});
describe('Two-Factor Authentication Validation', () => {
describe('validateTotpCode', () => {
it('should be an array ending with handleValidationErrors', () => {
expect(Array.isArray(validateTotpCode)).toBe(true);
expect(validateTotpCode.length).toBeGreaterThan(1);
expect(validateTotpCode[validateTotpCode.length - 1]).toBe(handleValidationErrors);
});
it('should validate 6-digit numeric format', () => {
const validCodes = ['123456', '000000', '999999', '012345'];
const invalidCodes = ['12345', '1234567', 'abcdef', '12345a', '12 345', ''];
const totpRegex = /^\d{6}$/;
validCodes.forEach(code => {
expect(totpRegex.test(code)).toBe(true);
});
invalidCodes.forEach(code => {
expect(totpRegex.test(code)).toBe(false);
});
});
it('should have at least 2 middleware functions', () => {
expect(validateTotpCode.length).toBeGreaterThanOrEqual(2);
});
});
describe('validateEmailOtp', () => {
it('should be an array ending with handleValidationErrors', () => {
expect(Array.isArray(validateEmailOtp)).toBe(true);
expect(validateEmailOtp.length).toBeGreaterThan(1);
expect(validateEmailOtp[validateEmailOtp.length - 1]).toBe(handleValidationErrors);
});
it('should validate 6-digit numeric format', () => {
const validCodes = ['123456', '000000', '999999', '654321'];
const invalidCodes = ['12345', '1234567', 'abcdef', '12345a', '', ' '];
const otpRegex = /^\d{6}$/;
validCodes.forEach(code => {
expect(otpRegex.test(code)).toBe(true);
});
invalidCodes.forEach(code => {
expect(otpRegex.test(code)).toBe(false);
});
});
it('should have at least 2 middleware functions', () => {
expect(validateEmailOtp.length).toBeGreaterThanOrEqual(2);
});
});
describe('validateRecoveryCode', () => {
it('should be an array ending with handleValidationErrors', () => {
expect(Array.isArray(validateRecoveryCode)).toBe(true);
expect(validateRecoveryCode.length).toBeGreaterThan(1);
expect(validateRecoveryCode[validateRecoveryCode.length - 1]).toBe(handleValidationErrors);
});
it('should validate XXXX-XXXX format', () => {
const validCodes = ['ABCD-1234', 'abcd-efgh', '1234-5678', 'A1B2-C3D4', 'aaaa-bbbb'];
const invalidCodes = ['ABCD1234', 'ABCD-12345', 'ABC-1234', 'ABCD-123', '', 'ABCD--1234', 'ABCD_1234'];
const recoveryRegex = /^[A-Za-z0-9]{4}-[A-Za-z0-9]{4}$/i;
validCodes.forEach(code => {
expect(recoveryRegex.test(code)).toBe(true);
});
invalidCodes.forEach(code => {
expect(recoveryRegex.test(code)).toBe(false);
});
});
it('should have at least 2 middleware functions', () => {
expect(validateRecoveryCode.length).toBeGreaterThanOrEqual(2);
});
});
});
describe('Password Reset Validation', () => {
describe('validateForgotPassword', () => {
it('should be an array ending with handleValidationErrors', () => {
expect(Array.isArray(validateForgotPassword)).toBe(true);
expect(validateForgotPassword.length).toBeGreaterThan(1);
expect(validateForgotPassword[validateForgotPassword.length - 1]).toBe(handleValidationErrors);
});
it('should validate email format', () => {
const validEmails = ['user@example.com', 'test.user@domain.co.uk', 'email@test.org'];
const invalidEmails = ['invalid-email', '@domain.com', 'user@', 'user.domain.com'];
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
validEmails.forEach(email => {
expect(emailRegex.test(email)).toBe(true);
});
invalidEmails.forEach(email => {
expect(emailRegex.test(email)).toBe(false);
});
});
it('should enforce email length limits', () => {
const longEmail = 'a'.repeat(250) + '@example.com';
expect(longEmail.length).toBeGreaterThan(255);
const validEmail = 'user@example.com';
expect(validEmail.length).toBeLessThanOrEqual(255);
});
});
describe('validateResetPassword', () => {
it('should be an array ending with handleValidationErrors', () => {
expect(Array.isArray(validateResetPassword)).toBe(true);
expect(validateResetPassword.length).toBeGreaterThan(1);
expect(validateResetPassword[validateResetPassword.length - 1]).toBe(handleValidationErrors);
});
it('should validate 64-character token format', () => {
const valid64CharToken = 'a'.repeat(64);
const shortToken = 'a'.repeat(63);
const longToken = 'a'.repeat(65);
expect(valid64CharToken.length).toBe(64);
expect(shortToken.length).toBe(63);
expect(longToken.length).toBe(65);
});
it('should validate password strength requirements', () => {
const passwordRegex = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z])(?=.*[-@$!%*?&#^]).{8,}$/;
const strongPasswords = ['Password123!', 'MyStr0ng@Pass', 'Secure1#Test'];
const weakPasswords = ['password', 'PASSWORD123', 'Password', '12345678'];
strongPasswords.forEach(password => {
expect(passwordRegex.test(password)).toBe(true);
});
weakPasswords.forEach(password => {
expect(passwordRegex.test(password)).toBe(false);
});
});
it('should have multiple middleware functions for token and password', () => {
expect(validateResetPassword.length).toBeGreaterThanOrEqual(3);
});
});
describe('validateVerifyResetToken', () => {
it('should be an array ending with handleValidationErrors', () => {
expect(Array.isArray(validateVerifyResetToken)).toBe(true);
expect(validateVerifyResetToken.length).toBeGreaterThan(1);
expect(validateVerifyResetToken[validateVerifyResetToken.length - 1]).toBe(handleValidationErrors);
});
it('should validate 64-character token format', () => {
const valid64CharToken = 'abcdef1234567890'.repeat(4);
expect(valid64CharToken.length).toBe(64);
const shortToken = 'abc123'.repeat(10);
expect(shortToken.length).toBe(60);
const longToken = 'a'.repeat(65);
expect(longToken.length).toBe(65);
});
it('should have at least 2 middleware functions', () => {
expect(validateVerifyResetToken.length).toBeGreaterThanOrEqual(2);
});
});
});
describe('Feedback Validation', () => {
describe('validateFeedback', () => {
it('should be an array ending with handleValidationErrors', () => {
expect(Array.isArray(validateFeedback)).toBe(true);
expect(validateFeedback.length).toBeGreaterThan(1);
expect(validateFeedback[validateFeedback.length - 1]).toBe(handleValidationErrors);
});
it('should validate text length (5-5000 chars)', () => {
const tooShort = 'abcd'; // 4 chars
const minValid = 'abcde'; // 5 chars
const maxValid = 'a'.repeat(5000);
const tooLong = 'a'.repeat(5001);
expect(tooShort.length).toBe(4);
expect(minValid.length).toBe(5);
expect(maxValid.length).toBe(5000);
expect(tooLong.length).toBe(5001);
// Validate boundaries
expect(tooShort.length).toBeLessThan(5);
expect(minValid.length).toBeGreaterThanOrEqual(5);
expect(maxValid.length).toBeLessThanOrEqual(5000);
expect(tooLong.length).toBeGreaterThan(5000);
});
it('should have at least 2 middleware functions', () => {
expect(validateFeedback.length).toBeGreaterThanOrEqual(2);
});
it('should include optional URL validation', () => {
// The feedback validation should include url field as optional
expect(validateFeedback.length).toBeGreaterThanOrEqual(2);
});
});
});
describe('Coordinates Validation', () => {
describe('validateCoordinatesQuery', () => {
it('should be an array ending with handleValidationErrors', () => {
expect(Array.isArray(validateCoordinatesQuery)).toBe(true);
expect(validateCoordinatesQuery.length).toBeGreaterThan(1);
expect(validateCoordinatesQuery[validateCoordinatesQuery.length - 1]).toBe(handleValidationErrors);
});
it('should validate latitude range (-90 to 90)', () => {
const validLatitudes = [0, 45, -45, 90, -90, 37.7749];
const invalidLatitudes = [91, -91, 180, -180, 1000];
validLatitudes.forEach(lat => {
expect(lat).toBeGreaterThanOrEqual(-90);
expect(lat).toBeLessThanOrEqual(90);
});
invalidLatitudes.forEach(lat => {
expect(lat < -90 || lat > 90).toBe(true);
});
});
it('should validate longitude range (-180 to 180)', () => {
const validLongitudes = [0, 90, -90, 180, -180, -122.4194];
const invalidLongitudes = [181, -181, 360, -360];
validLongitudes.forEach(lng => {
expect(lng).toBeGreaterThanOrEqual(-180);
expect(lng).toBeLessThanOrEqual(180);
});
invalidLongitudes.forEach(lng => {
expect(lng < -180 || lng > 180).toBe(true);
});
});
it('should validate radius range (0.1 to 100)', () => {
const validRadii = [0.1, 1, 50, 100, 0.5, 99.9];
const invalidRadii = [0, 0.05, 100.1, 200, -1];
validRadii.forEach(radius => {
expect(radius).toBeGreaterThanOrEqual(0.1);
expect(radius).toBeLessThanOrEqual(100);
});
invalidRadii.forEach(radius => {
expect(radius < 0.1 || radius > 100).toBe(true);
});
});
it('should have middleware for lat, lng, and radius', () => {
expect(validateCoordinatesQuery.length).toBeGreaterThanOrEqual(4);
});
});
describe('validateCoordinatesBody', () => {
it('should be an array with validation middleware', () => {
expect(Array.isArray(validateCoordinatesBody)).toBe(true);
expect(validateCoordinatesBody.length).toBeGreaterThan(0);
});
it('should validate body latitude range (-90 to 90)', () => {
const validLatitudes = [0, 45.5, -89.99, 90, -90];
const invalidLatitudes = [90.1, -90.1, 100, -100];
validLatitudes.forEach(lat => {
expect(lat).toBeGreaterThanOrEqual(-90);
expect(lat).toBeLessThanOrEqual(90);
});
invalidLatitudes.forEach(lat => {
expect(lat < -90 || lat > 90).toBe(true);
});
});
it('should validate body longitude range (-180 to 180)', () => {
const validLongitudes = [0, 179.99, -179.99, 180, -180];
const invalidLongitudes = [180.1, -180.1, 200, -200];
validLongitudes.forEach(lng => {
expect(lng).toBeGreaterThanOrEqual(-180);
expect(lng).toBeLessThanOrEqual(180);
});
invalidLongitudes.forEach(lng => {
expect(lng < -180 || lng > 180).toBe(true);
});
});
it('should have middleware for latitude and longitude', () => {
expect(validateCoordinatesBody.length).toBeGreaterThanOrEqual(2);
});
});
});
describe('Module Exports Completeness', () => {
it('should export all validators from the module', () => {
const validationModule = require('../../../middleware/validation');
// Core middleware
expect(validationModule).toHaveProperty('sanitizeInput');
expect(validationModule).toHaveProperty('handleValidationErrors');
// Auth validators
expect(validationModule).toHaveProperty('validateRegistration');
expect(validationModule).toHaveProperty('validateLogin');
expect(validationModule).toHaveProperty('validateGoogleAuth');
// Profile validators
expect(validationModule).toHaveProperty('validateProfileUpdate');
expect(validationModule).toHaveProperty('validatePasswordChange');
// Password reset validators
expect(validationModule).toHaveProperty('validateForgotPassword');
expect(validationModule).toHaveProperty('validateResetPassword');
expect(validationModule).toHaveProperty('validateVerifyResetToken');
// Feedback validator
expect(validationModule).toHaveProperty('validateFeedback');
// Coordinate validators
expect(validationModule).toHaveProperty('validateCoordinatesQuery');
expect(validationModule).toHaveProperty('validateCoordinatesBody');
// 2FA validators
expect(validationModule).toHaveProperty('validateTotpCode');
expect(validationModule).toHaveProperty('validateEmailOtp');
expect(validationModule).toHaveProperty('validateRecoveryCode');
});
it('should export functions and arrays with correct types', () => {
const validationModule = require('../../../middleware/validation');
// Functions
expect(typeof validationModule.sanitizeInput).toBe('function');
expect(typeof validationModule.handleValidationErrors).toBe('function');
// Arrays (validation chains)
expect(Array.isArray(validationModule.validateRegistration)).toBe(true);
expect(Array.isArray(validationModule.validateLogin)).toBe(true);
expect(Array.isArray(validationModule.validateGoogleAuth)).toBe(true);
expect(Array.isArray(validationModule.validateProfileUpdate)).toBe(true);
expect(Array.isArray(validationModule.validatePasswordChange)).toBe(true);
expect(Array.isArray(validationModule.validateForgotPassword)).toBe(true);
expect(Array.isArray(validationModule.validateResetPassword)).toBe(true);
expect(Array.isArray(validationModule.validateVerifyResetToken)).toBe(true);
expect(Array.isArray(validationModule.validateFeedback)).toBe(true);
expect(Array.isArray(validationModule.validateCoordinatesQuery)).toBe(true);
expect(Array.isArray(validationModule.validateCoordinatesBody)).toBe(true);
expect(Array.isArray(validationModule.validateTotpCode)).toBe(true);
expect(Array.isArray(validationModule.validateEmailOtp)).toBe(true);
expect(Array.isArray(validationModule.validateRecoveryCode)).toBe(true);
});
it('should have all validation arrays end with handleValidationErrors', () => {
const validationModule = require('../../../middleware/validation');
const validatorsWithHandler = [
'validateRegistration',
'validateLogin',
'validateGoogleAuth',
'validateProfileUpdate',
'validatePasswordChange',
'validateForgotPassword',
'validateResetPassword',
'validateVerifyResetToken',
'validateFeedback',
'validateCoordinatesQuery',
'validateTotpCode',
'validateEmailOtp',
'validateRecoveryCode'
];
validatorsWithHandler.forEach(validatorName => {
const validator = validationModule[validatorName];
expect(validator[validator.length - 1]).toBe(validationModule.handleValidationErrors);
});
});
});
});

View File

@@ -3,6 +3,27 @@ const crypto = require('crypto');
// Mock crypto module
jest.mock('crypto');
// Mock the logger to prevent winston-daily-rotate-file issues
jest.mock('../../../utils/logger', () => ({
error: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
withRequestId: jest.fn(() => ({
error: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
})),
}));
// Mock TwoFactorService to prevent otplib loading
jest.mock('../../../services/TwoFactorService', () => ({
generateSecret: jest.fn(),
verifyToken: jest.fn(),
generateQRCode: jest.fn(),
}));
// Mock the entire models module
jest.mock('../../../models', () => {
const mockUser = {

View File

@@ -0,0 +1,244 @@
const request = require('supertest');
const express = require('express');
// Mock dependencies
jest.mock('../../../models', () => ({
Feedback: {
create: jest.fn(),
},
User: {
findByPk: jest.fn(),
},
}));
jest.mock('../../../middleware/auth', () => ({
authenticateToken: (req, res, next) => {
req.user = { id: 'user-123', email: 'test@example.com' };
next();
},
}));
jest.mock('../../../middleware/validation', () => ({
validateFeedback: (req, res, next) => next(),
sanitizeInput: (req, res, next) => next(),
}));
jest.mock('../../../services/email', () => ({
feedback: {
sendFeedbackConfirmation: jest.fn(),
sendFeedbackNotificationToAdmin: jest.fn(),
},
}));
jest.mock('../../../utils/logger', () => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
withRequestId: jest.fn(() => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
})),
}));
const { Feedback } = require('../../../models');
const emailServices = require('../../../services/email');
const feedbackRoutes = require('../../../routes/feedback');
describe('Feedback Routes', () => {
let app;
beforeEach(() => {
app = express();
app.use(express.json());
app.use('/feedback', feedbackRoutes);
// Add error handler
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});
jest.clearAllMocks();
});
describe('POST /feedback', () => {
it('should create feedback successfully', async () => {
const mockFeedback = {
id: 'feedback-123',
userId: 'user-123',
feedbackText: 'Great app!',
url: 'https://example.com/page',
userAgent: 'Mozilla/5.0',
};
Feedback.create.mockResolvedValue(mockFeedback);
emailServices.feedback.sendFeedbackConfirmation.mockResolvedValue();
emailServices.feedback.sendFeedbackNotificationToAdmin.mockResolvedValue();
const response = await request(app)
.post('/feedback')
.set('User-Agent', 'Mozilla/5.0')
.send({
feedbackText: 'Great app!',
url: 'https://example.com/page',
});
expect(response.status).toBe(201);
expect(response.body.id).toBe('feedback-123');
expect(response.body.feedbackText).toBe('Great app!');
expect(Feedback.create).toHaveBeenCalledWith({
userId: 'user-123',
feedbackText: 'Great app!',
url: 'https://example.com/page',
userAgent: 'Mozilla/5.0',
});
});
it('should send confirmation email to user', async () => {
const mockFeedback = {
id: 'feedback-123',
feedbackText: 'Great app!',
};
Feedback.create.mockResolvedValue(mockFeedback);
emailServices.feedback.sendFeedbackConfirmation.mockResolvedValue();
emailServices.feedback.sendFeedbackNotificationToAdmin.mockResolvedValue();
await request(app)
.post('/feedback')
.send({ feedbackText: 'Great app!' });
expect(emailServices.feedback.sendFeedbackConfirmation).toHaveBeenCalledWith(
{ id: 'user-123', email: 'test@example.com' },
mockFeedback
);
});
it('should send notification email to admin', async () => {
const mockFeedback = {
id: 'feedback-123',
feedbackText: 'Great app!',
};
Feedback.create.mockResolvedValue(mockFeedback);
emailServices.feedback.sendFeedbackConfirmation.mockResolvedValue();
emailServices.feedback.sendFeedbackNotificationToAdmin.mockResolvedValue();
await request(app)
.post('/feedback')
.send({ feedbackText: 'Great app!' });
expect(emailServices.feedback.sendFeedbackNotificationToAdmin).toHaveBeenCalledWith(
{ id: 'user-123', email: 'test@example.com' },
mockFeedback
);
});
it('should succeed even if confirmation email fails', async () => {
const mockFeedback = {
id: 'feedback-123',
feedbackText: 'Great app!',
};
Feedback.create.mockResolvedValue(mockFeedback);
emailServices.feedback.sendFeedbackConfirmation.mockRejectedValue(new Error('Email failed'));
emailServices.feedback.sendFeedbackNotificationToAdmin.mockResolvedValue();
const response = await request(app)
.post('/feedback')
.send({ feedbackText: 'Great app!' });
expect(response.status).toBe(201);
});
it('should succeed even if admin notification email fails', async () => {
const mockFeedback = {
id: 'feedback-123',
feedbackText: 'Great app!',
};
Feedback.create.mockResolvedValue(mockFeedback);
emailServices.feedback.sendFeedbackConfirmation.mockResolvedValue();
emailServices.feedback.sendFeedbackNotificationToAdmin.mockRejectedValue(new Error('Email failed'));
const response = await request(app)
.post('/feedback')
.send({ feedbackText: 'Great app!' });
expect(response.status).toBe(201);
});
it('should handle feedback with null url', async () => {
const mockFeedback = {
id: 'feedback-123',
feedbackText: 'Great app!',
url: null,
};
Feedback.create.mockResolvedValue(mockFeedback);
emailServices.feedback.sendFeedbackConfirmation.mockResolvedValue();
emailServices.feedback.sendFeedbackNotificationToAdmin.mockResolvedValue();
const response = await request(app)
.post('/feedback')
.send({ feedbackText: 'Great app!' });
expect(response.status).toBe(201);
expect(Feedback.create).toHaveBeenCalledWith(
expect.objectContaining({
url: null,
})
);
});
it('should capture user agent from headers', async () => {
const mockFeedback = {
id: 'feedback-123',
feedbackText: 'Great app!',
userAgent: 'CustomUserAgent/1.0',
};
Feedback.create.mockResolvedValue(mockFeedback);
emailServices.feedback.sendFeedbackConfirmation.mockResolvedValue();
emailServices.feedback.sendFeedbackNotificationToAdmin.mockResolvedValue();
await request(app)
.post('/feedback')
.set('User-Agent', 'CustomUserAgent/1.0')
.send({ feedbackText: 'Great app!' });
expect(Feedback.create).toHaveBeenCalledWith(
expect.objectContaining({
userAgent: 'CustomUserAgent/1.0',
})
);
});
it('should handle missing user agent', async () => {
const mockFeedback = {
id: 'feedback-123',
feedbackText: 'Great app!',
};
Feedback.create.mockResolvedValue(mockFeedback);
emailServices.feedback.sendFeedbackConfirmation.mockResolvedValue();
emailServices.feedback.sendFeedbackNotificationToAdmin.mockResolvedValue();
const response = await request(app)
.post('/feedback')
.send({ feedbackText: 'Great app!' });
expect(response.status).toBe(201);
});
it('should return 500 when database error occurs', async () => {
Feedback.create.mockRejectedValue(new Error('Database error'));
const response = await request(app)
.post('/feedback')
.send({ feedbackText: 'Great app!' });
expect(response.status).toBe(500);
});
});
});

View File

@@ -46,18 +46,18 @@ jest.mock('sequelize', () => ({
}));
jest.mock('../../../middleware/auth', () => ({
authenticateToken: (req, res, next) => {
authenticateToken: jest.fn((req, res, next) => {
req.user = { id: 'user-123', role: 'user', isVerified: true };
next();
},
requireAdmin: (req, res, next) => {
}),
requireAdmin: jest.fn((req, res, next) => {
if (req.user && req.user.role === 'admin') {
next();
} else {
res.status(403).json({ error: 'Admin access required' });
}
},
optionalAuth: (req, res, next) => next(),
}),
optionalAuth: jest.fn((req, res, next) => next()),
}));
jest.mock('../../../utils/logger', () => ({
@@ -97,6 +97,7 @@ jest.mock('../../../config/imageLimits', () => ({
}));
const { ForumPost, ForumComment, PostTag, User } = require('../../../models');
const { authenticateToken } = require('../../../middleware/auth');
const forumRoutes = require('../../../routes/forum');
const app = express();
@@ -810,4 +811,616 @@ describe('Forum Routes', () => {
expect(response.status).toBe(403);
});
});
describe('PATCH /forum/posts/:id/accept-answer', () => {
it('should mark comment as accepted answer', async () => {
const mockPost = {
id: 'post-1',
authorId: 'user-123',
status: 'open',
update: jest.fn().mockResolvedValue(),
toJSON: function() { return this; },
};
const mockComment = {
id: 'comment-1',
postId: 'post-1',
authorId: 'other-user',
parentCommentId: null,
isDeleted: false,
};
ForumPost.findByPk
.mockResolvedValueOnce(mockPost) // First call to check post
.mockResolvedValueOnce({ ...mockPost, toJSON: () => mockPost }); // Second call for response
ForumComment.findByPk.mockResolvedValue(mockComment);
const response = await request(app)
.patch('/forum/posts/post-1/accept-answer')
.send({ commentId: 'comment-1' });
expect(response.status).toBe(200);
expect(mockPost.update).toHaveBeenCalledWith(expect.objectContaining({
acceptedAnswerId: 'comment-1',
status: 'closed',
}));
});
it('should unmark accepted answer when no commentId provided', async () => {
const mockPost = {
id: 'post-1',
authorId: 'user-123',
acceptedAnswerId: 'comment-1',
status: 'closed',
update: jest.fn().mockResolvedValue(),
toJSON: function() { return this; },
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.patch('/forum/posts/post-1/accept-answer')
.send({});
expect(response.status).toBe(200);
expect(mockPost.update).toHaveBeenCalledWith(expect.objectContaining({
acceptedAnswerId: null,
status: 'open',
}));
});
it('should return 404 for non-existent post', async () => {
ForumPost.findByPk.mockResolvedValue(null);
const response = await request(app)
.patch('/forum/posts/non-existent/accept-answer')
.send({ commentId: 'comment-1' });
expect(response.status).toBe(404);
});
it('should return 403 when non-author tries to mark answer', async () => {
const mockPost = {
id: 'post-1',
authorId: 'other-user',
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.patch('/forum/posts/post-1/accept-answer')
.send({ commentId: 'comment-1' });
expect(response.status).toBe(403);
expect(response.body.error).toContain('author');
});
it('should return 404 for non-existent comment', async () => {
const mockPost = {
id: 'post-1',
authorId: 'user-123',
};
ForumPost.findByPk.mockResolvedValue(mockPost);
ForumComment.findByPk.mockResolvedValue(null);
const response = await request(app)
.patch('/forum/posts/post-1/accept-answer')
.send({ commentId: 'non-existent' });
expect(response.status).toBe(404);
});
it('should return 400 when comment belongs to different post', async () => {
const mockPost = {
id: 'post-1',
authorId: 'user-123',
};
const mockComment = {
id: 'comment-1',
postId: 'other-post',
isDeleted: false,
};
ForumPost.findByPk.mockResolvedValue(mockPost);
ForumComment.findByPk.mockResolvedValue(mockComment);
const response = await request(app)
.patch('/forum/posts/post-1/accept-answer')
.send({ commentId: 'comment-1' });
expect(response.status).toBe(400);
expect(response.body.error).toContain('does not belong');
});
it('should return 400 when marking deleted comment as answer', async () => {
const mockPost = {
id: 'post-1',
authorId: 'user-123',
};
const mockComment = {
id: 'comment-1',
postId: 'post-1',
isDeleted: true,
};
ForumPost.findByPk.mockResolvedValue(mockPost);
ForumComment.findByPk.mockResolvedValue(mockComment);
const response = await request(app)
.patch('/forum/posts/post-1/accept-answer')
.send({ commentId: 'comment-1' });
expect(response.status).toBe(400);
expect(response.body.error).toContain('deleted');
});
it('should return 400 when marking reply as answer', async () => {
const mockPost = {
id: 'post-1',
authorId: 'user-123',
};
const mockComment = {
id: 'comment-1',
postId: 'post-1',
parentCommentId: 'parent-comment',
isDeleted: false,
};
ForumPost.findByPk.mockResolvedValue(mockPost);
ForumComment.findByPk.mockResolvedValue(mockComment);
const response = await request(app)
.patch('/forum/posts/post-1/accept-answer')
.send({ commentId: 'comment-1' });
expect(response.status).toBe(400);
expect(response.body.error).toContain('top-level');
});
});
describe('PUT /forum/comments/:id', () => {
it('should return 400 when editing deleted comment', async () => {
const mockComment = {
id: 'comment-1',
authorId: 'user-123',
isDeleted: true,
};
ForumComment.findByPk.mockResolvedValue(mockComment);
const response = await request(app)
.put('/forum/comments/comment-1')
.send({ content: 'Updated' });
expect(response.status).toBe(400);
expect(response.body.error).toContain('deleted');
});
});
describe('GET /forum/posts - Additional Filters', () => {
it('should filter posts by tag', async () => {
ForumPost.findAndCountAll.mockResolvedValue({
count: 0,
rows: [],
});
const response = await request(app)
.get('/forum/posts')
.query({ tag: 'javascript' });
expect(response.status).toBe(200);
expect(ForumPost.findAndCountAll).toHaveBeenCalled();
});
it('should filter posts by status', async () => {
ForumPost.findAndCountAll.mockResolvedValue({
count: 0,
rows: [],
});
const response = await request(app)
.get('/forum/posts')
.query({ status: 'open' });
expect(response.status).toBe(200);
expect(ForumPost.findAndCountAll).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
status: 'open',
}),
})
);
});
it('should sort posts by views', async () => {
ForumPost.findAndCountAll.mockResolvedValue({
count: 0,
rows: [],
});
const response = await request(app)
.get('/forum/posts')
.query({ sort: 'views' });
expect(response.status).toBe(200);
expect(ForumPost.findAndCountAll).toHaveBeenCalledWith(
expect.objectContaining({
order: expect.arrayContaining([
['viewCount', 'DESC'],
]),
})
);
});
});
describe('GET /forum/tags - Search', () => {
it('should search tags by name', async () => {
PostTag.findAll.mockResolvedValue([
{ tagName: 'javascript', count: 10 },
]);
const response = await request(app)
.get('/forum/tags')
.query({ search: 'java' });
expect(response.status).toBe(200);
expect(PostTag.findAll).toHaveBeenCalled();
});
});
});
describe('Forum Admin Routes', () => {
beforeEach(() => {
jest.clearAllMocks();
// Override auth mock to use admin user
authenticateToken.mockImplementation((req, res, next) => {
req.user = { id: 'admin-123', role: 'admin', isVerified: true };
next();
});
});
afterEach(() => {
// Reset back to regular user for other test suites
authenticateToken.mockImplementation((req, res, next) => {
req.user = { id: 'user-123', role: 'user', isVerified: true };
next();
});
});
describe('DELETE /forum/admin/posts/:id', () => {
it('should soft delete post with reason', async () => {
const mockPost = {
id: 'post-1',
authorId: 'user-123',
isDeleted: false,
update: jest.fn().mockResolvedValue(),
author: { id: 'user-123', firstName: 'John', email: 'john@example.com' },
};
ForumPost.findByPk.mockResolvedValue(mockPost);
User.findByPk.mockResolvedValue({ id: 'admin-123', firstName: 'Admin' });
const response = await request(app)
.delete('/forum/admin/posts/post-1')
.send({ reason: 'Violates community guidelines' });
expect(response.status).toBe(200);
expect(mockPost.update).toHaveBeenCalledWith(expect.objectContaining({
isDeleted: true,
deletedBy: 'admin-123',
deletionReason: 'Violates community guidelines',
}));
});
it('should return 400 when reason not provided', async () => {
const response = await request(app)
.delete('/forum/admin/posts/post-1')
.send({});
expect(response.status).toBe(400);
expect(response.body.error).toContain('reason');
});
it('should return 404 for non-existent post', async () => {
ForumPost.findByPk.mockResolvedValue(null);
const response = await request(app)
.delete('/forum/admin/posts/non-existent')
.send({ reason: 'Test reason' });
expect(response.status).toBe(404);
});
it('should return 400 when post already deleted', async () => {
const mockPost = {
id: 'post-1',
isDeleted: true,
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.delete('/forum/admin/posts/post-1')
.send({ reason: 'Test reason' });
expect(response.status).toBe(400);
expect(response.body.error).toContain('already deleted');
});
});
describe('PATCH /forum/admin/posts/:id/restore', () => {
it('should restore deleted post', async () => {
const mockPost = {
id: 'post-1',
authorId: 'user-123',
isDeleted: true,
update: jest.fn().mockResolvedValue(),
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.patch('/forum/admin/posts/post-1/restore');
expect(response.status).toBe(200);
expect(mockPost.update).toHaveBeenCalledWith({
isDeleted: false,
deletedBy: null,
deletedAt: null,
deletionReason: null,
});
});
it('should return 404 for non-existent post', async () => {
ForumPost.findByPk.mockResolvedValue(null);
const response = await request(app)
.patch('/forum/admin/posts/non-existent/restore');
expect(response.status).toBe(404);
});
it('should return 400 when post not deleted', async () => {
const mockPost = {
id: 'post-1',
isDeleted: false,
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.patch('/forum/admin/posts/post-1/restore');
expect(response.status).toBe(400);
expect(response.body.error).toContain('not deleted');
});
});
describe('DELETE /forum/admin/comments/:id', () => {
it('should soft delete comment with reason', async () => {
const mockComment = {
id: 'comment-1',
authorId: 'user-123',
postId: 'post-1',
isDeleted: false,
update: jest.fn().mockResolvedValue(),
author: { id: 'user-123', firstName: 'John', email: 'john@example.com' },
};
const mockPost = {
id: 'post-1',
title: 'Test Post',
commentCount: 5,
decrement: jest.fn().mockResolvedValue(),
};
ForumComment.findByPk.mockResolvedValue(mockComment);
ForumPost.findByPk.mockResolvedValue(mockPost);
User.findByPk.mockResolvedValue({ id: 'admin-123', firstName: 'Admin' });
const response = await request(app)
.delete('/forum/admin/comments/comment-1')
.send({ reason: 'Inappropriate content' });
expect(response.status).toBe(200);
expect(mockComment.update).toHaveBeenCalledWith(expect.objectContaining({
isDeleted: true,
deletedBy: 'admin-123',
deletionReason: 'Inappropriate content',
}));
expect(mockPost.decrement).toHaveBeenCalledWith('commentCount');
});
it('should return 400 when reason not provided', async () => {
const response = await request(app)
.delete('/forum/admin/comments/comment-1')
.send({});
expect(response.status).toBe(400);
expect(response.body.error).toContain('reason');
});
it('should return 404 for non-existent comment', async () => {
ForumComment.findByPk.mockResolvedValue(null);
const response = await request(app)
.delete('/forum/admin/comments/non-existent')
.send({ reason: 'Test reason' });
expect(response.status).toBe(404);
});
it('should return 400 when comment already deleted', async () => {
const mockComment = {
id: 'comment-1',
isDeleted: true,
};
ForumComment.findByPk.mockResolvedValue(mockComment);
const response = await request(app)
.delete('/forum/admin/comments/comment-1')
.send({ reason: 'Test reason' });
expect(response.status).toBe(400);
expect(response.body.error).toContain('already deleted');
});
});
describe('PATCH /forum/admin/comments/:id/restore', () => {
it('should restore deleted comment', async () => {
const mockComment = {
id: 'comment-1',
authorId: 'user-123',
postId: 'post-1',
isDeleted: true,
update: jest.fn().mockResolvedValue(),
};
const mockPost = {
id: 'post-1',
increment: jest.fn().mockResolvedValue(),
};
ForumComment.findByPk.mockResolvedValue(mockComment);
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.patch('/forum/admin/comments/comment-1/restore');
expect(response.status).toBe(200);
expect(mockComment.update).toHaveBeenCalledWith({
isDeleted: false,
deletedBy: null,
deletedAt: null,
deletionReason: null,
});
expect(mockPost.increment).toHaveBeenCalledWith('commentCount');
});
it('should return 404 for non-existent comment', async () => {
ForumComment.findByPk.mockResolvedValue(null);
const response = await request(app)
.patch('/forum/admin/comments/non-existent/restore');
expect(response.status).toBe(404);
});
it('should return 400 when comment not deleted', async () => {
const mockComment = {
id: 'comment-1',
isDeleted: false,
};
ForumComment.findByPk.mockResolvedValue(mockComment);
const response = await request(app)
.patch('/forum/admin/comments/comment-1/restore');
expect(response.status).toBe(400);
expect(response.body.error).toContain('not deleted');
});
});
describe('PATCH /forum/admin/posts/:id/close', () => {
it('should close post discussion', async () => {
const mockPost = {
id: 'post-1',
authorId: 'user-123',
status: 'open',
update: jest.fn().mockResolvedValue(),
author: { id: 'user-123', firstName: 'John', email: 'john@example.com' },
};
ForumPost.findByPk.mockResolvedValue(mockPost);
ForumComment.findAll.mockResolvedValue([]);
User.findByPk.mockResolvedValue({ id: 'admin-123', firstName: 'Admin' });
const response = await request(app)
.patch('/forum/admin/posts/post-1/close');
expect(response.status).toBe(200);
expect(mockPost.update).toHaveBeenCalledWith(expect.objectContaining({
status: 'closed',
closedBy: 'admin-123',
}));
});
it('should return 404 for non-existent post', async () => {
ForumPost.findByPk.mockResolvedValue(null);
const response = await request(app)
.patch('/forum/admin/posts/non-existent/close');
expect(response.status).toBe(404);
});
it('should return 400 when post already closed', async () => {
const mockPost = {
id: 'post-1',
status: 'closed',
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.patch('/forum/admin/posts/post-1/close');
expect(response.status).toBe(400);
expect(response.body.error).toContain('already closed');
});
});
describe('PATCH /forum/admin/posts/:id/reopen', () => {
it('should reopen closed post discussion', async () => {
const mockPost = {
id: 'post-1',
authorId: 'user-123',
status: 'closed',
update: jest.fn().mockResolvedValue(),
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.patch('/forum/admin/posts/post-1/reopen');
expect(response.status).toBe(200);
expect(mockPost.update).toHaveBeenCalledWith({
status: 'open',
closedBy: null,
closedAt: null,
});
});
it('should return 404 for non-existent post', async () => {
ForumPost.findByPk.mockResolvedValue(null);
const response = await request(app)
.patch('/forum/admin/posts/non-existent/reopen');
expect(response.status).toBe(404);
});
it('should return 400 when post not closed', async () => {
const mockPost = {
id: 'post-1',
status: 'open',
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.patch('/forum/admin/posts/post-1/reopen');
expect(response.status).toBe(400);
expect(response.body.error).toContain('not closed');
});
});
});

View File

@@ -8,7 +8,8 @@ jest.mock('sequelize', () => ({
lte: 'lte',
iLike: 'iLike',
or: 'or',
not: 'not'
not: 'not',
ne: 'ne'
}
}));
@@ -28,18 +29,27 @@ jest.mock('../../../models', () => ({
}
}));
// Track whether to simulate admin user
let mockIsAdmin = true;
// Mock auth middleware
jest.mock('../../../middleware/auth', () => ({
authenticateToken: (req, res, next) => {
if (req.headers.authorization) {
req.user = { id: 1 };
req.user = { id: 1, role: mockIsAdmin ? 'admin' : 'user' };
next();
} else {
res.status(401).json({ error: 'No token provided' });
}
},
requireVerifiedEmail: (req, res, next) => next(),
requireAdmin: (req, res, next) => next(),
requireAdmin: (req, res, next) => {
if (req.user && req.user.role === 'admin') {
next();
} else {
res.status(403).json({ error: 'Admin access required' });
}
},
optionalAuth: (req, res, next) => next()
}));
@@ -75,6 +85,7 @@ const mockItemCreate = Item.create;
const mockItemFindAll = Item.findAll;
const mockItemCount = Item.count;
const mockRentalFindAll = Rental.findAll;
const mockRentalCount = Rental.count;
const mockUserModel = User;
// Set up Express app for testing
@@ -95,6 +106,7 @@ describe('Items Routes', () => {
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
jest.spyOn(console, 'error').mockImplementation();
mockItemCount.mockResolvedValue(1); // Default to not first listing
mockIsAdmin = true; // Default to admin user
});
afterEach(() => {
@@ -199,7 +211,9 @@ describe('Items Routes', () => {
{
model: mockUserModel,
as: 'owner',
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
attributes: ['id', 'firstName', 'lastName', 'imageFilename'],
where: { isBanned: { 'ne': true } },
required: true
}
],
limit: 20,
@@ -1401,4 +1415,303 @@ describe('Items Routes', () => {
});
});
});
describe('DELETE /admin/:id (Admin Soft Delete)', () => {
const mockItem = {
id: 1,
name: 'Test Item',
ownerId: 2,
isDeleted: false,
owner: { id: 2, firstName: 'John', lastName: 'Doe', email: 'john@example.com' },
update: jest.fn()
};
const mockUpdatedItem = {
id: 1,
name: 'Test Item',
ownerId: 2,
isDeleted: true,
deletedBy: 1,
deletedAt: expect.any(Date),
deletionReason: 'Violates terms of service',
owner: { id: 2, firstName: 'John', lastName: 'Doe' },
deleter: { id: 1, firstName: 'Admin', lastName: 'User' }
};
beforeEach(() => {
mockItem.update.mockReset();
mockRentalCount.mockResolvedValue(0); // No active rentals by default
});
it('should soft delete item as admin with valid reason', async () => {
mockItemFindByPk
.mockResolvedValueOnce(mockItem)
.mockResolvedValueOnce(mockUpdatedItem);
mockItem.update.mockResolvedValue();
const response = await request(app)
.delete('/items/admin/1')
.set('Authorization', 'Bearer valid_token')
.send({ reason: 'Violates terms of service' });
expect(response.status).toBe(200);
expect(mockItem.update).toHaveBeenCalledWith({
isDeleted: true,
deletedBy: 1,
deletedAt: expect.any(Date),
deletionReason: 'Violates terms of service'
});
});
it('should return updated item with deleter information', async () => {
mockItemFindByPk
.mockResolvedValueOnce(mockItem)
.mockResolvedValueOnce(mockUpdatedItem);
mockItem.update.mockResolvedValue();
const response = await request(app)
.delete('/items/admin/1')
.set('Authorization', 'Bearer valid_token')
.send({ reason: 'Violates terms of service' });
expect(response.status).toBe(200);
expect(response.body.deleter).toBeDefined();
expect(response.body.isDeleted).toBe(true);
});
it('should return 400 when reason is missing', async () => {
const response = await request(app)
.delete('/items/admin/1')
.set('Authorization', 'Bearer valid_token')
.send({});
expect(response.status).toBe(400);
expect(response.body.error).toBe('Deletion reason is required');
});
it('should return 400 when reason is empty', async () => {
const response = await request(app)
.delete('/items/admin/1')
.set('Authorization', 'Bearer valid_token')
.send({ reason: ' ' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Deletion reason is required');
});
it('should return 401 when not authenticated', async () => {
const response = await request(app)
.delete('/items/admin/1')
.send({ reason: 'Violates terms of service' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('No token provided');
});
it('should return 403 when user is not admin', async () => {
mockIsAdmin = false;
const response = await request(app)
.delete('/items/admin/1')
.set('Authorization', 'Bearer valid_token')
.send({ reason: 'Violates terms of service' });
expect(response.status).toBe(403);
expect(response.body.error).toBe('Admin access required');
});
it('should return 404 for non-existent item', async () => {
mockItemFindByPk.mockResolvedValue(null);
const response = await request(app)
.delete('/items/admin/999')
.set('Authorization', 'Bearer valid_token')
.send({ reason: 'Violates terms of service' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Item not found');
});
it('should return 400 when item is already deleted', async () => {
const deletedItem = { ...mockItem, isDeleted: true };
mockItemFindByPk.mockResolvedValue(deletedItem);
const response = await request(app)
.delete('/items/admin/1')
.set('Authorization', 'Bearer valid_token')
.send({ reason: 'Violates terms of service' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Item is already deleted');
});
it('should return 400 when item has active rentals', async () => {
mockItemFindByPk.mockResolvedValue(mockItem);
mockRentalCount.mockResolvedValue(2);
const response = await request(app)
.delete('/items/admin/1')
.set('Authorization', 'Bearer valid_token')
.send({ reason: 'Violates terms of service' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Cannot delete item with active or upcoming rentals');
expect(response.body.code).toBe('ACTIVE_RENTALS_EXIST');
expect(response.body.activeRentalsCount).toBe(2);
});
it('should handle database errors', async () => {
mockItemFindByPk.mockRejectedValue(new Error('Database error'));
const response = await request(app)
.delete('/items/admin/1')
.set('Authorization', 'Bearer valid_token')
.send({ reason: 'Violates terms of service' });
expect(response.status).toBe(500);
expect(response.body.error).toBe('Database error');
});
it('should handle update errors', async () => {
mockItemFindByPk.mockResolvedValue(mockItem);
mockItem.update.mockRejectedValue(new Error('Update failed'));
const response = await request(app)
.delete('/items/admin/1')
.set('Authorization', 'Bearer valid_token')
.send({ reason: 'Violates terms of service' });
expect(response.status).toBe(500);
expect(response.body.error).toBe('Update failed');
});
});
describe('PATCH /admin/:id/restore (Admin Restore)', () => {
const mockDeletedItem = {
id: 1,
name: 'Test Item',
ownerId: 2,
isDeleted: true,
deletedBy: 1,
deletedAt: new Date(),
deletionReason: 'Violates terms of service',
update: jest.fn()
};
const mockRestoredItem = {
id: 1,
name: 'Test Item',
ownerId: 2,
isDeleted: false,
deletedBy: null,
deletedAt: null,
deletionReason: null,
owner: { id: 2, firstName: 'John', lastName: 'Doe' }
};
beforeEach(() => {
mockDeletedItem.update.mockReset();
});
it('should restore soft-deleted item as admin', async () => {
mockItemFindByPk
.mockResolvedValueOnce(mockDeletedItem)
.mockResolvedValueOnce(mockRestoredItem);
mockDeletedItem.update.mockResolvedValue();
const response = await request(app)
.patch('/items/admin/1/restore')
.set('Authorization', 'Bearer valid_token');
expect(response.status).toBe(200);
expect(mockDeletedItem.update).toHaveBeenCalledWith({
isDeleted: false,
deletedBy: null,
deletedAt: null,
deletionReason: null
});
});
it('should clear deletion fields after restore', async () => {
mockItemFindByPk
.mockResolvedValueOnce(mockDeletedItem)
.mockResolvedValueOnce(mockRestoredItem);
mockDeletedItem.update.mockResolvedValue();
const response = await request(app)
.patch('/items/admin/1/restore')
.set('Authorization', 'Bearer valid_token');
expect(response.status).toBe(200);
expect(response.body.isDeleted).toBe(false);
expect(response.body.deletedBy).toBeNull();
expect(response.body.deletedAt).toBeNull();
expect(response.body.deletionReason).toBeNull();
});
it('should return 401 when not authenticated', async () => {
const response = await request(app)
.patch('/items/admin/1/restore');
expect(response.status).toBe(401);
expect(response.body.error).toBe('No token provided');
});
it('should return 403 when user is not admin', async () => {
mockIsAdmin = false;
const response = await request(app)
.patch('/items/admin/1/restore')
.set('Authorization', 'Bearer valid_token');
expect(response.status).toBe(403);
expect(response.body.error).toBe('Admin access required');
});
it('should return 404 for non-existent item', async () => {
mockItemFindByPk.mockResolvedValue(null);
const response = await request(app)
.patch('/items/admin/999/restore')
.set('Authorization', 'Bearer valid_token');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Item not found');
});
it('should return 400 when item is not deleted', async () => {
const activeItem = { ...mockDeletedItem, isDeleted: false };
mockItemFindByPk.mockResolvedValue(activeItem);
const response = await request(app)
.patch('/items/admin/1/restore')
.set('Authorization', 'Bearer valid_token');
expect(response.status).toBe(400);
expect(response.body.error).toBe('Item is not deleted');
});
it('should handle database errors', async () => {
mockItemFindByPk.mockRejectedValue(new Error('Database error'));
const response = await request(app)
.patch('/items/admin/1/restore')
.set('Authorization', 'Bearer valid_token');
expect(response.status).toBe(500);
expect(response.body.error).toBe('Database error');
});
it('should handle update errors', async () => {
mockItemFindByPk.mockResolvedValue(mockDeletedItem);
mockDeletedItem.update.mockRejectedValue(new Error('Update failed'));
const response = await request(app)
.patch('/items/admin/1/restore')
.set('Authorization', 'Bearer valid_token');
expect(response.status).toBe(500);
expect(response.body.error).toBe('Update failed');
});
});
});

File diff suppressed because it is too large Load Diff

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
});
});
@@ -421,6 +419,11 @@ describe("Stripe Routes", () => {
const mockUser = {
id: 1,
stripeConnectedAccountId: "acct_123456789",
stripePayoutsEnabled: true,
stripeRequirementsCurrentlyDue: [],
stripeRequirementsPastDue: [],
stripeDisabledReason: null,
update: jest.fn().mockResolvedValue(true),
};
it("should get account status successfully", async () => {
@@ -461,7 +464,7 @@ describe("Stripe Routes", () => {
});
expect(StripeService.getAccountStatus).toHaveBeenCalledWith(
"acct_123456789"
"acct_123456789",
);
});
@@ -511,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
});
});
@@ -677,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 () => {
@@ -780,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

@@ -0,0 +1,335 @@
const request = require('supertest');
const express = require('express');
// Set env before loading the module
process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test';
// Mock dependencies
jest.mock('../../../services/stripeWebhookService', () => ({
constructEvent: jest.fn(),
handleAccountUpdated: jest.fn(),
handlePayoutPaid: jest.fn(),
handlePayoutFailed: jest.fn(),
handlePayoutCanceled: jest.fn(),
handleAccountDeauthorized: jest.fn(),
}));
jest.mock('../../../services/disputeService', () => ({
handleDisputeCreated: jest.fn(),
handleDisputeClosed: jest.fn(),
}));
jest.mock('../../../utils/logger', () => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
}));
const StripeWebhookService = require('../../../services/stripeWebhookService');
const DisputeService = require('../../../services/disputeService');
const stripeWebhooksRoutes = require('../../../routes/stripeWebhooks');
describe('Stripe Webhooks Routes', () => {
let app;
beforeEach(() => {
app = express();
// Add raw body middleware to capture raw body for signature verification
app.use(express.raw({ type: 'application/json' }));
app.use((req, res, next) => {
req.rawBody = req.body;
// Parse body for route handler
if (Buffer.isBuffer(req.body)) {
try {
req.body = JSON.parse(req.body.toString());
} catch (e) {
req.body = {};
}
}
next();
});
app.use('/stripe/webhooks', stripeWebhooksRoutes);
jest.clearAllMocks();
});
describe('POST /stripe/webhooks', () => {
it('should return 400 when signature is missing', async () => {
const response = await request(app)
.post('/stripe/webhooks')
.send({ type: 'test' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Missing signature');
});
it('should return 400 when signature verification fails', async () => {
StripeWebhookService.constructEvent.mockImplementation(() => {
throw new Error('Invalid signature');
});
const response = await request(app)
.post('/stripe/webhooks')
.set('stripe-signature', 'invalid-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify({ type: 'test' }));
expect(response.status).toBe(400);
expect(response.body.error).toBe('Invalid signature');
});
it('should handle account.updated event', async () => {
const mockEvent = {
id: 'evt_123',
type: 'account.updated',
data: { object: { id: 'acct_123' } },
};
StripeWebhookService.constructEvent.mockReturnValue(mockEvent);
StripeWebhookService.handleAccountUpdated.mockResolvedValue();
const response = await request(app)
.post('/stripe/webhooks')
.set('stripe-signature', 'valid-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(mockEvent));
expect(response.status).toBe(200);
expect(response.body.received).toBe(true);
expect(response.body.eventId).toBe('evt_123');
expect(StripeWebhookService.handleAccountUpdated).toHaveBeenCalledWith({ id: 'acct_123' });
});
it('should handle payout.paid event', async () => {
const mockEvent = {
id: 'evt_123',
type: 'payout.paid',
data: { object: { id: 'po_123' } },
account: 'acct_456',
};
StripeWebhookService.constructEvent.mockReturnValue(mockEvent);
StripeWebhookService.handlePayoutPaid.mockResolvedValue();
const response = await request(app)
.post('/stripe/webhooks')
.set('stripe-signature', 'valid-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(mockEvent));
expect(response.status).toBe(200);
expect(StripeWebhookService.handlePayoutPaid).toHaveBeenCalledWith({ id: 'po_123' }, 'acct_456');
});
it('should handle payout.failed event', async () => {
const mockEvent = {
id: 'evt_123',
type: 'payout.failed',
data: { object: { id: 'po_123' } },
account: 'acct_456',
};
StripeWebhookService.constructEvent.mockReturnValue(mockEvent);
StripeWebhookService.handlePayoutFailed.mockResolvedValue();
const response = await request(app)
.post('/stripe/webhooks')
.set('stripe-signature', 'valid-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(mockEvent));
expect(response.status).toBe(200);
expect(StripeWebhookService.handlePayoutFailed).toHaveBeenCalledWith({ id: 'po_123' }, 'acct_456');
});
it('should handle payout.canceled event', async () => {
const mockEvent = {
id: 'evt_123',
type: 'payout.canceled',
data: { object: { id: 'po_123' } },
account: 'acct_456',
};
StripeWebhookService.constructEvent.mockReturnValue(mockEvent);
StripeWebhookService.handlePayoutCanceled.mockResolvedValue();
const response = await request(app)
.post('/stripe/webhooks')
.set('stripe-signature', 'valid-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(mockEvent));
expect(response.status).toBe(200);
expect(StripeWebhookService.handlePayoutCanceled).toHaveBeenCalledWith({ id: 'po_123' }, 'acct_456');
});
it('should handle account.application.deauthorized event', async () => {
const mockEvent = {
id: 'evt_123',
type: 'account.application.deauthorized',
data: { object: {} },
account: 'acct_456',
};
StripeWebhookService.constructEvent.mockReturnValue(mockEvent);
StripeWebhookService.handleAccountDeauthorized.mockResolvedValue();
const response = await request(app)
.post('/stripe/webhooks')
.set('stripe-signature', 'valid-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(mockEvent));
expect(response.status).toBe(200);
expect(StripeWebhookService.handleAccountDeauthorized).toHaveBeenCalledWith('acct_456');
});
it('should handle charge.dispute.created event', async () => {
const mockEvent = {
id: 'evt_123',
type: 'charge.dispute.created',
data: { object: { id: 'dp_123' } },
};
StripeWebhookService.constructEvent.mockReturnValue(mockEvent);
DisputeService.handleDisputeCreated.mockResolvedValue();
const response = await request(app)
.post('/stripe/webhooks')
.set('stripe-signature', 'valid-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(mockEvent));
expect(response.status).toBe(200);
expect(DisputeService.handleDisputeCreated).toHaveBeenCalledWith({ id: 'dp_123' });
});
it('should handle charge.dispute.closed event', async () => {
const mockEvent = {
id: 'evt_123',
type: 'charge.dispute.closed',
data: { object: { id: 'dp_123' } },
};
StripeWebhookService.constructEvent.mockReturnValue(mockEvent);
DisputeService.handleDisputeClosed.mockResolvedValue();
const response = await request(app)
.post('/stripe/webhooks')
.set('stripe-signature', 'valid-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(mockEvent));
expect(response.status).toBe(200);
expect(DisputeService.handleDisputeClosed).toHaveBeenCalledWith({ id: 'dp_123' });
});
it('should handle charge.dispute.funds_reinstated event', async () => {
const mockEvent = {
id: 'evt_123',
type: 'charge.dispute.funds_reinstated',
data: { object: { id: 'dp_123' } },
};
StripeWebhookService.constructEvent.mockReturnValue(mockEvent);
DisputeService.handleDisputeClosed.mockResolvedValue();
const response = await request(app)
.post('/stripe/webhooks')
.set('stripe-signature', 'valid-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(mockEvent));
expect(response.status).toBe(200);
expect(DisputeService.handleDisputeClosed).toHaveBeenCalled();
});
it('should handle charge.dispute.funds_withdrawn event', async () => {
const mockEvent = {
id: 'evt_123',
type: 'charge.dispute.funds_withdrawn',
data: { object: { id: 'dp_123' } },
};
StripeWebhookService.constructEvent.mockReturnValue(mockEvent);
DisputeService.handleDisputeClosed.mockResolvedValue();
const response = await request(app)
.post('/stripe/webhooks')
.set('stripe-signature', 'valid-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(mockEvent));
expect(response.status).toBe(200);
expect(DisputeService.handleDisputeClosed).toHaveBeenCalled();
});
it('should handle unhandled event types gracefully', async () => {
const mockEvent = {
id: 'evt_123',
type: 'customer.created',
data: { object: {} },
};
StripeWebhookService.constructEvent.mockReturnValue(mockEvent);
const response = await request(app)
.post('/stripe/webhooks')
.set('stripe-signature', 'valid-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(mockEvent));
expect(response.status).toBe(200);
expect(response.body.received).toBe(true);
});
it('should return 200 even when handler throws error', async () => {
const mockEvent = {
id: 'evt_123',
type: 'account.updated',
data: { object: { id: 'acct_123' } },
};
StripeWebhookService.constructEvent.mockReturnValue(mockEvent);
StripeWebhookService.handleAccountUpdated.mockRejectedValue(new Error('Handler error'));
const response = await request(app)
.post('/stripe/webhooks')
.set('stripe-signature', 'valid-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(mockEvent));
// Should still return 200 to prevent Stripe retries
expect(response.status).toBe(200);
expect(response.body.received).toBe(true);
});
it('should log event with connected account when present', async () => {
const mockEvent = {
id: 'evt_123',
type: 'payout.paid',
data: { object: { id: 'po_123' } },
account: 'acct_connected',
};
StripeWebhookService.constructEvent.mockReturnValue(mockEvent);
StripeWebhookService.handlePayoutPaid.mockResolvedValue();
await request(app)
.post('/stripe/webhooks')
.set('stripe-signature', 'valid-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(mockEvent));
// Logger should have been called with connected account info
const logger = require('../../../utils/logger');
expect(logger.info).toHaveBeenCalledWith(
'Stripe webhook received',
expect.objectContaining({
eventId: 'evt_123',
eventType: 'payout.paid',
connectedAccount: 'acct_connected',
})
);
});
});
});

View File

@@ -0,0 +1,793 @@
const request = require('supertest');
const express = require('express');
// Mock dependencies before requiring routes
jest.mock('../../../models', () => ({
User: {
findByPk: jest.fn(),
},
}));
jest.mock('../../../services/TwoFactorService', () => ({
generateTotpSecret: jest.fn(),
generateRecoveryCodes: jest.fn(),
}));
jest.mock('../../../services/email', () => ({
auth: {
sendTwoFactorEnabledEmail: jest.fn(),
sendTwoFactorOtpEmail: jest.fn(),
sendRecoveryCodeUsedEmail: jest.fn(),
sendTwoFactorDisabledEmail: jest.fn(),
},
}));
jest.mock('../../../middleware/auth', () => ({
authenticateToken: (req, res, next) => {
req.user = { id: 'user-123' };
next();
},
}));
jest.mock('../../../middleware/stepUpAuth', () => ({
requireStepUpAuth: () => (req, res, next) => next(),
}));
jest.mock('../../../middleware/csrf', () => ({
csrfProtection: (req, res, next) => next(),
}));
jest.mock('../../../middleware/validation', () => ({
sanitizeInput: (req, res, next) => next(),
validateTotpCode: (req, res, next) => next(),
validateEmailOtp: (req, res, next) => next(),
validateRecoveryCode: (req, res, next) => next(),
}));
jest.mock('../../../middleware/rateLimiter', () => ({
twoFactorVerificationLimiter: (req, res, next) => next(),
twoFactorSetupLimiter: (req, res, next) => next(),
recoveryCodeLimiter: (req, res, next) => next(),
emailOtpSendLimiter: (req, res, next) => next(),
}));
jest.mock('../../../utils/logger', () => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
}));
const { User } = require('../../../models');
const TwoFactorService = require('../../../services/TwoFactorService');
const emailServices = require('../../../services/email');
const twoFactorRoutes = require('../../../routes/twoFactor');
describe('Two Factor Routes', () => {
let app;
beforeEach(() => {
app = express();
app.use(express.json());
app.use('/2fa', twoFactorRoutes);
jest.clearAllMocks();
});
// ============================================
// SETUP ENDPOINTS
// ============================================
describe('POST /2fa/setup/totp/init', () => {
it('should initialize TOTP setup and return QR code', async () => {
const mockUser = {
id: 'user-123',
email: 'test@example.com',
twoFactorEnabled: false,
storePendingTotpSecret: jest.fn().mockResolvedValue(),
};
User.findByPk.mockResolvedValue(mockUser);
TwoFactorService.generateTotpSecret.mockResolvedValue({
qrCodeDataUrl: 'data:image/png;base64,test',
encryptedSecret: 'encrypted-secret',
encryptedSecretIv: 'iv-123',
});
const response = await request(app)
.post('/2fa/setup/totp/init');
expect(response.status).toBe(200);
expect(response.body.qrCodeDataUrl).toBe('data:image/png;base64,test');
expect(response.body.message).toContain('Scan the QR code');
expect(mockUser.storePendingTotpSecret).toHaveBeenCalledWith('encrypted-secret', 'iv-123');
});
it('should return 404 when user not found', async () => {
User.findByPk.mockResolvedValue(null);
const response = await request(app)
.post('/2fa/setup/totp/init');
expect(response.status).toBe(404);
expect(response.body.error).toBe('User not found');
});
it('should return 400 when 2FA already enabled', async () => {
User.findByPk.mockResolvedValue({
id: 'user-123',
twoFactorEnabled: true,
});
const response = await request(app)
.post('/2fa/setup/totp/init');
expect(response.status).toBe(400);
expect(response.body.error).toContain('already enabled');
});
it('should handle errors during setup', async () => {
User.findByPk.mockRejectedValue(new Error('Database error'));
const response = await request(app)
.post('/2fa/setup/totp/init');
expect(response.status).toBe(500);
expect(response.body.error).toContain('Failed to initialize');
});
});
describe('POST /2fa/setup/totp/verify', () => {
it('should verify TOTP code and enable 2FA', async () => {
const mockUser = {
id: 'user-123',
twoFactorEnabled: false,
twoFactorSetupPendingSecret: 'pending-secret',
verifyPendingTotpCode: jest.fn().mockReturnValue(true),
enableTotp: jest.fn().mockResolvedValue(),
};
User.findByPk.mockResolvedValue(mockUser);
TwoFactorService.generateRecoveryCodes.mockResolvedValue({
codes: ['XXXX-YYYY', 'AAAA-BBBB'],
});
emailServices.auth.sendTwoFactorEnabledEmail.mockResolvedValue();
const response = await request(app)
.post('/2fa/setup/totp/verify')
.send({ code: '123456' });
expect(response.status).toBe(200);
expect(response.body.message).toContain('enabled successfully');
expect(response.body.recoveryCodes).toHaveLength(2);
expect(response.body.warning).toContain('Save these recovery codes');
expect(mockUser.enableTotp).toHaveBeenCalled();
});
it('should return 404 when user not found', async () => {
User.findByPk.mockResolvedValue(null);
const response = await request(app)
.post('/2fa/setup/totp/verify')
.send({ code: '123456' });
expect(response.status).toBe(404);
});
it('should return 400 when 2FA already enabled', async () => {
User.findByPk.mockResolvedValue({
id: 'user-123',
twoFactorEnabled: true,
});
const response = await request(app)
.post('/2fa/setup/totp/verify')
.send({ code: '123456' });
expect(response.status).toBe(400);
});
it('should return 400 when no pending secret', async () => {
User.findByPk.mockResolvedValue({
id: 'user-123',
twoFactorEnabled: false,
twoFactorSetupPendingSecret: null,
});
const response = await request(app)
.post('/2fa/setup/totp/verify')
.send({ code: '123456' });
expect(response.status).toBe(400);
expect(response.body.error).toContain('No pending TOTP setup');
});
it('should return 400 for invalid code', async () => {
const mockUser = {
id: 'user-123',
twoFactorEnabled: false,
twoFactorSetupPendingSecret: 'pending-secret',
verifyPendingTotpCode: jest.fn().mockReturnValue(false),
};
User.findByPk.mockResolvedValue(mockUser);
const response = await request(app)
.post('/2fa/setup/totp/verify')
.send({ code: '123456' });
expect(response.status).toBe(400);
expect(response.body.error).toContain('Invalid verification code');
});
it('should continue even if email fails', async () => {
const mockUser = {
id: 'user-123',
twoFactorEnabled: false,
twoFactorSetupPendingSecret: 'pending-secret',
verifyPendingTotpCode: jest.fn().mockReturnValue(true),
enableTotp: jest.fn().mockResolvedValue(),
};
User.findByPk.mockResolvedValue(mockUser);
TwoFactorService.generateRecoveryCodes.mockResolvedValue({ codes: ['XXXX-YYYY'] });
emailServices.auth.sendTwoFactorEnabledEmail.mockRejectedValue(new Error('Email failed'));
const response = await request(app)
.post('/2fa/setup/totp/verify')
.send({ code: '123456' });
expect(response.status).toBe(200);
});
});
describe('POST /2fa/setup/email/init', () => {
it('should send email OTP for setup', async () => {
const mockUser = {
id: 'user-123',
twoFactorEnabled: false,
generateEmailOtp: jest.fn().mockResolvedValue('123456'),
};
User.findByPk.mockResolvedValue(mockUser);
emailServices.auth.sendTwoFactorOtpEmail.mockResolvedValue();
const response = await request(app)
.post('/2fa/setup/email/init');
expect(response.status).toBe(200);
expect(response.body.message).toContain('Verification code sent');
expect(emailServices.auth.sendTwoFactorOtpEmail).toHaveBeenCalled();
});
it('should return 404 when user not found', async () => {
User.findByPk.mockResolvedValue(null);
const response = await request(app)
.post('/2fa/setup/email/init');
expect(response.status).toBe(404);
});
it('should return 400 when 2FA already enabled', async () => {
User.findByPk.mockResolvedValue({
id: 'user-123',
twoFactorEnabled: true,
});
const response = await request(app)
.post('/2fa/setup/email/init');
expect(response.status).toBe(400);
});
it('should return 500 when email fails', async () => {
const mockUser = {
id: 'user-123',
twoFactorEnabled: false,
generateEmailOtp: jest.fn().mockResolvedValue('123456'),
};
User.findByPk.mockResolvedValue(mockUser);
emailServices.auth.sendTwoFactorOtpEmail.mockRejectedValue(new Error('Email failed'));
const response = await request(app)
.post('/2fa/setup/email/init');
expect(response.status).toBe(500);
expect(response.body.error).toContain('Failed to send verification email');
});
});
describe('POST /2fa/setup/email/verify', () => {
it('should verify email OTP and enable email 2FA', async () => {
const mockUser = {
id: 'user-123',
twoFactorEnabled: false,
isEmailOtpLocked: jest.fn().mockReturnValue(false),
verifyEmailOtp: jest.fn().mockReturnValue(true),
enableEmailTwoFactor: jest.fn().mockResolvedValue(),
clearEmailOtp: jest.fn().mockResolvedValue(),
};
User.findByPk.mockResolvedValue(mockUser);
TwoFactorService.generateRecoveryCodes.mockResolvedValue({ codes: ['XXXX-YYYY'] });
emailServices.auth.sendTwoFactorEnabledEmail.mockResolvedValue();
const response = await request(app)
.post('/2fa/setup/email/verify')
.send({ code: '123456' });
expect(response.status).toBe(200);
expect(response.body.recoveryCodes).toBeDefined();
expect(mockUser.enableEmailTwoFactor).toHaveBeenCalled();
expect(mockUser.clearEmailOtp).toHaveBeenCalled();
});
it('should return 429 when OTP locked', async () => {
const mockUser = {
id: 'user-123',
twoFactorEnabled: false,
isEmailOtpLocked: jest.fn().mockReturnValue(true),
};
User.findByPk.mockResolvedValue(mockUser);
const response = await request(app)
.post('/2fa/setup/email/verify')
.send({ code: '123456' });
expect(response.status).toBe(429);
expect(response.body.error).toContain('Too many failed attempts');
});
it('should return 400 for invalid OTP', async () => {
const mockUser = {
id: 'user-123',
twoFactorEnabled: false,
isEmailOtpLocked: jest.fn().mockReturnValue(false),
verifyEmailOtp: jest.fn().mockReturnValue(false),
incrementEmailOtpAttempts: jest.fn().mockResolvedValue(),
};
User.findByPk.mockResolvedValue(mockUser);
const response = await request(app)
.post('/2fa/setup/email/verify')
.send({ code: '123456' });
expect(response.status).toBe(400);
expect(mockUser.incrementEmailOtpAttempts).toHaveBeenCalled();
});
});
// ============================================
// VERIFICATION ENDPOINTS
// ============================================
describe('POST /2fa/verify/totp', () => {
it('should verify TOTP code for step-up auth', async () => {
const mockUser = {
id: 'user-123',
twoFactorEnabled: true,
twoFactorMethod: 'totp',
verifyTotpCode: jest.fn().mockReturnValue(true),
markTotpCodeUsed: jest.fn().mockResolvedValue(),
updateStepUpSession: jest.fn().mockResolvedValue(),
};
User.findByPk.mockResolvedValue(mockUser);
const response = await request(app)
.post('/2fa/verify/totp')
.send({ code: '123456' });
expect(response.status).toBe(200);
expect(response.body.verified).toBe(true);
expect(mockUser.markTotpCodeUsed).toHaveBeenCalled();
expect(mockUser.updateStepUpSession).toHaveBeenCalled();
});
it('should return 400 when TOTP not enabled', async () => {
User.findByPk.mockResolvedValue({
id: 'user-123',
twoFactorEnabled: false,
});
const response = await request(app)
.post('/2fa/verify/totp')
.send({ code: '123456' });
expect(response.status).toBe(400);
});
it('should return 400 when wrong 2FA method', async () => {
User.findByPk.mockResolvedValue({
id: 'user-123',
twoFactorEnabled: true,
twoFactorMethod: 'email',
});
const response = await request(app)
.post('/2fa/verify/totp')
.send({ code: '123456' });
expect(response.status).toBe(400);
});
it('should return 400 for invalid code', async () => {
const mockUser = {
id: 'user-123',
twoFactorEnabled: true,
twoFactorMethod: 'totp',
verifyTotpCode: jest.fn().mockReturnValue(false),
};
User.findByPk.mockResolvedValue(mockUser);
const response = await request(app)
.post('/2fa/verify/totp')
.send({ code: '123456' });
expect(response.status).toBe(400);
});
});
describe('POST /2fa/verify/email/send', () => {
it('should send email OTP for step-up auth', async () => {
const mockUser = {
id: 'user-123',
twoFactorEnabled: true,
generateEmailOtp: jest.fn().mockResolvedValue('123456'),
};
User.findByPk.mockResolvedValue(mockUser);
emailServices.auth.sendTwoFactorOtpEmail.mockResolvedValue();
const response = await request(app)
.post('/2fa/verify/email/send');
expect(response.status).toBe(200);
expect(response.body.message).toContain('Verification code sent');
});
it('should return 400 when 2FA not enabled', async () => {
User.findByPk.mockResolvedValue({
id: 'user-123',
twoFactorEnabled: false,
});
const response = await request(app)
.post('/2fa/verify/email/send');
expect(response.status).toBe(400);
});
it('should return 500 when email fails', async () => {
const mockUser = {
id: 'user-123',
twoFactorEnabled: true,
generateEmailOtp: jest.fn().mockResolvedValue('123456'),
};
User.findByPk.mockResolvedValue(mockUser);
emailServices.auth.sendTwoFactorOtpEmail.mockRejectedValue(new Error('Email failed'));
const response = await request(app)
.post('/2fa/verify/email/send');
expect(response.status).toBe(500);
});
});
describe('POST /2fa/verify/email', () => {
it('should verify email OTP for step-up auth', async () => {
const mockUser = {
id: 'user-123',
twoFactorEnabled: true,
isEmailOtpLocked: jest.fn().mockReturnValue(false),
verifyEmailOtp: jest.fn().mockReturnValue(true),
updateStepUpSession: jest.fn().mockResolvedValue(),
clearEmailOtp: jest.fn().mockResolvedValue(),
};
User.findByPk.mockResolvedValue(mockUser);
const response = await request(app)
.post('/2fa/verify/email')
.send({ code: '123456' });
expect(response.status).toBe(200);
expect(response.body.verified).toBe(true);
});
it('should return 400 when 2FA not enabled', async () => {
User.findByPk.mockResolvedValue({
id: 'user-123',
twoFactorEnabled: false,
});
const response = await request(app)
.post('/2fa/verify/email')
.send({ code: '123456' });
expect(response.status).toBe(400);
});
it('should return 429 when locked', async () => {
const mockUser = {
id: 'user-123',
twoFactorEnabled: true,
isEmailOtpLocked: jest.fn().mockReturnValue(true),
};
User.findByPk.mockResolvedValue(mockUser);
const response = await request(app)
.post('/2fa/verify/email')
.send({ code: '123456' });
expect(response.status).toBe(429);
});
it('should return 400 and increment attempts for invalid OTP', async () => {
const mockUser = {
id: 'user-123',
twoFactorEnabled: true,
isEmailOtpLocked: jest.fn().mockReturnValue(false),
verifyEmailOtp: jest.fn().mockReturnValue(false),
incrementEmailOtpAttempts: jest.fn().mockResolvedValue(),
};
User.findByPk.mockResolvedValue(mockUser);
const response = await request(app)
.post('/2fa/verify/email')
.send({ code: '123456' });
expect(response.status).toBe(400);
expect(mockUser.incrementEmailOtpAttempts).toHaveBeenCalled();
});
});
describe('POST /2fa/verify/recovery', () => {
it('should verify recovery code', async () => {
const mockUser = {
id: 'user-123',
twoFactorEnabled: true,
useRecoveryCode: jest.fn().mockResolvedValue({ valid: true, remainingCodes: 5 }),
};
User.findByPk.mockResolvedValue(mockUser);
emailServices.auth.sendRecoveryCodeUsedEmail.mockResolvedValue();
const response = await request(app)
.post('/2fa/verify/recovery')
.send({ code: 'XXXX-YYYY' });
expect(response.status).toBe(200);
expect(response.body.verified).toBe(true);
expect(response.body.remainingCodes).toBe(5);
});
it('should warn when recovery codes are low', async () => {
const mockUser = {
id: 'user-123',
twoFactorEnabled: true,
useRecoveryCode: jest.fn().mockResolvedValue({ valid: true, remainingCodes: 2 }),
};
User.findByPk.mockResolvedValue(mockUser);
emailServices.auth.sendRecoveryCodeUsedEmail.mockResolvedValue();
const response = await request(app)
.post('/2fa/verify/recovery')
.send({ code: 'XXXX-YYYY' });
expect(response.status).toBe(200);
expect(response.body.warning).toContain('running low');
});
it('should return 400 when 2FA not enabled', async () => {
User.findByPk.mockResolvedValue({
id: 'user-123',
twoFactorEnabled: false,
});
const response = await request(app)
.post('/2fa/verify/recovery')
.send({ code: 'XXXX-YYYY' });
expect(response.status).toBe(400);
});
it('should return 400 for invalid recovery code', async () => {
const mockUser = {
id: 'user-123',
twoFactorEnabled: true,
useRecoveryCode: jest.fn().mockResolvedValue({ valid: false, remainingCodes: 0 }),
};
User.findByPk.mockResolvedValue(mockUser);
const response = await request(app)
.post('/2fa/verify/recovery')
.send({ code: 'XXXX-YYYY' });
expect(response.status).toBe(400);
});
});
// ============================================
// MANAGEMENT ENDPOINTS
// ============================================
describe('GET /2fa/status', () => {
it('should return 2FA status', async () => {
const mockUser = {
id: 'user-123',
twoFactorEnabled: true,
twoFactorMethod: 'totp',
getRemainingRecoveryCodes: jest.fn().mockReturnValue(5),
};
User.findByPk.mockResolvedValue(mockUser);
const response = await request(app)
.get('/2fa/status');
expect(response.status).toBe(200);
expect(response.body.enabled).toBe(true);
expect(response.body.method).toBe('totp');
expect(response.body.hasRecoveryCodes).toBe(true);
expect(response.body.lowRecoveryCodes).toBe(false);
});
it('should return low recovery codes warning', async () => {
const mockUser = {
id: 'user-123',
twoFactorEnabled: true,
twoFactorMethod: 'totp',
getRemainingRecoveryCodes: jest.fn().mockReturnValue(1),
};
User.findByPk.mockResolvedValue(mockUser);
const response = await request(app)
.get('/2fa/status');
expect(response.status).toBe(200);
expect(response.body.lowRecoveryCodes).toBe(true);
});
it('should return 404 when user not found', async () => {
User.findByPk.mockResolvedValue(null);
const response = await request(app)
.get('/2fa/status');
expect(response.status).toBe(404);
});
});
describe('POST /2fa/disable', () => {
it('should disable 2FA', async () => {
const mockUser = {
id: 'user-123',
twoFactorEnabled: true,
disableTwoFactor: jest.fn().mockResolvedValue(),
};
User.findByPk.mockResolvedValue(mockUser);
emailServices.auth.sendTwoFactorDisabledEmail.mockResolvedValue();
const response = await request(app)
.post('/2fa/disable');
expect(response.status).toBe(200);
expect(response.body.message).toContain('disabled');
expect(mockUser.disableTwoFactor).toHaveBeenCalled();
});
it('should return 400 when 2FA not enabled', async () => {
User.findByPk.mockResolvedValue({
id: 'user-123',
twoFactorEnabled: false,
});
const response = await request(app)
.post('/2fa/disable');
expect(response.status).toBe(400);
});
it('should return 404 when user not found', async () => {
User.findByPk.mockResolvedValue(null);
const response = await request(app)
.post('/2fa/disable');
expect(response.status).toBe(404);
});
});
describe('POST /2fa/recovery/regenerate', () => {
it('should regenerate recovery codes', async () => {
const mockUser = {
id: 'user-123',
twoFactorEnabled: true,
regenerateRecoveryCodes: jest.fn().mockResolvedValue(['NEW1-CODE', 'NEW2-CODE']),
};
User.findByPk.mockResolvedValue(mockUser);
const response = await request(app)
.post('/2fa/recovery/regenerate');
expect(response.status).toBe(200);
expect(response.body.recoveryCodes).toHaveLength(2);
expect(response.body.warning).toContain('previous codes are now invalid');
});
it('should return 400 when 2FA not enabled', async () => {
User.findByPk.mockResolvedValue({
id: 'user-123',
twoFactorEnabled: false,
});
const response = await request(app)
.post('/2fa/recovery/regenerate');
expect(response.status).toBe(400);
});
it('should return 404 when user not found', async () => {
User.findByPk.mockResolvedValue(null);
const response = await request(app)
.post('/2fa/recovery/regenerate');
expect(response.status).toBe(404);
});
});
describe('GET /2fa/recovery/remaining', () => {
it('should return recovery codes status', async () => {
const mockUser = {
id: 'user-123',
getRemainingRecoveryCodes: jest.fn().mockReturnValue(8),
};
User.findByPk.mockResolvedValue(mockUser);
const response = await request(app)
.get('/2fa/recovery/remaining');
expect(response.status).toBe(200);
expect(response.body.hasRecoveryCodes).toBe(true);
expect(response.body.lowRecoveryCodes).toBe(false);
});
it('should indicate when low on recovery codes', async () => {
const mockUser = {
id: 'user-123',
getRemainingRecoveryCodes: jest.fn().mockReturnValue(1),
};
User.findByPk.mockResolvedValue(mockUser);
const response = await request(app)
.get('/2fa/recovery/remaining');
expect(response.status).toBe(200);
expect(response.body.lowRecoveryCodes).toBe(true);
});
it('should return 404 when user not found', async () => {
User.findByPk.mockResolvedValue(null);
const response = await request(app)
.get('/2fa/recovery/remaining');
expect(response.status).toBe(404);
});
});
});

View File

@@ -23,6 +23,8 @@ jest.mock("../../../middleware/auth", () => ({
};
next();
}),
optionalAuth: jest.fn((req, res, next) => next()),
requireAdmin: jest.fn((req, res, next) => next()),
}));
jest.mock("../../../services/UserService", () => ({
@@ -365,7 +367,7 @@ describe("Users Routes", () => {
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUser);
expect(mockUserFindByPk).toHaveBeenCalledWith("2", {
attributes: { exclude: ["password", "email", "phone", "address"] },
attributes: { exclude: ["password", "email", "phone", "address", "verificationToken", "passwordResetToken", "isBanned", "bannedAt", "bannedBy", "banReason"] },
});
});
@@ -429,4 +431,113 @@ describe("Users Routes", () => {
expect(response.body).toEqual({ error: "Database error" });
});
});
describe("POST /admin/:id/ban", () => {
const mockTargetUser = {
id: 2,
role: "user",
isBanned: false,
banUser: jest.fn().mockResolvedValue(),
};
beforeEach(() => {
mockUserFindByPk.mockResolvedValue(mockTargetUser);
});
it("should ban a user with reason", async () => {
const response = await request(app)
.post("/users/admin/2/ban")
.send({ reason: "Violation of terms" });
expect(response.status).toBe(200);
expect(response.body.message).toContain("banned successfully");
expect(mockTargetUser.banUser).toHaveBeenCalledWith(1, "Violation of terms");
});
it("should return 400 when reason is not provided", async () => {
const response = await request(app)
.post("/users/admin/2/ban")
.send({});
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: "Ban reason is required" });
});
it("should return 404 for non-existent user", async () => {
mockUserFindByPk.mockResolvedValue(null);
const response = await request(app)
.post("/users/admin/999/ban")
.send({ reason: "Test" });
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: "User not found" });
});
it("should return 403 when trying to ban admin", async () => {
const adminUser = { ...mockTargetUser, role: "admin" };
mockUserFindByPk.mockResolvedValue(adminUser);
const response = await request(app)
.post("/users/admin/2/ban")
.send({ reason: "Test" });
expect(response.status).toBe(403);
expect(response.body).toEqual({ error: "Cannot ban admin users" });
});
it("should return 400 when user is already banned", async () => {
const bannedUser = { ...mockTargetUser, isBanned: true };
mockUserFindByPk.mockResolvedValue(bannedUser);
const response = await request(app)
.post("/users/admin/2/ban")
.send({ reason: "Test" });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: "User is already banned" });
});
});
describe("POST /admin/:id/unban", () => {
const mockBannedUser = {
id: 2,
isBanned: true,
unbanUser: jest.fn().mockResolvedValue(),
};
beforeEach(() => {
mockUserFindByPk.mockResolvedValue(mockBannedUser);
});
it("should unban a banned user", async () => {
const response = await request(app)
.post("/users/admin/2/unban");
expect(response.status).toBe(200);
expect(response.body.message).toContain("unbanned successfully");
expect(mockBannedUser.unbanUser).toHaveBeenCalled();
});
it("should return 404 for non-existent user", async () => {
mockUserFindByPk.mockResolvedValue(null);
const response = await request(app)
.post("/users/admin/999/unban");
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: "User not found" });
});
it("should return 400 when user is not banned", async () => {
const notBannedUser = { ...mockBannedUser, isBanned: false };
mockUserFindByPk.mockResolvedValue(notBannedUser);
const response = await request(app)
.post("/users/admin/2/unban");
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: "User is not banned" });
});
});
});

View File

@@ -0,0 +1,529 @@
const crypto = require("crypto");
const bcrypt = require("bcryptjs");
const { authenticator } = require("otplib");
const QRCode = require("qrcode");
// Mock dependencies
jest.mock("otplib", () => ({
authenticator: {
generateSecret: jest.fn(),
keyuri: jest.fn(),
verify: jest.fn(),
},
}));
jest.mock("qrcode", () => ({
toDataURL: jest.fn(),
}));
jest.mock("bcryptjs", () => ({
hash: jest.fn(),
compare: jest.fn(),
}));
jest.mock("../../../utils/logger", () => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
}));
const TwoFactorService = require("../../../services/TwoFactorService");
describe("TwoFactorService", () => {
const originalEnv = process.env;
beforeEach(() => {
jest.clearAllMocks();
process.env = {
...originalEnv,
TOTP_ENCRYPTION_KEY: "a".repeat(64),
TOTP_ISSUER: "TestApp",
TWO_FACTOR_EMAIL_OTP_EXPIRY_MINUTES: "10",
TWO_FACTOR_STEP_UP_VALIDITY_MINUTES: "5",
};
});
afterEach(() => {
process.env = originalEnv;
});
describe("generateTotpSecret", () => {
it("should generate TOTP secret with QR code", async () => {
authenticator.generateSecret.mockReturnValue("test-secret");
authenticator.keyuri.mockReturnValue(
"otpauth://totp/VillageShare:test@example.com?secret=test-secret",
);
QRCode.toDataURL.mockResolvedValue("data:image/png;base64,qrcode");
const result =
await TwoFactorService.generateTotpSecret("test@example.com");
expect(result.qrCodeDataUrl).toBe("data:image/png;base64,qrcode");
expect(result.encryptedSecret).toBeDefined();
expect(result.encryptedSecretIv).toBeDefined();
// The issuer is loaded at module load time, so it uses the default 'VillageShare'
expect(authenticator.keyuri).toHaveBeenCalledWith(
"test@example.com",
"VillageShare",
"test-secret",
);
});
it("should use issuer from environment", async () => {
authenticator.generateSecret.mockReturnValue("test-secret");
authenticator.keyuri.mockReturnValue(
"otpauth://totp/VillageShare:test@example.com",
);
QRCode.toDataURL.mockResolvedValue("data:image/png;base64,qrcode");
const result =
await TwoFactorService.generateTotpSecret("test@example.com");
expect(result.qrCodeDataUrl).toBeDefined();
expect(authenticator.keyuri).toHaveBeenCalled();
});
});
describe("verifyTotpCode", () => {
it("should return true for valid code", () => {
authenticator.verify.mockReturnValue(true);
// Use actual encryption
const { encrypted, iv } = TwoFactorService._encryptSecret("test-secret");
const result = TwoFactorService.verifyTotpCode(encrypted, iv, "123456");
expect(result).toBe(true);
});
it("should return false for invalid code", () => {
authenticator.verify.mockReturnValue(false);
const { encrypted, iv } = TwoFactorService._encryptSecret("test-secret");
const result = TwoFactorService.verifyTotpCode(encrypted, iv, "654321");
expect(result).toBe(false);
});
it("should return false for non-6-digit code", () => {
const result = TwoFactorService.verifyTotpCode(
"encrypted",
"iv",
"12345",
);
expect(result).toBe(false);
const result2 = TwoFactorService.verifyTotpCode(
"encrypted",
"iv",
"1234567",
);
expect(result2).toBe(false);
const result3 = TwoFactorService.verifyTotpCode(
"encrypted",
"iv",
"abcdef",
);
expect(result3).toBe(false);
});
it("should return false when decryption fails", () => {
const result = TwoFactorService.verifyTotpCode(
"invalid-encrypted",
"invalid-iv",
"123456",
);
expect(result).toBe(false);
});
});
describe("generateEmailOtp", () => {
it("should generate 6-digit code", () => {
const result = TwoFactorService.generateEmailOtp();
expect(result.code).toMatch(/^\d{6}$/);
});
it("should return hashed code", () => {
const result = TwoFactorService.generateEmailOtp();
expect(result.hashedCode).toHaveLength(64); // SHA-256 hex
});
it("should set expiry in the future", () => {
const result = TwoFactorService.generateEmailOtp();
const now = new Date();
expect(result.expiry.getTime()).toBeGreaterThan(now.getTime());
});
it("should generate different codes each time", () => {
const result1 = TwoFactorService.generateEmailOtp();
const result2 = TwoFactorService.generateEmailOtp();
// Codes should likely be different (very small chance of collision)
expect(result1.code).not.toBe(result2.code);
});
});
describe("verifyEmailOtp", () => {
it("should return true for valid code", () => {
const code = "123456";
const hashedCode = crypto.createHash("sha256").update(code).digest("hex");
const expiry = new Date(Date.now() + 600000); // 10 minutes from now
const result = TwoFactorService.verifyEmailOtp(code, hashedCode, expiry);
expect(result).toBe(true);
});
it("should return false for invalid code", () => {
const correctHash = crypto
.createHash("sha256")
.update("123456")
.digest("hex");
const expiry = new Date(Date.now() + 600000);
const result = TwoFactorService.verifyEmailOtp(
"654321",
correctHash,
expiry,
);
expect(result).toBe(false);
});
it("should return false for expired code", () => {
const code = "123456";
const hashedCode = crypto.createHash("sha256").update(code).digest("hex");
const expiry = new Date(Date.now() - 60000); // 1 minute ago
const result = TwoFactorService.verifyEmailOtp(code, hashedCode, expiry);
expect(result).toBe(false);
});
it("should return false for non-6-digit code", () => {
const hashedCode = crypto
.createHash("sha256")
.update("123456")
.digest("hex");
const expiry = new Date(Date.now() + 600000);
expect(TwoFactorService.verifyEmailOtp("12345", hashedCode, expiry)).toBe(
false,
);
expect(
TwoFactorService.verifyEmailOtp("1234567", hashedCode, expiry),
).toBe(false);
expect(
TwoFactorService.verifyEmailOtp("abcdef", hashedCode, expiry),
).toBe(false);
});
it("should return false when no expiry provided", () => {
const code = "123456";
const hashedCode = crypto.createHash("sha256").update(code).digest("hex");
const result = TwoFactorService.verifyEmailOtp(code, hashedCode, null);
expect(result).toBe(false);
});
});
describe("generateRecoveryCodes", () => {
it("should generate 10 recovery codes", async () => {
bcrypt.hash.mockResolvedValue("hashed-code");
const result = await TwoFactorService.generateRecoveryCodes();
expect(result.codes).toHaveLength(10);
expect(result.hashedCodes).toHaveLength(10);
});
it("should generate codes in XXXX-XXXX format", async () => {
bcrypt.hash.mockResolvedValue("hashed-code");
const result = await TwoFactorService.generateRecoveryCodes();
result.codes.forEach((code) => {
expect(code).toMatch(/^[A-Z0-9]{4}-[A-Z0-9]{4}$/);
});
});
it("should exclude confusing characters", async () => {
bcrypt.hash.mockResolvedValue("hashed-code");
const result = await TwoFactorService.generateRecoveryCodes();
const confusingChars = ["0", "O", "1", "I", "L"];
result.codes.forEach((code) => {
confusingChars.forEach((char) => {
expect(code).not.toContain(char);
});
});
});
it("should hash each code with bcrypt", async () => {
bcrypt.hash.mockResolvedValue("hashed-code");
await TwoFactorService.generateRecoveryCodes();
expect(bcrypt.hash).toHaveBeenCalledTimes(10);
});
});
describe("verifyRecoveryCode", () => {
it("should return valid for correct code (new format)", async () => {
bcrypt.compare.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
const recoveryData = {
version: 1,
codes: [
{ hash: "hash1", used: false, index: 0 },
{ hash: "hash2", used: false, index: 1 },
],
};
const result = await TwoFactorService.verifyRecoveryCode(
"XXXX-YYYY",
recoveryData,
);
expect(result.valid).toBe(true);
expect(result.index).toBe(1);
});
it("should return invalid for incorrect code", async () => {
bcrypt.compare.mockResolvedValue(false);
const recoveryData = {
version: 1,
codes: [{ hash: "hash1", used: false, index: 0 }],
};
const result = await TwoFactorService.verifyRecoveryCode(
"XXXX-YYYY",
recoveryData,
);
expect(result.valid).toBe(false);
expect(result.index).toBe(-1);
});
it("should skip used codes", async () => {
bcrypt.compare.mockResolvedValue(true);
const recoveryData = {
version: 1,
codes: [
{ hash: "hash1", used: true, index: 0 },
{ hash: "hash2", used: false, index: 1 },
],
};
await TwoFactorService.verifyRecoveryCode("XXXX-YYYY", recoveryData);
// Should only check the unused code
expect(bcrypt.compare).toHaveBeenCalledTimes(1);
});
it("should normalize input code to uppercase", async () => {
bcrypt.compare.mockResolvedValue(true);
const recoveryData = {
version: 1,
codes: [{ hash: "hash1", used: false, index: 0 }],
};
await TwoFactorService.verifyRecoveryCode("xxxx-yyyy", recoveryData);
expect(bcrypt.compare).toHaveBeenCalledWith("XXXX-YYYY", "hash1");
});
it("should return invalid for wrong format", async () => {
const recoveryData = {
version: 1,
codes: [{ hash: "hash1", used: false, index: 0 }],
};
const result = await TwoFactorService.verifyRecoveryCode(
"INVALID",
recoveryData,
);
expect(result.valid).toBe(false);
});
it("should handle legacy array format", async () => {
bcrypt.compare.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
const recoveryData = ["hash1", "hash2", "hash3"];
const result = await TwoFactorService.verifyRecoveryCode(
"XXXX-YYYY",
recoveryData,
);
expect(result.valid).toBe(true);
});
it("should skip null entries in legacy format", async () => {
bcrypt.compare.mockResolvedValue(true);
const recoveryData = [null, "hash2"];
await TwoFactorService.verifyRecoveryCode("XXXX-YYYY", recoveryData);
expect(bcrypt.compare).toHaveBeenCalledTimes(1);
});
});
describe("validateStepUpSession", () => {
it("should return true for valid session", () => {
const user = {
twoFactorVerifiedAt: new Date(Date.now() - 60000), // 1 minute ago
};
const result = TwoFactorService.validateStepUpSession(user);
expect(result).toBe(true);
});
it("should return false for expired session", () => {
const user = {
twoFactorVerifiedAt: new Date(Date.now() - 600000), // 10 minutes ago
};
const result = TwoFactorService.validateStepUpSession(user, 5); // 5 minute window
expect(result).toBe(false);
});
it("should return false when no verification timestamp", () => {
const user = {
twoFactorVerifiedAt: null,
};
const result = TwoFactorService.validateStepUpSession(user);
expect(result).toBe(false);
});
it("should use custom max age when provided", () => {
const user = {
twoFactorVerifiedAt: new Date(Date.now() - 1200000), // 20 minutes ago
};
const result = TwoFactorService.validateStepUpSession(user, 30); // 30 minute window
expect(result).toBe(true);
});
});
describe("getRemainingRecoveryCodesCount", () => {
it("should return count for new format", () => {
const recoveryData = {
version: 1,
codes: [
{ hash: "hash1", used: false },
{ hash: "hash2", used: true },
{ hash: "hash3", used: false },
],
};
const result =
TwoFactorService.getRemainingRecoveryCodesCount(recoveryData);
expect(result).toBe(2);
});
it("should return count for legacy array format", () => {
const recoveryData = ["hash1", null, "hash3", "hash4", null];
const result =
TwoFactorService.getRemainingRecoveryCodesCount(recoveryData);
expect(result).toBe(3);
});
it("should return 0 for null data", () => {
const result = TwoFactorService.getRemainingRecoveryCodesCount(null);
expect(result).toBe(0);
});
it("should return 0 for undefined data", () => {
const result = TwoFactorService.getRemainingRecoveryCodesCount(undefined);
expect(result).toBe(0);
});
it("should handle empty array", () => {
const result = TwoFactorService.getRemainingRecoveryCodesCount([]);
expect(result).toBe(0);
});
it("should handle all used codes", () => {
const recoveryData = {
version: 1,
codes: [
{ hash: "hash1", used: true },
{ hash: "hash2", used: true },
],
};
const result =
TwoFactorService.getRemainingRecoveryCodesCount(recoveryData);
expect(result).toBe(0);
});
});
describe("isEmailOtpLocked", () => {
it("should return true when max attempts reached", () => {
const result = TwoFactorService.isEmailOtpLocked(3);
expect(result).toBe(true);
});
it("should return true when over max attempts", () => {
const result = TwoFactorService.isEmailOtpLocked(5);
expect(result).toBe(true);
});
it("should return false when under max attempts", () => {
const result = TwoFactorService.isEmailOtpLocked(2);
expect(result).toBe(false);
});
it("should return false for zero attempts", () => {
const result = TwoFactorService.isEmailOtpLocked(0);
expect(result).toBe(false);
});
});
describe("_encryptSecret / _decryptSecret", () => {
it("should encrypt and decrypt correctly", () => {
const secret = "my-test-secret";
const { encrypted, iv } = TwoFactorService._encryptSecret(secret);
const decrypted = TwoFactorService._decryptSecret(encrypted, iv);
expect(decrypted).toBe(secret);
});
it("should throw error when encryption key is missing", () => {
delete process.env.TOTP_ENCRYPTION_KEY;
expect(() => TwoFactorService._encryptSecret("test")).toThrow(
"TOTP_ENCRYPTION_KEY",
);
});
it("should throw error when encryption key is wrong length", () => {
process.env.TOTP_ENCRYPTION_KEY = "short";
expect(() => TwoFactorService._encryptSecret("test")).toThrow(
"64-character hex string",
);
});
});
});

View File

@@ -121,5 +121,215 @@ describe('ConditionCheckService', () => {
)
).rejects.toThrow('Rental not found');
});
it('should allow empty photos array', async () => {
ConditionCheck.create.mockResolvedValue({
id: 'check-123',
rentalId: 'rental-123',
checkType: 'rental_start_renter',
photos: [],
notes: 'No photos',
submittedBy: 'renter-789'
});
const result = await ConditionCheckService.submitConditionCheck(
'rental-123',
'rental_start_renter',
'renter-789',
[],
'No photos'
);
expect(result).toBeTruthy();
});
it('should allow null notes', async () => {
ConditionCheck.create.mockResolvedValue({
id: 'check-123',
rentalId: 'rental-123',
checkType: 'rental_start_renter',
photos: mockPhotos,
notes: null,
submittedBy: 'renter-789'
});
const result = await ConditionCheckService.submitConditionCheck(
'rental-123',
'rental_start_renter',
'renter-789',
mockPhotos
);
expect(result).toBeTruthy();
});
});
describe('validateConditionCheck', () => {
const now = new Date();
const mockRental = {
id: 'rental-123',
ownerId: 'owner-456',
renterId: 'renter-789',
startDateTime: new Date(now.getTime() - 1000 * 60 * 60),
endDateTime: new Date(now.getTime() + 1000 * 60 * 60 * 24),
status: 'confirmed'
};
beforeEach(() => {
Rental.findByPk.mockResolvedValue(mockRental);
ConditionCheck.findOne.mockResolvedValue(null);
});
it('should return canSubmit false when rental not found', async () => {
Rental.findByPk.mockResolvedValue(null);
const result = await ConditionCheckService.validateConditionCheck(
'nonexistent',
'rental_start_renter',
'renter-789'
);
expect(result.canSubmit).toBe(false);
expect(result.reason).toBe('Rental not found');
});
it('should reject owner check by renter', async () => {
const result = await ConditionCheckService.validateConditionCheck(
'rental-123',
'pre_rental_owner',
'renter-789'
);
expect(result.canSubmit).toBe(false);
expect(result.reason).toContain('owner');
});
it('should reject renter check by owner', async () => {
const result = await ConditionCheckService.validateConditionCheck(
'rental-123',
'rental_start_renter',
'owner-456'
);
expect(result.canSubmit).toBe(false);
expect(result.reason).toContain('renter');
});
it('should reject duplicate checks', async () => {
ConditionCheck.findOne.mockResolvedValue({ id: 'existing' });
const result = await ConditionCheckService.validateConditionCheck(
'rental-123',
'rental_start_renter',
'renter-789'
);
expect(result.canSubmit).toBe(false);
expect(result.reason).toContain('already submitted');
});
it('should return canSubmit false for invalid check type', async () => {
const result = await ConditionCheckService.validateConditionCheck(
'rental-123',
'invalid_type',
'owner-456'
);
expect(result.canSubmit).toBe(false);
expect(result.reason).toBe('Invalid check type');
});
it('should allow post_rental_owner anytime', async () => {
const result = await ConditionCheckService.validateConditionCheck(
'rental-123',
'post_rental_owner',
'owner-456'
);
expect(result.canSubmit).toBe(true);
});
});
describe('getConditionChecksForRentals', () => {
it('should return empty array for empty rental IDs', async () => {
const result = await ConditionCheckService.getConditionChecksForRentals([]);
expect(result).toEqual([]);
});
it('should return empty array for null rental IDs', async () => {
const result = await ConditionCheckService.getConditionChecksForRentals(null);
expect(result).toEqual([]);
});
it('should return condition checks for rentals', async () => {
const mockChecks = [
{ id: 'check-1', rentalId: 'rental-1', checkType: 'pre_rental_owner' },
{ id: 'check-2', rentalId: 'rental-1', checkType: 'rental_start_renter' },
{ id: 'check-3', rentalId: 'rental-2', checkType: 'pre_rental_owner' },
];
ConditionCheck.findAll.mockResolvedValue(mockChecks);
const result = await ConditionCheckService.getConditionChecksForRentals(['rental-1', 'rental-2']);
expect(result).toHaveLength(3);
expect(ConditionCheck.findAll).toHaveBeenCalled();
});
});
describe('getAvailableChecks', () => {
it('should return empty array for empty rental IDs', async () => {
const result = await ConditionCheckService.getAvailableChecks('user-123', []);
expect(result).toEqual([]);
});
it('should return empty array for null rental IDs', async () => {
const result = await ConditionCheckService.getAvailableChecks('user-123', null);
expect(result).toEqual([]);
});
it('should return available checks for owner', async () => {
const now = new Date();
const mockRentals = [{
id: 'rental-123',
ownerId: 'owner-456',
renterId: 'renter-789',
itemId: 'item-123',
startDateTime: new Date(now.getTime() + 12 * 60 * 60 * 1000), // 12 hours from now
endDateTime: new Date(now.getTime() + 36 * 60 * 60 * 1000),
status: 'confirmed',
}];
Rental.findAll.mockResolvedValue(mockRentals);
Rental.findByPk.mockResolvedValue(mockRentals[0]);
ConditionCheck.findOne.mockResolvedValue(null);
const result = await ConditionCheckService.getAvailableChecks('owner-456', ['rental-123']);
// Should have pre_rental_owner available
expect(result.length).toBeGreaterThanOrEqual(0);
});
it('should return available checks for renter when rental is active', async () => {
const now = new Date();
const mockRentals = [{
id: 'rental-123',
ownerId: 'owner-456',
renterId: 'renter-789',
itemId: 'item-123',
startDateTime: new Date(now.getTime() - 60 * 60 * 1000), // 1 hour ago
endDateTime: new Date(now.getTime() + 24 * 60 * 60 * 1000),
status: 'confirmed',
}];
Rental.findAll.mockResolvedValue(mockRentals);
Rental.findByPk.mockResolvedValue(mockRentals[0]);
ConditionCheck.findOne.mockResolvedValue(null);
const result = await ConditionCheckService.getAvailableChecks('renter-789', ['rental-123']);
// May have rental_start_renter available
expect(Array.isArray(result)).toBe(true);
});
});
});

Some files were not shown because too many files have changed in this diff Show More