Compare commits
58 Commits
76102d48a9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d3c124d3e | ||
|
|
420e0efeb4 | ||
|
|
23ca97cea9 | ||
|
|
b5755109a7 | ||
|
|
0136b74ee0 | ||
|
|
cae9e7e473 | ||
|
|
fcce10e664 | ||
|
|
28554acc2d | ||
|
|
1923ffc251 | ||
|
|
d4362074f5 | ||
|
|
75ddb2908f | ||
|
|
41d8cf4c04 | ||
|
|
e6c56ae90f | ||
|
|
d570f607d3 | ||
|
|
f9c2057e64 | ||
|
|
f58178a253 | ||
|
|
cf97dffbfb | ||
|
|
63385e049c | ||
|
|
35d5050286 | ||
|
|
826e4f2ed5 | ||
|
|
a3ef343326 | ||
|
|
1b6f782648 | ||
|
|
18a37e2996 | ||
|
|
7b12e59f0c | ||
|
|
c6b531d12a | ||
|
|
942867d94c | ||
|
|
c560d9e13c | ||
|
|
2242ed810e | ||
|
|
e7081620a9 | ||
|
|
7f2f45b1c2 | ||
|
|
da82872297 | ||
|
|
f5fdcbfb82 | ||
|
|
2ee5571b5b | ||
|
|
89dd99c263 | ||
|
|
c2ebe8709d | ||
|
|
6c9fd8aec2 | ||
|
|
80d643c65c | ||
|
|
415bcc5021 | ||
|
|
86cb8b3fe0 | ||
|
|
860b6d6160 | ||
|
|
8aea3c38ed | ||
|
|
e2e32f7632 | ||
|
|
0ea35e9d6f | ||
|
|
8585633907 | ||
|
|
3042a9007f | ||
|
|
5248c3dc39 | ||
|
|
65b7574be2 | ||
|
|
bcb917c959 | ||
|
|
8b9b92d848 | ||
|
|
550de32a41 | ||
|
|
5eb877b7c2 | ||
|
|
b56e031ee5 | ||
|
|
1203fb7996 | ||
|
|
28c0b4976d | ||
|
|
ec84b8354e | ||
|
|
8809a012d5 | ||
|
|
a0d63ff04a | ||
|
|
e408880cae |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -17,6 +17,7 @@ node_modules/
|
|||||||
.env.development.local
|
.env.development.local
|
||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
|
.env.dev
|
||||||
.mcp.json
|
.mcp.json
|
||||||
.claude
|
.claude
|
||||||
|
|
||||||
@@ -65,3 +66,9 @@ frontend/.env.local
|
|||||||
# Uploads
|
# Uploads
|
||||||
uploads/
|
uploads/
|
||||||
temp/
|
temp/
|
||||||
|
|
||||||
|
# Infrastructure CDK
|
||||||
|
infrastructure/cdk/dist/
|
||||||
|
infrastructure/cdk/cdk.out/
|
||||||
|
infrastructure/cdk/*.js
|
||||||
|
infrastructure/cdk/*.d.ts
|
||||||
113
README.md
113
README.md
@@ -1,112 +1 @@
|
|||||||
# Rentall App
|
# Village Share
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@@ -1,6 +1,5 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
uploads/
|
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
127
backend/S3.md
127
backend/S3.md
@@ -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
5
backend/babel.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: [
|
||||||
|
['@babel/preset-env', { targets: { node: 'current' } }]
|
||||||
|
]
|
||||||
|
};
|
||||||
@@ -18,7 +18,7 @@ function getAWSCredentials() {
|
|||||||
*/
|
*/
|
||||||
function getAWSConfig() {
|
function getAWSConfig() {
|
||||||
const config = {
|
const config = {
|
||||||
region: process.env.AWS_REGION || "us-east-1",
|
region: process.env.AWS_REGION,
|
||||||
};
|
};
|
||||||
|
|
||||||
const credentials = getAWSCredentials();
|
const credentials = getAWSCredentials();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ if (!process.env.DB_NAME && process.env.NODE_ENV) {
|
|||||||
const result = dotenv.config({ path: envFile });
|
const result = dotenv.config({ path: envFile });
|
||||||
if (result.error && process.env.NODE_ENV !== "production") {
|
if (result.error && process.env.NODE_ENV !== "production") {
|
||||||
console.warn(
|
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,
|
password: process.env.DB_PASSWORD,
|
||||||
database: process.env.DB_NAME,
|
database: process.env.DB_NAME,
|
||||||
host: process.env.DB_HOST,
|
host: process.env.DB_HOST,
|
||||||
port: process.env.DB_PORT || 5432,
|
port: process.env.DB_PORT,
|
||||||
dialect: "postgres",
|
dialect: "postgres",
|
||||||
logging: false,
|
logging: false,
|
||||||
pool: {
|
pool: {
|
||||||
@@ -52,7 +52,7 @@ const sequelize = new Sequelize(
|
|||||||
dialect: dbConfig.dialect,
|
dialect: dbConfig.dialect,
|
||||||
logging: dbConfig.logging,
|
logging: dbConfig.logging,
|
||||||
pool: dbConfig.pool,
|
pool: dbConfig.pool,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Export the sequelize instance as default (for backward compatibility)
|
// Export the sequelize instance as default (for backward compatibility)
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ module.exports = {
|
|||||||
testMatch: ['**/tests/unit/**/*.test.js'],
|
testMatch: ['**/tests/unit/**/*.test.js'],
|
||||||
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
|
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
|
||||||
testTimeout: 10000,
|
testTimeout: 10000,
|
||||||
|
transformIgnorePatterns: [
|
||||||
|
'node_modules/(?!(@scure|@otplib|otplib|@noble)/)'
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
displayName: 'integration',
|
displayName: 'integration',
|
||||||
@@ -13,6 +16,9 @@ module.exports = {
|
|||||||
testMatch: ['**/tests/integration/**/*.test.js'],
|
testMatch: ['**/tests/integration/**/*.test.js'],
|
||||||
setupFilesAfterEnv: ['<rootDir>/tests/integration-setup.js'],
|
setupFilesAfterEnv: ['<rootDir>/tests/integration-setup.js'],
|
||||||
testTimeout: 30000,
|
testTimeout: 30000,
|
||||||
|
transformIgnorePatterns: [
|
||||||
|
'node_modules/(?!(@scure|@otplib|otplib|@noble)/)'
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// Run tests sequentially to avoid module cache conflicts between unit and integration tests
|
// Run tests sequentially to avoid module cache conflicts between unit and integration tests
|
||||||
@@ -23,7 +29,10 @@ module.exports = {
|
|||||||
'!**/node_modules/**',
|
'!**/node_modules/**',
|
||||||
'!**/coverage/**',
|
'!**/coverage/**',
|
||||||
'!**/tests/**',
|
'!**/tests/**',
|
||||||
'!jest.config.js'
|
'!**/migrations/**',
|
||||||
|
'!**/scripts/**',
|
||||||
|
'!jest.config.js',
|
||||||
|
'!babel.config.js',
|
||||||
],
|
],
|
||||||
coverageReporters: ['text', 'lcov', 'html'],
|
coverageReporters: ['text', 'lcov', 'html'],
|
||||||
coverageThreshold: {
|
coverageThreshold: {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -33,6 +33,14 @@ const authenticateToken = async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.",
|
||||||
|
code: "USER_BANNED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Validate JWT version to invalidate old tokens after password change
|
// Validate JWT version to invalidate old tokens after password change
|
||||||
if (decoded.jwtVersion !== user.jwtVersion) {
|
if (decoded.jwtVersion !== user.jwtVersion) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
@@ -93,6 +101,12 @@ const optionalAuth = async (req, res, next) => {
|
|||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Banned users are treated as unauthenticated for optional auth
|
||||||
|
if (user.isBanned) {
|
||||||
|
req.user = null;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
// Validate JWT version to invalidate old tokens after password change
|
// Validate JWT version to invalidate old tokens after password change
|
||||||
if (decoded.jwtVersion !== user.jwtVersion) {
|
if (decoded.jwtVersion !== user.jwtVersion) {
|
||||||
req.user = null;
|
req.user = null;
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
const csrf = require("csrf");
|
const csrf = require("csrf");
|
||||||
const cookieParser = require("cookie-parser");
|
const cookieParser = require("cookie-parser");
|
||||||
|
const logger = require("../utils/logger");
|
||||||
|
|
||||||
// Initialize CSRF token generator
|
// Initialize CSRF token generator
|
||||||
const tokens = new csrf();
|
const tokens = new csrf();
|
||||||
|
|
||||||
// Generate a secret for signing tokens
|
// Use persistent secret from environment variable to prevent token invalidation on restart
|
||||||
const secret = tokens.secretSync();
|
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
|
// CSRF middleware using double submit cookie pattern
|
||||||
const csrfProtection = (req, res, next) => {
|
const csrfProtection = (req, res, next) => {
|
||||||
@@ -15,8 +28,7 @@ const csrfProtection = (req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get token from header or body
|
// Get token from header or body
|
||||||
const token =
|
const token = req.headers["x-csrf-token"] || req.body.csrfToken;
|
||||||
req.headers["x-csrf-token"] || req.body.csrfToken || req.query.csrfToken;
|
|
||||||
|
|
||||||
// Get token from cookie
|
// Get token from cookie
|
||||||
const cookieToken = req.cookies && req.cookies["csrf-token"];
|
const cookieToken = req.cookies && req.cookies["csrf-token"];
|
||||||
@@ -47,7 +59,7 @@ const generateCSRFToken = (req, res, next) => {
|
|||||||
// Set token in cookie (httpOnly for security)
|
// Set token in cookie (httpOnly for security)
|
||||||
res.cookie("csrf-token", token, {
|
res.cookie("csrf-token", token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV !== "dev",
|
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
|
||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
maxAge: 60 * 60 * 1000, // 1 hour
|
maxAge: 60 * 60 * 1000, // 1 hour
|
||||||
});
|
});
|
||||||
@@ -67,7 +79,7 @@ const getCSRFToken = (req, res) => {
|
|||||||
|
|
||||||
res.cookie("csrf-token", token, {
|
res.cookie("csrf-token", token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV !== "dev",
|
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
|
||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
maxAge: 60 * 60 * 1000,
|
maxAge: 60 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -207,6 +207,57 @@ const authRateLimiters = {
|
|||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
handler: createRateLimitHandler('general'),
|
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 = {
|
module.exports = {
|
||||||
@@ -223,6 +274,12 @@ module.exports = {
|
|||||||
emailVerificationLimiter: authRateLimiters.emailVerification,
|
emailVerificationLimiter: authRateLimiters.emailVerification,
|
||||||
generalLimiter: authRateLimiters.general,
|
generalLimiter: authRateLimiters.general,
|
||||||
|
|
||||||
|
// Two-Factor Authentication rate limiters
|
||||||
|
twoFactorVerificationLimiter: authRateLimiters.twoFactorVerification,
|
||||||
|
twoFactorSetupLimiter: authRateLimiters.twoFactorSetup,
|
||||||
|
recoveryCodeLimiter: authRateLimiters.recoveryCode,
|
||||||
|
emailOtpSendLimiter: authRateLimiters.emailOtpSend,
|
||||||
|
|
||||||
// Burst protection
|
// Burst protection
|
||||||
burstProtection,
|
burstProtection,
|
||||||
|
|
||||||
|
|||||||
73
backend/middleware/stepUpAuth.js
Normal file
73
backend/middleware/stepUpAuth.js
Normal 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 };
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
const { body, validationResult } = require("express-validator");
|
const { body, query, validationResult } = require("express-validator");
|
||||||
const DOMPurify = require("dompurify");
|
const DOMPurify = require("dompurify");
|
||||||
const { JSDOM } = require("jsdom");
|
const { JSDOM } = require("jsdom");
|
||||||
|
|
||||||
@@ -316,6 +316,60 @@ const validateFeedback = [
|
|||||||
handleValidationErrors,
|
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 = {
|
module.exports = {
|
||||||
sanitizeInput,
|
sanitizeInput,
|
||||||
handleValidationErrors,
|
handleValidationErrors,
|
||||||
@@ -328,4 +382,10 @@ module.exports = {
|
|||||||
validateResetPassword,
|
validateResetPassword,
|
||||||
validateVerifyResetToken,
|
validateVerifyResetToken,
|
||||||
validateFeedback,
|
validateFeedback,
|
||||||
|
validateCoordinatesQuery,
|
||||||
|
validateCoordinatesBody,
|
||||||
|
// Two-Factor Authentication
|
||||||
|
validateTotpCode,
|
||||||
|
validateEmailOtp,
|
||||||
|
validateRecoveryCode,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
down: async (queryInterface, Sequelize) => {
|
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", {
|
await queryInterface.changeColumn("Items", "images", {
|
||||||
type: Sequelize.ARRAY(Sequelize.STRING),
|
type: Sequelize.ARRAY(Sequelize.STRING),
|
||||||
defaultValue: [],
|
defaultValue: [],
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
down: async (queryInterface, Sequelize) => {
|
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([
|
await Promise.all([
|
||||||
queryInterface.changeColumn("Users", "profileImage", {
|
queryInterface.changeColumn("Users", "profileImage", {
|
||||||
type: Sequelize.STRING,
|
type: Sequelize.STRING,
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
// Add paymentFailedNotifiedAt - tracks when owner notified renter about failed payment
|
||||||
|
await queryInterface.addColumn("Rentals", "paymentFailedNotifiedAt", {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add paymentMethodUpdatedAt - tracks last payment method update for rate limiting
|
||||||
|
await queryInterface.addColumn("Rentals", "paymentMethodUpdatedAt", {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add paymentMethodUpdateCount - count of updates within time window for rate limiting
|
||||||
|
await queryInterface.addColumn("Rentals", "paymentMethodUpdateCount", {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.removeColumn("Rentals", "paymentMethodUpdateCount");
|
||||||
|
await queryInterface.removeColumn("Rentals", "paymentMethodUpdatedAt");
|
||||||
|
await queryInterface.removeColumn("Rentals", "paymentFailedNotifiedAt");
|
||||||
|
},
|
||||||
|
};
|
||||||
95
backend/migrations/20260105161056-create-image-metadata.js
Normal file
95
backend/migrations/20260105161056-create-image-metadata.js
Normal 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");
|
||||||
|
},
|
||||||
|
};
|
||||||
41
backend/migrations/20260106000001-add-user-ban-fields.js
Normal file
41
backend/migrations/20260106000001-add-user-ban-fields.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
// isBanned - boolean flag indicating if user is banned
|
||||||
|
await queryInterface.addColumn("Users", "isBanned", {
|
||||||
|
type: Sequelize.BOOLEAN,
|
||||||
|
defaultValue: false,
|
||||||
|
allowNull: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// bannedAt - timestamp when ban was applied
|
||||||
|
await queryInterface.addColumn("Users", "bannedAt", {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// bannedBy - UUID of admin who applied the ban
|
||||||
|
await queryInterface.addColumn("Users", "bannedBy", {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: "Users",
|
||||||
|
key: "id",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// banReason - reason provided by admin for the ban
|
||||||
|
await queryInterface.addColumn("Users", "banReason", {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.removeColumn("Users", "banReason");
|
||||||
|
await queryInterface.removeColumn("Users", "bannedBy");
|
||||||
|
await queryInterface.removeColumn("Users", "bannedAt");
|
||||||
|
await queryInterface.removeColumn("Users", "isBanned");
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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";'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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");
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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");
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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";
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
};
|
||||||
107
backend/migrations/20260115000001-add-two-factor-auth-fields.js
Normal file
107
backend/migrations/20260115000001-add-two-factor-auth-fields.js
Normal 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";'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
303
backend/migrations/README.md
Normal file
303
backend/migrations/README.md
Normal 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" },
|
||||||
|
});
|
||||||
|
```
|
||||||
88
backend/models/ImageMetadata.js
Normal file
88
backend/models/ImageMetadata.js
Normal 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;
|
||||||
@@ -67,11 +67,11 @@ const Rental = sequelize.define("Rental", {
|
|||||||
allowNull: false,
|
allowNull: false,
|
||||||
},
|
},
|
||||||
paymentStatus: {
|
paymentStatus: {
|
||||||
type: DataTypes.ENUM("pending", "paid", "refunded", "not_required"),
|
type: DataTypes.ENUM("pending", "paid", "refunded", "not_required", "requires_action"),
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
},
|
},
|
||||||
payoutStatus: {
|
payoutStatus: {
|
||||||
type: DataTypes.ENUM("pending", "completed", "failed"),
|
type: DataTypes.ENUM("pending", "completed", "failed", "on_hold"),
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
},
|
},
|
||||||
payoutProcessedAt: {
|
payoutProcessedAt: {
|
||||||
@@ -94,6 +94,52 @@ const Rental = sequelize.define("Rental", {
|
|||||||
bankDepositFailureCode: {
|
bankDepositFailureCode: {
|
||||||
type: DataTypes.STRING,
|
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
|
// Refund tracking fields
|
||||||
refundAmount: {
|
refundAmount: {
|
||||||
type: DataTypes.DECIMAL(10, 2),
|
type: DataTypes.DECIMAL(10, 2),
|
||||||
@@ -131,6 +177,21 @@ const Rental = sequelize.define("Rental", {
|
|||||||
chargedAt: {
|
chargedAt: {
|
||||||
type: DataTypes.DATE,
|
type: DataTypes.DATE,
|
||||||
},
|
},
|
||||||
|
// Payment failure notification tracking
|
||||||
|
paymentFailedNotifiedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
},
|
||||||
|
paymentFailedReason: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
// Payment method update rate limiting
|
||||||
|
paymentMethodUpdatedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
},
|
||||||
|
paymentMethodUpdateCount: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
deliveryMethod: {
|
deliveryMethod: {
|
||||||
type: DataTypes.ENUM("pickup", "delivery"),
|
type: DataTypes.ENUM("pickup", "delivery"),
|
||||||
defaultValue: "pickup",
|
defaultValue: "pickup",
|
||||||
|
|||||||
@@ -124,6 +124,24 @@ const User = sequelize.define(
|
|||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
allowNull: true,
|
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: {
|
loginAttempts: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
defaultValue: 0,
|
defaultValue: 0,
|
||||||
@@ -142,6 +160,23 @@ const User = sequelize.define(
|
|||||||
defaultValue: "user",
|
defaultValue: "user",
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
},
|
},
|
||||||
|
isBanned: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
bannedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
bannedBy: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
banReason: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
itemRequestNotificationRadius: {
|
itemRequestNotificationRadius: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
defaultValue: 10,
|
defaultValue: 10,
|
||||||
@@ -156,6 +191,66 @@ const User = sequelize.define(
|
|||||||
defaultValue: 0,
|
defaultValue: 0,
|
||||||
allowNull: true,
|
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: {
|
hooks: {
|
||||||
@@ -170,7 +265,7 @@ const User = sequelize.define(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
User.prototype.comparePassword = async function (password) {
|
User.prototype.comparePassword = async function (password) {
|
||||||
@@ -343,4 +438,278 @@ User.prototype.resetPassword = async function (newPassword) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Ban user method - sets ban fields and invalidates all sessions
|
||||||
|
User.prototype.banUser = async function (adminId, reason) {
|
||||||
|
return this.update({
|
||||||
|
isBanned: true,
|
||||||
|
bannedAt: new Date(),
|
||||||
|
bannedBy: adminId,
|
||||||
|
banReason: reason,
|
||||||
|
// Increment JWT version to immediately invalidate all sessions
|
||||||
|
jwtVersion: this.jwtVersion + 1,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Unban user method - clears ban fields
|
||||||
|
User.prototype.unbanUser = async function () {
|
||||||
|
return this.update({
|
||||||
|
isBanned: false,
|
||||||
|
bannedAt: null,
|
||||||
|
bannedBy: null,
|
||||||
|
banReason: null,
|
||||||
|
// 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(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = User;
|
module.exports = User;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const UserAddress = require("./UserAddress");
|
|||||||
const ConditionCheck = require("./ConditionCheck");
|
const ConditionCheck = require("./ConditionCheck");
|
||||||
const AlphaInvitation = require("./AlphaInvitation");
|
const AlphaInvitation = require("./AlphaInvitation");
|
||||||
const Feedback = require("./Feedback");
|
const Feedback = require("./Feedback");
|
||||||
|
const ImageMetadata = require("./ImageMetadata");
|
||||||
|
|
||||||
User.hasMany(Item, { as: "ownedItems", foreignKey: "ownerId" });
|
User.hasMany(Item, { as: "ownedItems", foreignKey: "ownerId" });
|
||||||
Item.belongsTo(User, { as: "owner", foreignKey: "ownerId" });
|
Item.belongsTo(User, { as: "owner", foreignKey: "ownerId" });
|
||||||
@@ -91,4 +92,5 @@ module.exports = {
|
|||||||
ConditionCheck,
|
ConditionCheck,
|
||||||
AlphaInvitation,
|
AlphaInvitation,
|
||||||
Feedback,
|
Feedback,
|
||||||
|
ImageMetadata,
|
||||||
};
|
};
|
||||||
|
|||||||
3124
backend/package-lock.json
generated
3124
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,7 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.940.0",
|
"@aws-sdk/client-s3": "^3.940.0",
|
||||||
|
"@aws-sdk/client-scheduler": "^3.896.0",
|
||||||
"@aws-sdk/client-ses": "^3.896.0",
|
"@aws-sdk/client-ses": "^3.896.0",
|
||||||
"@aws-sdk/credential-providers": "^3.901.0",
|
"@aws-sdk/credential-providers": "^3.901.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.940.0",
|
"@aws-sdk/s3-request-presigner": "^3.940.0",
|
||||||
@@ -54,8 +55,9 @@
|
|||||||
"jsdom": "^27.0.0",
|
"jsdom": "^27.0.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"morgan": "^1.10.1",
|
"morgan": "^1.10.1",
|
||||||
"node-cron": "^3.0.3",
|
"otplib": "^13.1.1",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"sequelize": "^6.37.7",
|
"sequelize": "^6.37.7",
|
||||||
"sequelize-cli": "^6.6.3",
|
"sequelize-cli": "^6.6.3",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
@@ -65,7 +67,10 @@
|
|||||||
"winston-daily-rotate-file": "^5.0.0"
|
"winston-daily-rotate-file": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.28.6",
|
||||||
|
"@babel/preset-env": "^7.28.6",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
|
"babel-jest": "^30.2.0",
|
||||||
"jest": "^30.1.3",
|
"jest": "^30.1.3",
|
||||||
"nodemon": "^3.1.10",
|
"nodemon": "^3.1.10",
|
||||||
"sequelize-mock": "^0.10.2",
|
"sequelize-mock": "^0.10.2",
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ router.post("/validate-code", alphaCodeValidationLimiter, async (req, res) => {
|
|||||||
|
|
||||||
res.cookie("alphaAccessCode", cookieData, {
|
res.cookie("alphaAccessCode", cookieData, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
|
||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
|
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,8 +28,7 @@ const router = express.Router();
|
|||||||
const googleClient = new OAuth2Client(
|
const googleClient = new OAuth2Client(
|
||||||
process.env.GOOGLE_CLIENT_ID,
|
process.env.GOOGLE_CLIENT_ID,
|
||||||
process.env.GOOGLE_CLIENT_SECRET,
|
process.env.GOOGLE_CLIENT_SECRET,
|
||||||
process.env.GOOGLE_REDIRECT_URI ||
|
process.env.GOOGLE_REDIRECT_URI,
|
||||||
"http://localhost:3000/auth/google/callback"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get CSRF token endpoint
|
// Get CSRF token endpoint
|
||||||
@@ -120,7 +119,7 @@ router.post(
|
|||||||
try {
|
try {
|
||||||
await emailServices.auth.sendVerificationEmail(
|
await emailServices.auth.sendVerificationEmail(
|
||||||
user,
|
user,
|
||||||
user.verificationToken
|
user.verificationToken,
|
||||||
);
|
);
|
||||||
verificationEmailSent = true;
|
verificationEmailSent = true;
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
@@ -137,28 +136,26 @@ router.post(
|
|||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ id: user.id, jwtVersion: user.jwtVersion },
|
{ id: user.id, jwtVersion: user.jwtVersion },
|
||||||
process.env.JWT_ACCESS_SECRET,
|
process.env.JWT_ACCESS_SECRET,
|
||||||
{ expiresIn: "15m" } // Short-lived access token
|
{ expiresIn: "15m" }, // Short-lived access token
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshToken = jwt.sign(
|
const refreshToken = jwt.sign(
|
||||||
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
|
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
|
||||||
process.env.JWT_REFRESH_SECRET,
|
process.env.JWT_REFRESH_SECRET,
|
||||||
{ expiresIn: "7d" }
|
{ expiresIn: "7d" },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set tokens as httpOnly cookies
|
// Set tokens as httpOnly cookies
|
||||||
res.cookie("accessToken", token, {
|
res.cookie("accessToken", token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure:
|
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
|
||||||
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
|
|
||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
maxAge: 15 * 60 * 1000, // 15 minutes
|
maxAge: 15 * 60 * 1000, // 15 minutes
|
||||||
});
|
});
|
||||||
|
|
||||||
res.cookie("refreshToken", refreshToken, {
|
res.cookie("refreshToken", refreshToken, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure:
|
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
|
||||||
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
|
|
||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||||
});
|
});
|
||||||
@@ -190,7 +187,7 @@ router.post(
|
|||||||
});
|
});
|
||||||
res.status(500).json({ error: "Registration failed. Please try again." });
|
res.status(500).json({ error: "Registration failed. Please try again." });
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
@@ -219,6 +216,15 @@ 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.",
|
||||||
|
code: "USER_BANNED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Verify password
|
// Verify password
|
||||||
const isPasswordValid = await user.comparePassword(password);
|
const isPasswordValid = await user.comparePassword(password);
|
||||||
|
|
||||||
@@ -236,28 +242,26 @@ router.post(
|
|||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ id: user.id, jwtVersion: user.jwtVersion },
|
{ id: user.id, jwtVersion: user.jwtVersion },
|
||||||
process.env.JWT_ACCESS_SECRET,
|
process.env.JWT_ACCESS_SECRET,
|
||||||
{ expiresIn: "15m" } // Short-lived access token
|
{ expiresIn: "15m" }, // Short-lived access token
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshToken = jwt.sign(
|
const refreshToken = jwt.sign(
|
||||||
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
|
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
|
||||||
process.env.JWT_REFRESH_SECRET,
|
process.env.JWT_REFRESH_SECRET,
|
||||||
{ expiresIn: "7d" }
|
{ expiresIn: "7d" },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set tokens as httpOnly cookies
|
// Set tokens as httpOnly cookies
|
||||||
res.cookie("accessToken", token, {
|
res.cookie("accessToken", token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure:
|
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
|
||||||
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
|
|
||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
maxAge: 15 * 60 * 1000, // 15 minutes
|
maxAge: 15 * 60 * 1000, // 15 minutes
|
||||||
});
|
});
|
||||||
|
|
||||||
res.cookie("refreshToken", refreshToken, {
|
res.cookie("refreshToken", refreshToken, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure:
|
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
|
||||||
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
|
|
||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||||
});
|
});
|
||||||
@@ -288,7 +292,7 @@ router.post(
|
|||||||
});
|
});
|
||||||
res.status(500).json({ error: "Login failed. Please try again." });
|
res.status(500).json({ error: "Login failed. Please try again." });
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
@@ -310,9 +314,7 @@ router.post(
|
|||||||
// Exchange authorization code for tokens
|
// Exchange authorization code for tokens
|
||||||
const { tokens } = await googleClient.getToken({
|
const { tokens } = await googleClient.getToken({
|
||||||
code,
|
code,
|
||||||
redirect_uri:
|
redirect_uri: process.env.GOOGLE_REDIRECT_URI,
|
||||||
process.env.GOOGLE_REDIRECT_URI ||
|
|
||||||
"http://localhost:3000/auth/google/callback",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify the ID token from the token response
|
// Verify the ID token from the token response
|
||||||
@@ -406,32 +408,39 @@ 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.",
|
||||||
|
code: "USER_BANNED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Generate JWT tokens
|
// Generate JWT tokens
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ id: user.id, jwtVersion: user.jwtVersion },
|
{ id: user.id, jwtVersion: user.jwtVersion },
|
||||||
process.env.JWT_ACCESS_SECRET,
|
process.env.JWT_ACCESS_SECRET,
|
||||||
{ expiresIn: "15m" }
|
{ expiresIn: "15m" },
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshToken = jwt.sign(
|
const refreshToken = jwt.sign(
|
||||||
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
|
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
|
||||||
process.env.JWT_REFRESH_SECRET,
|
process.env.JWT_REFRESH_SECRET,
|
||||||
{ expiresIn: "7d" }
|
{ expiresIn: "7d" },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set tokens as httpOnly cookies
|
// Set tokens as httpOnly cookies
|
||||||
res.cookie("accessToken", token, {
|
res.cookie("accessToken", token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure:
|
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
|
||||||
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
|
|
||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
maxAge: 15 * 60 * 1000,
|
maxAge: 15 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.cookie("refreshToken", refreshToken, {
|
res.cookie("refreshToken", refreshToken, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure:
|
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
|
||||||
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
|
|
||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||||
});
|
});
|
||||||
@@ -478,7 +487,7 @@ router.post(
|
|||||||
.status(500)
|
.status(500)
|
||||||
.json({ error: "Google authentication failed. Please try again." });
|
.json({ error: "Google authentication failed. Please try again." });
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Email verification endpoint
|
// Email verification endpoint
|
||||||
@@ -595,7 +604,7 @@ router.post(
|
|||||||
error: "Email verification failed. Please try again.",
|
error: "Email verification failed. Please try again.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Resend verification email endpoint
|
// Resend verification email endpoint
|
||||||
@@ -640,7 +649,7 @@ router.post(
|
|||||||
try {
|
try {
|
||||||
await emailServices.auth.sendVerificationEmail(
|
await emailServices.auth.sendVerificationEmail(
|
||||||
user,
|
user,
|
||||||
user.verificationToken
|
user.verificationToken,
|
||||||
);
|
);
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
@@ -681,7 +690,7 @@ router.post(
|
|||||||
error: "Failed to resend verification email. Please try again.",
|
error: "Failed to resend verification email. Please try again.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Refresh token endpoint
|
// Refresh token endpoint
|
||||||
@@ -714,17 +723,26 @@ 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.",
|
||||||
|
code: "USER_BANNED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Generate new access token
|
// Generate new access token
|
||||||
const newAccessToken = jwt.sign(
|
const newAccessToken = jwt.sign(
|
||||||
{ id: user.id, jwtVersion: user.jwtVersion },
|
{ id: user.id, jwtVersion: user.jwtVersion },
|
||||||
process.env.JWT_ACCESS_SECRET,
|
process.env.JWT_ACCESS_SECRET,
|
||||||
{ expiresIn: "15m" }
|
{ expiresIn: "15m" },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set new access token cookie
|
// Set new access token cookie
|
||||||
res.cookie("accessToken", newAccessToken, {
|
res.cookie("accessToken", newAccessToken, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
|
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
|
||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
maxAge: 15 * 60 * 1000,
|
maxAge: 15 * 60 * 1000,
|
||||||
});
|
});
|
||||||
@@ -833,7 +851,7 @@ router.post(
|
|||||||
"Password reset requested for non-existent or OAuth user",
|
"Password reset requested for non-existent or OAuth user",
|
||||||
{
|
{
|
||||||
email: email,
|
email: email,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -853,7 +871,7 @@ router.post(
|
|||||||
error: "Failed to process password reset request. Please try again.",
|
error: "Failed to process password reset request. Please try again.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Verify reset token endpoint (optional - for frontend UX)
|
// Verify reset token endpoint (optional - for frontend UX)
|
||||||
@@ -907,7 +925,7 @@ router.post(
|
|||||||
error: "Failed to verify reset token. Please try again.",
|
error: "Failed to verify reset token. Please try again.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reset password endpoint
|
// Reset password endpoint
|
||||||
@@ -990,7 +1008,7 @@ router.post(
|
|||||||
error: "Failed to reset password. Please try again.",
|
error: "Failed to reset password. Please try again.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -7,6 +7,49 @@ const { IMAGE_LIMITS } = require("../config/imageLimits");
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Get condition checks for multiple rentals in a single request (batch)
|
||||||
|
router.get("/batch", authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { rentalIds } = req.query;
|
||||||
|
|
||||||
|
if (!rentalIds) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
conditionChecks: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = rentalIds.split(",").filter((id) => id.trim());
|
||||||
|
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
conditionChecks: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditionChecks =
|
||||||
|
await ConditionCheckService.getConditionChecksForRentals(ids);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
conditionChecks,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error("Error fetching batch condition checks", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
rentalIds: req.query.rentalIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Failed to fetch condition checks",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Submit a condition check
|
// Submit a condition check
|
||||||
router.post("/:rentalId", authenticateToken, async (req, res) => {
|
router.post("/:rentalId", authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -20,9 +63,13 @@ router.post("/:rentalId", authenticateToken, async (req, res) => {
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Validate S3 keys format and folder
|
// Validate S3 keys format and folder
|
||||||
const keyValidation = validateS3Keys(imageFilenamesArray, "condition-checks", {
|
const keyValidation = validateS3Keys(
|
||||||
maxKeys: IMAGE_LIMITS.conditionChecks,
|
imageFilenamesArray,
|
||||||
});
|
"condition-checks",
|
||||||
|
{
|
||||||
|
maxKeys: IMAGE_LIMITS.conditionChecks,
|
||||||
|
}
|
||||||
|
);
|
||||||
if (!keyValidation.valid) {
|
if (!keyValidation.valid) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -69,69 +116,16 @@ router.post("/:rentalId", authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get condition checks for a rental
|
|
||||||
router.get("/:rentalId", authenticateToken, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { rentalId } = req.params;
|
|
||||||
|
|
||||||
const conditionChecks = await ConditionCheckService.getConditionChecks(
|
|
||||||
rentalId
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
conditionChecks,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
|
||||||
reqLogger.error("Error fetching condition checks", {
|
|
||||||
error: error.message,
|
|
||||||
stack: error.stack,
|
|
||||||
rentalId: req.params.rentalId,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: "Failed to fetch condition checks",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get condition check timeline for a rental
|
|
||||||
router.get("/:rentalId/timeline", authenticateToken, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { rentalId } = req.params;
|
|
||||||
|
|
||||||
const timeline = await ConditionCheckService.getConditionCheckTimeline(
|
|
||||||
rentalId
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
timeline,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
|
||||||
reqLogger.error("Error fetching condition check timeline", {
|
|
||||||
error: error.message,
|
|
||||||
stack: error.stack,
|
|
||||||
rentalId: req.params.rentalId,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: error.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get available condition checks for current user
|
// Get available condition checks for current user
|
||||||
router.get("/", authenticateToken, async (req, res) => {
|
router.get("/", authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
|
const { rentalIds } = req.query;
|
||||||
|
const ids = rentalIds ? rentalIds.split(",").filter((id) => id.trim()) : [];
|
||||||
|
|
||||||
const availableChecks = await ConditionCheckService.getAvailableChecks(
|
const availableChecks = await ConditionCheckService.getAvailableChecks(
|
||||||
userId
|
userId,
|
||||||
|
ids
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const express = require('express');
|
|||||||
const { Op } = require('sequelize');
|
const { Op } = require('sequelize');
|
||||||
const { ForumPost, ForumComment, PostTag, User } = require('../models');
|
const { ForumPost, ForumComment, PostTag, User } = require('../models');
|
||||||
const { authenticateToken, requireAdmin, optionalAuth } = require('../middleware/auth');
|
const { authenticateToken, requireAdmin, optionalAuth } = require('../middleware/auth');
|
||||||
|
const { validateCoordinatesBody, handleValidationErrors } = require('../middleware/validation');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const emailServices = require('../services/email');
|
const emailServices = require('../services/email');
|
||||||
const googleMapsService = require('../services/googleMapsService');
|
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
|
// 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 {
|
try {
|
||||||
// Require email verification
|
// Require email verification
|
||||||
if (!req.user.isVerified) {
|
if (!req.user.isVerified) {
|
||||||
@@ -729,7 +730,7 @@ router.patch('/posts/:id/status', authenticateToken, async (req, res, next) => {
|
|||||||
stack: emailError.stack,
|
stack: emailError.stack,
|
||||||
postId: req.params.id
|
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,
|
commentId: commentId,
|
||||||
postId: req.params.id
|
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,
|
commentId: comment.id,
|
||||||
postId: req.params.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,
|
stack: emailError.stack,
|
||||||
postId: req.params.id
|
postId: req.params.id
|
||||||
});
|
});
|
||||||
console.error("Email notification error:", emailError);
|
logger.error("Email notification error", { error: emailError });
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const express = require("express");
|
|||||||
const { Op, Sequelize } = require("sequelize");
|
const { Op, Sequelize } = require("sequelize");
|
||||||
const { Item, User, Rental, sequelize } = require("../models"); // Import from models/index.js to get models with associations
|
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 { authenticateToken, requireVerifiedEmail, requireAdmin, optionalAuth } = require("../middleware/auth");
|
||||||
|
const { validateCoordinatesQuery, validateCoordinatesBody, handleValidationErrors } = require("../middleware/validation");
|
||||||
const logger = require("../utils/logger");
|
const logger = require("../utils/logger");
|
||||||
const { validateS3Keys } = require("../utils/s3KeyValidator");
|
const { validateS3Keys } = require("../utils/s3KeyValidator");
|
||||||
const { IMAGE_LIMITS } = require("../config/imageLimits");
|
const { IMAGE_LIMITS } = require("../config/imageLimits");
|
||||||
@@ -53,7 +54,7 @@ function extractAllowedFields(body) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get("/", async (req, res, next) => {
|
router.get("/", validateCoordinatesQuery, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
minPrice,
|
minPrice,
|
||||||
@@ -137,6 +138,10 @@ router.get("/", async (req, res, next) => {
|
|||||||
model: User,
|
model: User,
|
||||||
as: "owner",
|
as: "owner",
|
||||||
attributes: ["id", "firstName", "lastName", "imageFilename"],
|
attributes: ["id", "firstName", "lastName", "imageFilename"],
|
||||||
|
where: {
|
||||||
|
isBanned: { [Op.ne]: true }
|
||||||
|
},
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
limit: parseInt(limit),
|
limit: parseInt(limit),
|
||||||
@@ -323,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 {
|
try {
|
||||||
// Extract only allowed fields (prevents mass assignment)
|
// Extract only allowed fields (prevents mass assignment)
|
||||||
const allowedData = extractAllowedFields(req.body);
|
const allowedData = extractAllowedFields(req.body);
|
||||||
@@ -431,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 {
|
try {
|
||||||
const item = await Item.findByPk(req.params.id);
|
const item = await Item.findByPk(req.params.id);
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const helmet = require('helmet');
|
|
||||||
const { Message, User } = require('../models');
|
const { Message, User } = require('../models');
|
||||||
const { authenticateToken } = require('../middleware/auth');
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { emitNewMessage, emitMessageRead } = require('../sockets/messageSocket');
|
const { emitNewMessage, emitMessageRead } = require('../sockets/messageSocket');
|
||||||
const { Op } = require('sequelize');
|
const { Op } = require('sequelize');
|
||||||
const emailServices = require('../services/email');
|
const emailServices = require('../services/email');
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { validateS3Keys } = require('../utils/s3KeyValidator');
|
const { validateS3Keys } = require('../utils/s3KeyValidator');
|
||||||
const { IMAGE_LIMITS } = require('../config/imageLimits');
|
const { IMAGE_LIMITS } = require('../config/imageLimits');
|
||||||
const router = express.Router();
|
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;
|
module.exports = router;
|
||||||
@@ -11,8 +11,12 @@ const RefundService = require("../services/refundService");
|
|||||||
const LateReturnService = require("../services/lateReturnService");
|
const LateReturnService = require("../services/lateReturnService");
|
||||||
const PayoutService = require("../services/payoutService");
|
const PayoutService = require("../services/payoutService");
|
||||||
const DamageAssessmentService = require("../services/damageAssessmentService");
|
const DamageAssessmentService = require("../services/damageAssessmentService");
|
||||||
|
const StripeWebhookService = require("../services/stripeWebhookService");
|
||||||
|
const StripeService = require("../services/stripeService");
|
||||||
const emailServices = require("../services/email");
|
const emailServices = require("../services/email");
|
||||||
|
const EventBridgeSchedulerService = require("../services/eventBridgeSchedulerService");
|
||||||
const logger = require("../utils/logger");
|
const logger = require("../utils/logger");
|
||||||
|
const { PaymentError } = require("../utils/stripeErrors");
|
||||||
const { validateS3Keys } = require("../utils/s3KeyValidator");
|
const { validateS3Keys } = require("../utils/s3KeyValidator");
|
||||||
const { IMAGE_LIMITS } = require("../config/imageLimits");
|
const { IMAGE_LIMITS } = require("../config/imageLimits");
|
||||||
const { isActive, getEffectiveStatus } = require("../utils/rentalStatus");
|
const { isActive, getEffectiveStatus } = require("../utils/rentalStatus");
|
||||||
@@ -106,6 +110,19 @@ router.get("/renting", authenticateToken, async (req, res) => {
|
|||||||
|
|
||||||
router.get("/owning", authenticateToken, async (req, res) => {
|
router.get("/owning", authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
// Reconcile payout statuses with Stripe before returning data
|
||||||
|
// This handles cases where webhooks were missed
|
||||||
|
try {
|
||||||
|
await StripeWebhookService.reconcilePayoutStatuses(req.user.id);
|
||||||
|
} catch (reconcileError) {
|
||||||
|
// Log but don't fail the request - still return rentals
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error("Error reconciling payout statuses", {
|
||||||
|
error: reconcileError.message,
|
||||||
|
userId: req.user.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const rentals = await Rental.findAll({
|
const rentals = await Rental.findAll({
|
||||||
where: { ownerId: req.user.id },
|
where: { ownerId: req.user.id },
|
||||||
// Remove explicit attributes to let Sequelize handle missing columns gracefully
|
// Remove explicit attributes to let Sequelize handle missing columns gracefully
|
||||||
@@ -236,54 +253,41 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
||||||
if (rentalStartDateTime < fiveMinutesAgo) {
|
if (rentalStartDateTime < fiveMinutesAgo) {
|
||||||
return res.status(400).json({ error: "Start date cannot be in the past" });
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Start date cannot be in the past" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate end date/time is after start date/time
|
// Validate end date/time is after start date/time
|
||||||
if (rentalEndDateTime <= rentalStartDateTime) {
|
if (rentalEndDateTime <= rentalStartDateTime) {
|
||||||
return res.status(400).json({ error: "End date/time must be after start date/time" });
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "End date/time must be after start date/time" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate rental cost using duration calculator
|
// Calculate rental cost using duration calculator
|
||||||
totalAmount = RentalDurationCalculator.calculateRentalCost(
|
totalAmount = RentalDurationCalculator.calculateRentalCost(
|
||||||
rentalStartDateTime,
|
rentalStartDateTime,
|
||||||
rentalEndDateTime,
|
rentalEndDateTime,
|
||||||
item
|
item,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check for overlapping rentals using datetime ranges
|
// 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({
|
const overlappingRental = await Rental.findOne({
|
||||||
where: {
|
where: {
|
||||||
itemId,
|
itemId,
|
||||||
status: "confirmed",
|
status: "confirmed",
|
||||||
[Op.or]: [
|
startDateTime: { [Op.not]: null },
|
||||||
{
|
endDateTime: { [Op.not]: null },
|
||||||
[Op.and]: [
|
[Op.and]: [
|
||||||
{ startDateTime: { [Op.not]: null } },
|
// existingStart < newEnd (existing rental starts before new one ends)
|
||||||
{ endDateTime: { [Op.not]: null } },
|
{ startDateTime: { [Op.lt]: rentalEndDateTime } },
|
||||||
{
|
// existingEnd > newStart (existing rental ends after new one starts)
|
||||||
[Op.or]: [
|
{ endDateTime: { [Op.gt]: rentalStartDateTime } },
|
||||||
{
|
|
||||||
startDateTime: {
|
|
||||||
[Op.between]: [rentalStartDateTime, rentalEndDateTime],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
endDateTime: {
|
|
||||||
[Op.between]: [rentalStartDateTime, rentalEndDateTime],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
[Op.and]: [
|
|
||||||
{ startDateTime: { [Op.lte]: rentalStartDateTime } },
|
|
||||||
{ endDateTime: { [Op.gte]: rentalEndDateTime } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -348,7 +352,7 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
|
|||||||
await emailServices.rentalFlow.sendRentalRequestEmail(
|
await emailServices.rentalFlow.sendRentalRequestEmail(
|
||||||
rentalWithDetails.owner,
|
rentalWithDetails.owner,
|
||||||
rentalWithDetails.renter,
|
rentalWithDetails.renter,
|
||||||
rentalWithDetails
|
rentalWithDetails,
|
||||||
);
|
);
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
reqLogger.info("Rental request notification sent to owner", {
|
reqLogger.info("Rental request notification sent to owner", {
|
||||||
@@ -370,7 +374,7 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
await emailServices.rentalFlow.sendRentalRequestConfirmationEmail(
|
await emailServices.rentalFlow.sendRentalRequestConfirmationEmail(
|
||||||
rentalWithDetails.renter,
|
rentalWithDetails.renter,
|
||||||
rentalWithDetails
|
rentalWithDetails,
|
||||||
);
|
);
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
reqLogger.info("Rental request confirmation sent to renter", {
|
reqLogger.info("Rental request confirmation sent to renter", {
|
||||||
@@ -403,12 +407,24 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
|||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: "owner",
|
as: "owner",
|
||||||
attributes: ["id", "firstName", "lastName", "email", "stripeConnectedAccountId"],
|
attributes: [
|
||||||
|
"id",
|
||||||
|
"firstName",
|
||||||
|
"lastName",
|
||||||
|
"email",
|
||||||
|
"stripeConnectedAccountId",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: "renter",
|
as: "renter",
|
||||||
attributes: ["id", "firstName", "lastName", "email", "stripeCustomerId"],
|
attributes: [
|
||||||
|
"id",
|
||||||
|
"firstName",
|
||||||
|
"lastName",
|
||||||
|
"email",
|
||||||
|
"stripeCustomerId",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -458,9 +474,51 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
|||||||
itemName: rental.item.name,
|
itemName: rental.item.name,
|
||||||
renterId: rental.renterId,
|
renterId: rental.renterId,
|
||||||
ownerId: rental.ownerId,
|
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
|
// Update rental with payment completion
|
||||||
await rental.update({
|
await rental.update({
|
||||||
status: "confirmed",
|
status: "confirmed",
|
||||||
@@ -477,7 +535,13 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
|||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: "owner",
|
as: "owner",
|
||||||
attributes: ["id", "firstName", "lastName", "email", "stripeConnectedAccountId"],
|
attributes: [
|
||||||
|
"id",
|
||||||
|
"firstName",
|
||||||
|
"lastName",
|
||||||
|
"email",
|
||||||
|
"stripeConnectedAccountId",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
@@ -487,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 confirmation emails
|
||||||
// Send approval confirmation to owner with Stripe reminder
|
// Send approval confirmation to owner with Stripe reminder
|
||||||
try {
|
try {
|
||||||
await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(
|
await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(
|
||||||
updatedRental.owner,
|
updatedRental.owner,
|
||||||
updatedRental.renter,
|
updatedRental.renter,
|
||||||
updatedRental
|
updatedRental,
|
||||||
);
|
);
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
reqLogger.info("Rental approval confirmation sent to owner", {
|
reqLogger.info("Rental approval confirmation sent to owner", {
|
||||||
@@ -509,7 +587,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
|||||||
stack: emailError.stack,
|
stack: emailError.stack,
|
||||||
rentalId: updatedRental.id,
|
rentalId: updatedRental.id,
|
||||||
ownerId: updatedRental.ownerId,
|
ownerId: updatedRental.ownerId,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,7 +610,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
|||||||
renterNotification,
|
renterNotification,
|
||||||
updatedRental,
|
updatedRental,
|
||||||
renter.firstName,
|
renter.firstName,
|
||||||
true // isRenter = true to show payment receipt
|
true, // isRenter = true to show payment receipt
|
||||||
);
|
);
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
reqLogger.info("Rental confirmation sent to renter", {
|
reqLogger.info("Rental confirmation sent to renter", {
|
||||||
@@ -549,7 +627,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
|||||||
stack: emailError.stack,
|
stack: emailError.stack,
|
||||||
rentalId: updatedRental.id,
|
rentalId: updatedRental.id,
|
||||||
renterId: updatedRental.renterId,
|
renterId: updatedRental.renterId,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,14 +637,58 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
|||||||
const reqLogger = logger.withRequestId(req.id);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
reqLogger.error("Payment failed during approval", {
|
reqLogger.error("Payment failed during approval", {
|
||||||
error: paymentError.message,
|
error: paymentError.message,
|
||||||
|
code: paymentError.code,
|
||||||
stack: paymentError.stack,
|
stack: paymentError.stack,
|
||||||
rentalId: req.params.id,
|
rentalId: req.params.id,
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
});
|
});
|
||||||
// Keep rental as pending, but inform of payment failure
|
|
||||||
return res.status(400).json({
|
// Determine the renter-facing message
|
||||||
error: "Payment failed during approval",
|
const renterMessage =
|
||||||
details: paymentError.message,
|
paymentError instanceof PaymentError
|
||||||
|
? paymentError.renterMessage
|
||||||
|
: "Your payment could not be processed. Please try a different payment method.";
|
||||||
|
|
||||||
|
// Track payment failure timestamp and reason
|
||||||
|
await rental.update({
|
||||||
|
paymentFailedNotifiedAt: new Date(),
|
||||||
|
paymentFailedReason: renterMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-send payment declined email to renter
|
||||||
|
try {
|
||||||
|
await emailServices.payment.sendPaymentDeclinedNotification(
|
||||||
|
rental.renter.email,
|
||||||
|
{
|
||||||
|
renterFirstName: rental.renter.firstName,
|
||||||
|
itemName: rental.item.name,
|
||||||
|
declineReason: renterMessage,
|
||||||
|
rentalId: rental.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
reqLogger.info("Payment declined email auto-sent to renter", {
|
||||||
|
rentalId: rental.id,
|
||||||
|
renterId: rental.renterId,
|
||||||
|
});
|
||||||
|
} catch (emailError) {
|
||||||
|
reqLogger.error("Failed to send payment declined email", {
|
||||||
|
error: emailError.message,
|
||||||
|
rentalId: rental.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep rental as pending, inform owner of payment failure
|
||||||
|
const ownerMessage =
|
||||||
|
paymentError instanceof PaymentError
|
||||||
|
? paymentError.ownerMessage
|
||||||
|
: "The payment could not be processed.";
|
||||||
|
|
||||||
|
return res.status(402).json({
|
||||||
|
error: "payment_failed",
|
||||||
|
code: paymentError.code || "unknown_error",
|
||||||
|
ownerMessage,
|
||||||
|
renterMessage,
|
||||||
|
rentalId: rental.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -581,7 +703,13 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
|||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: "owner",
|
as: "owner",
|
||||||
attributes: ["id", "firstName", "lastName", "email", "stripeConnectedAccountId"],
|
attributes: [
|
||||||
|
"id",
|
||||||
|
"firstName",
|
||||||
|
"lastName",
|
||||||
|
"email",
|
||||||
|
"stripeConnectedAccountId",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
@@ -591,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 confirmation emails
|
||||||
// Send approval confirmation to owner (for free rentals, no Stripe reminder shown)
|
// Send approval confirmation to owner (for free rentals, no Stripe reminder shown)
|
||||||
try {
|
try {
|
||||||
await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(
|
await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(
|
||||||
updatedRental.owner,
|
updatedRental.owner,
|
||||||
updatedRental.renter,
|
updatedRental.renter,
|
||||||
updatedRental
|
updatedRental,
|
||||||
);
|
);
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
reqLogger.info("Rental approval confirmation sent to owner", {
|
reqLogger.info("Rental approval confirmation sent to owner", {
|
||||||
@@ -613,7 +755,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
|||||||
stack: emailError.stack,
|
stack: emailError.stack,
|
||||||
rentalId: updatedRental.id,
|
rentalId: updatedRental.id,
|
||||||
ownerId: updatedRental.ownerId,
|
ownerId: updatedRental.ownerId,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,7 +778,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
|||||||
renterNotification,
|
renterNotification,
|
||||||
updatedRental,
|
updatedRental,
|
||||||
renter.firstName,
|
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);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
reqLogger.info("Rental confirmation sent to renter", {
|
reqLogger.info("Rental confirmation sent to renter", {
|
||||||
@@ -653,7 +795,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
|||||||
stack: emailError.stack,
|
stack: emailError.stack,
|
||||||
rentalId: updatedRental.id,
|
rentalId: updatedRental.id,
|
||||||
renterId: updatedRental.renterId,
|
renterId: updatedRental.renterId,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -759,7 +901,7 @@ router.put("/:id/decline", authenticateToken, async (req, res) => {
|
|||||||
await emailServices.rentalFlow.sendRentalDeclinedEmail(
|
await emailServices.rentalFlow.sendRentalDeclinedEmail(
|
||||||
updatedRental.renter,
|
updatedRental.renter,
|
||||||
updatedRental,
|
updatedRental,
|
||||||
reason
|
reason,
|
||||||
);
|
);
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
reqLogger.info("Rental decline notification sent to renter", {
|
reqLogger.info("Rental decline notification sent to renter", {
|
||||||
@@ -942,7 +1084,9 @@ router.post("/cost-preview", authenticateToken, async (req, res) => {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
||||||
if (rentalStartDateTime < fiveMinutesAgo) {
|
if (rentalStartDateTime < fiveMinutesAgo) {
|
||||||
return res.status(400).json({ error: "Start date cannot be in the past" });
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Start date cannot be in the past" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate date range
|
// Validate date range
|
||||||
@@ -952,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
|
// Calculate rental cost using duration calculator
|
||||||
const totalAmount = RentalDurationCalculator.calculateRentalCost(
|
const totalAmount = RentalDurationCalculator.calculateRentalCost(
|
||||||
rentalStartDateTime,
|
rentalStartDateTime,
|
||||||
rentalEndDateTime,
|
rentalEndDateTime,
|
||||||
item
|
item,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate fees
|
// Calculate fees
|
||||||
@@ -979,7 +1144,18 @@ router.post("/cost-preview", authenticateToken, async (req, res) => {
|
|||||||
|
|
||||||
// Get earnings status for owner's rentals
|
// Get earnings status for owner's rentals
|
||||||
router.get("/earnings/status", authenticateToken, async (req, res, next) => {
|
router.get("/earnings/status", authenticateToken, async (req, res, next) => {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
|
||||||
try {
|
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({
|
const ownerRentals = await Rental.findAll({
|
||||||
where: {
|
where: {
|
||||||
ownerId: req.user.id,
|
ownerId: req.user.id,
|
||||||
@@ -993,6 +1169,9 @@ router.get("/earnings/status", authenticateToken, async (req, res, next) => {
|
|||||||
"payoutStatus",
|
"payoutStatus",
|
||||||
"payoutProcessedAt",
|
"payoutProcessedAt",
|
||||||
"stripeTransferId",
|
"stripeTransferId",
|
||||||
|
"bankDepositStatus",
|
||||||
|
"bankDepositAt",
|
||||||
|
"bankDepositFailureCode",
|
||||||
],
|
],
|
||||||
include: [{ model: Item, as: "item", attributes: ["name"] }],
|
include: [{ model: Item, as: "item", attributes: ["name"] }],
|
||||||
order: [["createdAt", "DESC"]],
|
order: [["createdAt", "DESC"]],
|
||||||
@@ -1000,7 +1179,6 @@ router.get("/earnings/status", authenticateToken, async (req, res, next) => {
|
|||||||
|
|
||||||
res.json(ownerRentals);
|
res.json(ownerRentals);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
|
||||||
reqLogger.error("Error getting earnings status", {
|
reqLogger.error("Error getting earnings status", {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
stack: error.stack,
|
stack: error.stack,
|
||||||
@@ -1015,7 +1193,7 @@ router.get("/:id/refund-preview", authenticateToken, async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const preview = await RefundService.getRefundPreview(
|
const preview = await RefundService.getRefundPreview(
|
||||||
req.params.id,
|
req.params.id,
|
||||||
req.user.id
|
req.user.id,
|
||||||
);
|
);
|
||||||
res.json(preview);
|
res.json(preview);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1059,7 +1237,7 @@ router.get(
|
|||||||
|
|
||||||
const lateCalculation = LateReturnService.calculateLateFee(
|
const lateCalculation = LateReturnService.calculateLateFee(
|
||||||
rental,
|
rental,
|
||||||
actualReturnDateTime
|
actualReturnDateTime,
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json(lateCalculation);
|
res.json(lateCalculation);
|
||||||
@@ -1073,7 +1251,7 @@ router.get(
|
|||||||
});
|
});
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cancel rental with refund processing
|
// Cancel rental with refund processing
|
||||||
@@ -1089,7 +1267,7 @@ router.post("/:id/cancel", authenticateToken, async (req, res, next) => {
|
|||||||
const result = await RefundService.processCancellation(
|
const result = await RefundService.processCancellation(
|
||||||
req.params.id,
|
req.params.id,
|
||||||
req.user.id,
|
req.user.id,
|
||||||
reason.trim()
|
reason.trim(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Return the updated rental with refund information
|
// Return the updated rental with refund information
|
||||||
@@ -1115,7 +1293,7 @@ router.post("/:id/cancel", authenticateToken, async (req, res, next) => {
|
|||||||
updatedRental.owner,
|
updatedRental.owner,
|
||||||
updatedRental.renter,
|
updatedRental.renter,
|
||||||
updatedRental,
|
updatedRental,
|
||||||
result.refund
|
result.refund,
|
||||||
);
|
);
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
reqLogger.info("Cancellation emails sent", {
|
reqLogger.info("Cancellation emails sent", {
|
||||||
@@ -1195,7 +1373,13 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
|
|||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: "owner",
|
as: "owner",
|
||||||
attributes: ["id", "firstName", "lastName", "email", "stripeConnectedAccountId"],
|
attributes: [
|
||||||
|
"id",
|
||||||
|
"firstName",
|
||||||
|
"lastName",
|
||||||
|
"email",
|
||||||
|
"stripeConnectedAccountId",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
@@ -1210,7 +1394,7 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
|
|||||||
await emailServices.rentalFlow.sendRentalCompletionEmails(
|
await emailServices.rentalFlow.sendRentalCompletionEmails(
|
||||||
rentalWithDetails.owner,
|
rentalWithDetails.owner,
|
||||||
rentalWithDetails.renter,
|
rentalWithDetails.renter,
|
||||||
rentalWithDetails
|
rentalWithDetails,
|
||||||
);
|
);
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
reqLogger.info("Rental completion emails sent", {
|
reqLogger.info("Rental completion emails sent", {
|
||||||
@@ -1248,7 +1432,7 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
|
|||||||
if (statusOptions?.returned_late && actualReturnDateTime) {
|
if (statusOptions?.returned_late && actualReturnDateTime) {
|
||||||
const lateReturnDamaged = await LateReturnService.processLateReturn(
|
const lateReturnDamaged = await LateReturnService.processLateReturn(
|
||||||
rentalId,
|
rentalId,
|
||||||
actualReturnDateTime
|
actualReturnDateTime,
|
||||||
);
|
);
|
||||||
damageUpdates.status = "returned_late_and_damaged";
|
damageUpdates.status = "returned_late_and_damaged";
|
||||||
damageUpdates.lateFees = lateReturnDamaged.lateCalculation.lateFee;
|
damageUpdates.lateFees = lateReturnDamaged.lateCalculation.lateFee;
|
||||||
@@ -1270,7 +1454,7 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
|
|||||||
|
|
||||||
const lateReturn = await LateReturnService.processLateReturn(
|
const lateReturn = await LateReturnService.processLateReturn(
|
||||||
rentalId,
|
rentalId,
|
||||||
actualReturnDateTime
|
actualReturnDateTime,
|
||||||
);
|
);
|
||||||
|
|
||||||
updatedRental = lateReturn.rental;
|
updatedRental = lateReturn.rental;
|
||||||
@@ -1291,7 +1475,7 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
|
|||||||
await emailServices.customerService.sendLostItemToCustomerService(
|
await emailServices.customerService.sendLostItemToCustomerService(
|
||||||
updatedRental,
|
updatedRental,
|
||||||
owner,
|
owner,
|
||||||
renter
|
renter,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -1369,7 +1553,7 @@ router.post("/:id/report-damage", authenticateToken, async (req, res, next) => {
|
|||||||
"damage-reports",
|
"damage-reports",
|
||||||
{
|
{
|
||||||
maxKeys: IMAGE_LIMITS.damageReports,
|
maxKeys: IMAGE_LIMITS.damageReports,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
if (!keyValidation.valid) {
|
if (!keyValidation.valid) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -1383,7 +1567,7 @@ router.post("/:id/report-damage", authenticateToken, async (req, res, next) => {
|
|||||||
const result = await DamageAssessmentService.processDamageAssessment(
|
const result = await DamageAssessmentService.processDamageAssessment(
|
||||||
rentalId,
|
rentalId,
|
||||||
damageInfo,
|
damageInfo,
|
||||||
userId
|
userId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
@@ -1411,4 +1595,408 @@ router.post("/:id/report-damage", authenticateToken, async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PUT /rentals/:id/payment-method - Renter updates payment method for pending rental
|
||||||
|
router.put("/:id/payment-method", authenticateToken, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const rentalId = req.params.id;
|
||||||
|
const { stripePaymentMethodId } = req.body;
|
||||||
|
|
||||||
|
if (!stripePaymentMethodId) {
|
||||||
|
return res.status(400).json({ error: "Payment method ID is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rental = await Rental.findByPk(rentalId, {
|
||||||
|
include: [
|
||||||
|
{ model: Item, as: "item" },
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: "owner",
|
||||||
|
attributes: ["id", "firstName", "email"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rental) {
|
||||||
|
return res.status(404).json({ error: "Rental not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only renter can update payment method
|
||||||
|
if (rental.renterId !== req.user.id) {
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.json({ error: "Only the renter can update the payment method" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only for pending rentals with pending payment
|
||||||
|
if (rental.status !== "pending" || rental.paymentStatus !== "pending") {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Can only update payment method for pending rentals",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify payment method belongs to renter's Stripe customer
|
||||||
|
const renter = await User.findByPk(req.user.id);
|
||||||
|
if (!renter.stripeCustomerId) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "No Stripe customer account found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let paymentMethod;
|
||||||
|
try {
|
||||||
|
paymentMethod = await StripeService.getPaymentMethod(
|
||||||
|
stripePaymentMethodId,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return res.status(400).json({ error: "Invalid payment method" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paymentMethod.customer !== renter.stripeCustomerId) {
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.json({ error: "Payment method does not belong to this account" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limiting: Max 3 updates per rental per hour
|
||||||
|
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
|
||||||
|
if (
|
||||||
|
rental.paymentMethodUpdatedAt &&
|
||||||
|
rental.paymentMethodUpdatedAt > oneHourAgo
|
||||||
|
) {
|
||||||
|
const updateCount = rental.paymentMethodUpdateCount || 0;
|
||||||
|
if (updateCount >= 3) {
|
||||||
|
return res.status(429).json({
|
||||||
|
error: "Too many payment method updates. Please try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store old payment method for audit log
|
||||||
|
const oldPaymentMethodId = rental.stripePaymentMethodId;
|
||||||
|
|
||||||
|
// Atomic update with status check (prevents race condition)
|
||||||
|
const [updateCount] = await Rental.update(
|
||||||
|
{
|
||||||
|
stripePaymentMethodId,
|
||||||
|
paymentMethodUpdatedAt: new Date(),
|
||||||
|
paymentMethodUpdateCount:
|
||||||
|
rental.paymentMethodUpdatedAt > oneHourAgo
|
||||||
|
? (rental.paymentMethodUpdateCount || 0) + 1
|
||||||
|
: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
where: {
|
||||||
|
id: rentalId,
|
||||||
|
status: "pending",
|
||||||
|
paymentStatus: "pending",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (updateCount === 0) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: "Rental status changed. Please refresh and try again.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.info("Payment method updated", {
|
||||||
|
rentalId,
|
||||||
|
userId: req.user.id,
|
||||||
|
oldPaymentMethodId,
|
||||||
|
newPaymentMethodId: stripePaymentMethodId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optionally notify owner that payment method was updated
|
||||||
|
try {
|
||||||
|
await emailServices.payment.sendPaymentMethodUpdatedNotification(
|
||||||
|
rental.owner.email,
|
||||||
|
{
|
||||||
|
ownerFirstName: rental.owner.firstName,
|
||||||
|
itemName: rental.item.name,
|
||||||
|
rentalId: rental.id,
|
||||||
|
approvalUrl: `${process.env.FRONTEND_URL}/rentals/${rentalId}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (emailError) {
|
||||||
|
// Don't fail the request if email fails
|
||||||
|
reqLogger.error("Failed to send payment method updated notification", {
|
||||||
|
error: emailError.message,
|
||||||
|
rentalId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Payment method updated. The owner can now retry approval.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error("Error updating payment method", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
rentalId: req.params.id,
|
||||||
|
userId: req.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ const express = require("express");
|
|||||||
const { authenticateToken, requireVerifiedEmail } = require("../middleware/auth");
|
const { authenticateToken, requireVerifiedEmail } = require("../middleware/auth");
|
||||||
const { User, Item } = require("../models");
|
const { User, Item } = require("../models");
|
||||||
const StripeService = require("../services/stripeService");
|
const StripeService = require("../services/stripeService");
|
||||||
|
const StripeWebhookService = require("../services/stripeWebhookService");
|
||||||
|
const emailServices = require("../services/email");
|
||||||
const logger = require("../utils/logger");
|
const logger = require("../utils/logger");
|
||||||
const router = express.Router();
|
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) => {
|
router.get("/account-status", authenticateToken, async (req, res, next) => {
|
||||||
let user = null;
|
let user = null;
|
||||||
try {
|
try {
|
||||||
@@ -190,6 +192,64 @@ router.get("/account-status", authenticateToken, async (req, res, next) => {
|
|||||||
payoutsEnabled: accountStatus.payouts_enabled,
|
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({
|
res.json({
|
||||||
accountId: accountStatus.id,
|
accountId: accountStatus.id,
|
||||||
detailsSubmitted: accountStatus.details_submitted,
|
detailsSubmitted: accountStatus.details_submitted,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const StripeWebhookService = require("../services/stripeWebhookService");
|
const StripeWebhookService = require("../services/stripeWebhookService");
|
||||||
|
const DisputeService = require("../services/disputeService");
|
||||||
const logger = require("../utils/logger");
|
const logger = require("../utils/logger");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -70,6 +71,31 @@ router.post("/", async (req, res) => {
|
|||||||
);
|
);
|
||||||
break;
|
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:
|
default:
|
||||||
logger.info("Unhandled webhook event type", { type: event.type });
|
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
|
// Still return 200 to prevent Stripe retries for processing errors
|
||||||
// Failed payouts will be handled by retry job
|
// 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
627
backend/routes/twoFactor.js
Normal 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;
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { User, UserAddress } = require('../models'); // Import from models/index.js to get models with associations
|
const { User, UserAddress } = require('../models'); // Import from models/index.js to get models with associations
|
||||||
const { authenticateToken } = require('../middleware/auth');
|
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 logger = require('../utils/logger');
|
||||||
const userService = require('../services/UserService');
|
const userService = require('../services/UserService');
|
||||||
|
const emailServices = require('../services/email');
|
||||||
const { validateS3Keys } = require('../utils/s3KeyValidator');
|
const { validateS3Keys } = require('../utils/s3KeyValidator');
|
||||||
const { IMAGE_LIMITS } = require('../config/imageLimits');
|
const { IMAGE_LIMITS } = require('../config/imageLimits');
|
||||||
const router = express.Router();
|
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 {
|
try {
|
||||||
// Extract only allowed fields (prevents mass assignment)
|
// Extract only allowed fields (prevents mass assignment)
|
||||||
const allowedData = extractAllowedAddressFields(req.body);
|
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 {
|
try {
|
||||||
// Extract only allowed fields (prevents mass assignment)
|
// Extract only allowed fields (prevents mass assignment)
|
||||||
const allowedData = extractAllowedAddressFields(req.body);
|
const allowedData = extractAllowedAddressFields(req.body);
|
||||||
@@ -210,10 +214,20 @@ router.put('/availability', authenticateToken, async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/:id', async (req, res, next) => {
|
router.get('/:id', optionalAuth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
const isAdmin = req.user?.role === 'admin';
|
||||||
|
|
||||||
|
// Base attributes to exclude
|
||||||
|
const excludedAttributes = ['password', 'email', 'phone', 'address', 'verificationToken', 'passwordResetToken'];
|
||||||
|
|
||||||
|
// If not admin, also exclude ban-related fields
|
||||||
|
if (!isAdmin) {
|
||||||
|
excludedAttributes.push('isBanned', 'bannedAt', 'bannedBy', 'banReason');
|
||||||
|
}
|
||||||
|
|
||||||
const user = await User.findByPk(req.params.id, {
|
const user = await User.findByPk(req.params.id, {
|
||||||
attributes: { exclude: ['password', 'email', 'phone', 'address'] }
|
attributes: { exclude: excludedAttributes }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -222,7 +236,8 @@ router.get('/:id', async (req, res, next) => {
|
|||||||
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
reqLogger.info("Public user profile fetched", {
|
reqLogger.info("Public user profile fetched", {
|
||||||
requestedUserId: req.params.id
|
requestedUserId: req.params.id,
|
||||||
|
viewerIsAdmin: isAdmin
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(user);
|
res.json(user);
|
||||||
@@ -258,7 +273,192 @@ router.put('/profile', authenticateToken, async (req, res, next) => {
|
|||||||
|
|
||||||
res.json(updatedUser);
|
res.json(updatedUser);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Profile update error:', error);
|
logger.error('Profile update error', { error });
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin: Ban a user
|
||||||
|
router.post('/admin/:id/ban', authenticateToken, requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { reason } = req.body;
|
||||||
|
const targetUserId = req.params.id;
|
||||||
|
|
||||||
|
// Validate reason is provided
|
||||||
|
if (!reason || !reason.trim()) {
|
||||||
|
return res.status(400).json({ error: "Ban reason is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent banning yourself
|
||||||
|
if (targetUserId === req.user.id) {
|
||||||
|
return res.status(400).json({ error: "You cannot ban yourself" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUser = await User.findByPk(targetUserId);
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
return res.status(404).json({ error: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent banning other admins
|
||||||
|
if (targetUser.role === 'admin') {
|
||||||
|
return res.status(403).json({ error: "Cannot ban admin users" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already banned
|
||||||
|
if (targetUser.isBanned) {
|
||||||
|
return res.status(400).json({ error: "User is already banned" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ban the user (this also invalidates sessions via jwtVersion increment)
|
||||||
|
await targetUser.banUser(req.user.id, reason.trim());
|
||||||
|
|
||||||
|
// Send ban notification email
|
||||||
|
try {
|
||||||
|
const emailServices = require("../services/email");
|
||||||
|
await emailServices.userEngagement.sendUserBannedNotification(
|
||||||
|
targetUser,
|
||||||
|
req.user,
|
||||||
|
reason.trim()
|
||||||
|
);
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.info("User ban notification email sent", {
|
||||||
|
bannedUserId: targetUserId,
|
||||||
|
adminId: req.user.id
|
||||||
|
});
|
||||||
|
} catch (emailError) {
|
||||||
|
// Log but don't fail the ban operation
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error('Failed to send user ban notification email', {
|
||||||
|
error: emailError.message,
|
||||||
|
stack: emailError.stack,
|
||||||
|
bannedUserId: targetUserId,
|
||||||
|
adminId: req.user.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.info("User banned by admin", {
|
||||||
|
targetUserId,
|
||||||
|
adminId: req.user.id,
|
||||||
|
reason: reason.trim()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return updated user data (excluding sensitive fields)
|
||||||
|
const updatedUser = await User.findByPk(targetUserId, {
|
||||||
|
attributes: { exclude: ['password', 'verificationToken', 'passwordResetToken'] }
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: "User has been banned successfully",
|
||||||
|
user: updatedUser
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error("Admin ban user failed", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
targetUserId: req.params.id,
|
||||||
|
adminId: req.user.id
|
||||||
|
});
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
const targetUserId = req.params.id;
|
||||||
|
|
||||||
|
const targetUser = await User.findByPk(targetUserId);
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
return res.status(404).json({ error: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is actually banned
|
||||||
|
if (!targetUser.isBanned) {
|
||||||
|
return res.status(400).json({ error: "User is not banned" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unban the user
|
||||||
|
await targetUser.unbanUser();
|
||||||
|
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.info("User unbanned by admin", {
|
||||||
|
targetUserId,
|
||||||
|
adminId: req.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return updated user data (excluding sensitive fields)
|
||||||
|
const updatedUser = await User.findByPk(targetUserId, {
|
||||||
|
attributes: { exclude: ['password', 'verificationToken', 'passwordResetToken'] }
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: "User has been unbanned successfully",
|
||||||
|
user: updatedUser
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error("Admin unban user failed", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
targetUserId: req.params.id,
|
||||||
|
adminId: req.user.id
|
||||||
|
});
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Load environment config
|
// Load environment config
|
||||||
const env = process.env.NODE_ENV || "dev";
|
const env = process.env.NODE_ENV;
|
||||||
const envFile = `.env.${env}`;
|
const envFile = `.env.${env}`;
|
||||||
require("dotenv").config({ path: envFile });
|
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
|
// Try to find by code first (if it looks like a code), otherwise by email
|
||||||
if (input.toUpperCase().startsWith("ALPHA-")) {
|
if (input.toUpperCase().startsWith("ALPHA-")) {
|
||||||
invitation = await AlphaInvitation.findOne({
|
invitation = await AlphaInvitation.findOne({
|
||||||
where: { code: input.toUpperCase() }
|
where: { code: input.toUpperCase() },
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
invitation = await AlphaInvitation.findOne({
|
invitation = await AlphaInvitation.findOne({
|
||||||
where: { email: normalizeEmail(input) }
|
where: { email: normalizeEmail(input) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +131,10 @@ async function resendInvitation(emailOrCode) {
|
|||||||
|
|
||||||
// Resend the email
|
// Resend the email
|
||||||
try {
|
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(`\n✅ Alpha invitation resent successfully!`);
|
||||||
console.log(` Email: ${invitation.email}`);
|
console.log(` Email: ${invitation.email}`);
|
||||||
@@ -178,7 +181,7 @@ async function listInvitations(filter = "all") {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log(
|
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("─".repeat(100));
|
||||||
console.log(
|
console.log(
|
||||||
@@ -186,7 +189,7 @@ async function listInvitations(filter = "all") {
|
|||||||
"EMAIL".padEnd(30) +
|
"EMAIL".padEnd(30) +
|
||||||
"STATUS".padEnd(10) +
|
"STATUS".padEnd(10) +
|
||||||
"USED BY".padEnd(25) +
|
"USED BY".padEnd(25) +
|
||||||
"CREATED"
|
"CREATED",
|
||||||
);
|
);
|
||||||
console.log("─".repeat(100));
|
console.log("─".repeat(100));
|
||||||
|
|
||||||
@@ -204,7 +207,7 @@ async function listInvitations(filter = "all") {
|
|||||||
inv.email.padEnd(30) +
|
inv.email.padEnd(30) +
|
||||||
inv.status.padEnd(10) +
|
inv.status.padEnd(10) +
|
||||||
usedBy.padEnd(25) +
|
usedBy.padEnd(25) +
|
||||||
created
|
created,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -221,7 +224,7 @@ async function listInvitations(filter = "all") {
|
|||||||
};
|
};
|
||||||
|
|
||||||
console.log(
|
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;
|
return invitations;
|
||||||
@@ -274,7 +277,9 @@ async function restoreInvitation(code) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (invitation.status !== "revoked") {
|
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(` Code: ${code}`);
|
||||||
console.log(` Email: ${invitation.email}`);
|
console.log(` Email: ${invitation.email}`);
|
||||||
return invitation;
|
return invitation;
|
||||||
@@ -288,7 +293,9 @@ async function restoreInvitation(code) {
|
|||||||
console.log(`\n✅ Invitation restored successfully!`);
|
console.log(`\n✅ Invitation restored successfully!`);
|
||||||
console.log(` Code: ${code}`);
|
console.log(` Code: ${code}`);
|
||||||
console.log(` Email: ${invitation.email}`);
|
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;
|
return invitation;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -313,7 +320,7 @@ async function bulkImport(csvPath) {
|
|||||||
const dataLines = hasHeader ? lines.slice(1) : lines;
|
const dataLines = hasHeader ? lines.slice(1) : lines;
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`\n📥 Importing ${dataLines.length} invitations from ${csvPath}...\n`
|
`\n📥 Importing ${dataLines.length} invitations from ${csvPath}...\n`,
|
||||||
);
|
);
|
||||||
|
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
@@ -391,7 +398,7 @@ CSV Format:
|
|||||||
if (!email) {
|
if (!email) {
|
||||||
console.log("\n❌ Error: Email is required");
|
console.log("\n❌ Error: Email is required");
|
||||||
console.log(
|
console.log(
|
||||||
"Usage: node scripts/manageAlphaInvitations.js add <email> [notes]\n"
|
"Usage: node scripts/manageAlphaInvitations.js add <email> [notes]\n",
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -406,7 +413,7 @@ CSV Format:
|
|||||||
if (!code) {
|
if (!code) {
|
||||||
console.log("\n❌ Error: Code is required");
|
console.log("\n❌ Error: Code is required");
|
||||||
console.log(
|
console.log(
|
||||||
"Usage: node scripts/manageAlphaInvitations.js revoke <code>\n"
|
"Usage: node scripts/manageAlphaInvitations.js revoke <code>\n",
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -418,7 +425,7 @@ CSV Format:
|
|||||||
if (!emailOrCode) {
|
if (!emailOrCode) {
|
||||||
console.log("\n❌ Error: Email or code is required");
|
console.log("\n❌ Error: Email or code is required");
|
||||||
console.log(
|
console.log(
|
||||||
"Usage: node scripts/manageAlphaInvitations.js resend <email|code>\n"
|
"Usage: node scripts/manageAlphaInvitations.js resend <email|code>\n",
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -430,7 +437,7 @@ CSV Format:
|
|||||||
if (!code) {
|
if (!code) {
|
||||||
console.log("\n❌ Error: Code is required");
|
console.log("\n❌ Error: Code is required");
|
||||||
console.log(
|
console.log(
|
||||||
"Usage: node scripts/manageAlphaInvitations.js restore <code>\n"
|
"Usage: node scripts/manageAlphaInvitations.js restore <code>\n",
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -442,7 +449,7 @@ CSV Format:
|
|||||||
if (!csvPath) {
|
if (!csvPath) {
|
||||||
console.log("\n❌ Error: CSV path is required");
|
console.log("\n❌ Error: CSV path is required");
|
||||||
console.log(
|
console.log(
|
||||||
"Usage: node scripts/manageAlphaInvitations.js bulk <csvPath>\n"
|
"Usage: node scripts/manageAlphaInvitations.js bulk <csvPath>\n",
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -451,7 +458,7 @@ CSV Format:
|
|||||||
} else {
|
} else {
|
||||||
console.log(`\n❌ Unknown command: ${command}`);
|
console.log(`\n❌ Unknown command: ${command}`);
|
||||||
console.log(
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Load environment-specific config
|
// Load environment-specific config
|
||||||
const env = process.env.NODE_ENV || "dev";
|
const env = process.env.NODE_ENV;
|
||||||
const envFile = `.env.${env}`;
|
const envFile = `.env.${env}`;
|
||||||
|
|
||||||
require("dotenv").config({
|
require("dotenv").config({
|
||||||
@@ -31,9 +31,8 @@ const conditionCheckRoutes = require("./routes/conditionChecks");
|
|||||||
const feedbackRoutes = require("./routes/feedback");
|
const feedbackRoutes = require("./routes/feedback");
|
||||||
const uploadRoutes = require("./routes/upload");
|
const uploadRoutes = require("./routes/upload");
|
||||||
const healthRoutes = require("./routes/health");
|
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 emailServices = require("./services/email");
|
||||||
const s3Service = require("./services/s3Service");
|
const s3Service = require("./services/s3Service");
|
||||||
|
|
||||||
@@ -47,7 +46,7 @@ const server = http.createServer(app);
|
|||||||
// Initialize Socket.io with CORS
|
// Initialize Socket.io with CORS
|
||||||
const io = new Server(server, {
|
const io = new Server(server, {
|
||||||
cors: {
|
cors: {
|
||||||
origin: process.env.FRONTEND_URL || "http://localhost:3000",
|
origin: process.env.FRONTEND_URL,
|
||||||
credentials: true,
|
credentials: true,
|
||||||
methods: ["GET", "POST"],
|
methods: ["GET", "POST"],
|
||||||
},
|
},
|
||||||
@@ -69,6 +68,7 @@ const {
|
|||||||
addRequestId,
|
addRequestId,
|
||||||
sanitizeError,
|
sanitizeError,
|
||||||
} = require("./middleware/security");
|
} = require("./middleware/security");
|
||||||
|
const { sanitizeInput } = require("./middleware/validation");
|
||||||
const { generalLimiter } = require("./middleware/rateLimiter");
|
const { generalLimiter } = require("./middleware/rateLimiter");
|
||||||
const errorLogger = require("./middleware/errorLogger");
|
const errorLogger = require("./middleware/errorLogger");
|
||||||
const apiLogger = require("./middleware/apiLogger");
|
const apiLogger = require("./middleware/apiLogger");
|
||||||
@@ -93,7 +93,7 @@ app.use(
|
|||||||
frameSrc: ["'self'", "https://accounts.google.com"],
|
frameSrc: ["'self'", "https://accounts.google.com"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cookie parser for CSRF
|
// 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)
|
// CORS with security settings (must come BEFORE rate limiter to ensure headers on all responses)
|
||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
origin: process.env.FRONTEND_URL || "http://localhost:3000",
|
origin: process.env.FRONTEND_URL,
|
||||||
credentials: true,
|
credentials: true,
|
||||||
optionsSuccessStatus: 200,
|
optionsSuccessStatus: 200,
|
||||||
exposedHeaders: ["X-CSRF-Token"],
|
exposedHeaders: ["X-CSRF-Token"],
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// General rate limiting for all routes
|
// General rate limiting for all routes
|
||||||
@@ -126,22 +126,18 @@ app.use(
|
|||||||
// Store raw body for webhook verification
|
// Store raw body for webhook verification
|
||||||
req.rawBody = buf;
|
req.rawBody = buf;
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
app.use(
|
app.use(
|
||||||
bodyParser.urlencoded({
|
bodyParser.urlencoded({
|
||||||
extended: true,
|
extended: true,
|
||||||
limit: "1mb",
|
limit: "1mb",
|
||||||
parameterLimit: 100, // Limit number of parameters
|
parameterLimit: 100, // Limit number of parameters
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Serve static files from uploads directory with CORS headers
|
// Apply input sanitization to all API routes (XSS prevention)
|
||||||
app.use(
|
app.use("/api/", sanitizeInput);
|
||||||
"/uploads",
|
|
||||||
helmet.crossOriginResourcePolicy({ policy: "cross-origin" }),
|
|
||||||
express.static(path.join(__dirname, "uploads"))
|
|
||||||
);
|
|
||||||
|
|
||||||
// Health check endpoints (no auth, no rate limiting)
|
// Health check endpoints (no auth, no rate limiting)
|
||||||
app.use("/health", healthRoutes);
|
app.use("/health", healthRoutes);
|
||||||
@@ -157,6 +153,7 @@ app.get("/", (req, res) => {
|
|||||||
// Public routes (no alpha access required)
|
// Public routes (no alpha access required)
|
||||||
app.use("/api/alpha", alphaRoutes);
|
app.use("/api/alpha", alphaRoutes);
|
||||||
app.use("/api/auth", authRoutes); // Auth has its own alpha checks in registration
|
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)
|
// Protected routes (require alpha access)
|
||||||
app.use("/api/users", requireAlphaAccess, userRoutes);
|
app.use("/api/users", requireAlphaAccess, userRoutes);
|
||||||
@@ -174,7 +171,7 @@ app.use("/api/upload", requireAlphaAccess, uploadRoutes);
|
|||||||
app.use(errorLogger);
|
app.use(errorLogger);
|
||||||
app.use(sanitizeError);
|
app.use(sanitizeError);
|
||||||
|
|
||||||
const PORT = process.env.PORT || 5000;
|
const PORT = process.env.PORT;
|
||||||
|
|
||||||
const { checkPendingMigrations } = require("./utils/checkMigrations");
|
const { checkPendingMigrations } = require("./utils/checkMigrations");
|
||||||
|
|
||||||
@@ -188,7 +185,7 @@ sequelize
|
|||||||
if (pendingMigrations.length > 0) {
|
if (pendingMigrations.length > 0) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Found ${pendingMigrations.length} pending migration(s). Please run 'npm run db:migrate'`,
|
`Found ${pendingMigrations.length} pending migration(s). Please run 'npm run db:migrate'`,
|
||||||
{ pendingMigrations }
|
{ pendingMigrations },
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -206,12 +203,12 @@ sequelize
|
|||||||
// Fail fast - don't start server if email templates can't load
|
// Fail fast - don't start server if email templates can't load
|
||||||
if (env === "prod" || env === "production") {
|
if (env === "prod" || env === "production") {
|
||||||
logger.error(
|
logger.error(
|
||||||
"Cannot start server without email services in production"
|
"Cannot start server without email services in production",
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
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);
|
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, () => {
|
server.listen(PORT, () => {
|
||||||
logger.info(`Server is running on port ${PORT}`, {
|
logger.info(`Server is running on port ${PORT}`, {
|
||||||
port: PORT,
|
port: PORT,
|
||||||
|
|||||||
305
backend/services/TwoFactorService.js
Normal file
305
backend/services/TwoFactorService.js
Normal 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;
|
||||||
@@ -156,13 +156,21 @@ class ConditionCheckService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all condition checks for a rental
|
* Get all condition checks for multiple rentals (batch)
|
||||||
* @param {string} rentalId - Rental ID
|
* @param {Array<string>} rentalIds - Array of Rental IDs
|
||||||
* @returns {Array} - Array of condition checks with user info
|
* @returns {Array} - Array of condition checks with user info
|
||||||
*/
|
*/
|
||||||
static async getConditionChecks(rentalId) {
|
static async getConditionChecksForRentals(rentalIds) {
|
||||||
|
if (!rentalIds || rentalIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const checks = await ConditionCheck.findAll({
|
const checks = await ConditionCheck.findAll({
|
||||||
where: { rentalId },
|
where: {
|
||||||
|
rentalId: {
|
||||||
|
[Op.in]: rentalIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
@@ -176,119 +184,24 @@ class ConditionCheckService {
|
|||||||
return checks;
|
return checks;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get condition check timeline for a rental
|
|
||||||
* @param {string} rentalId - Rental ID
|
|
||||||
* @returns {Object} - Timeline showing what checks are available/completed
|
|
||||||
*/
|
|
||||||
static async getConditionCheckTimeline(rentalId) {
|
|
||||||
const rental = await Rental.findByPk(rentalId);
|
|
||||||
if (!rental) {
|
|
||||||
throw new Error("Rental not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingChecks = await ConditionCheck.findAll({
|
|
||||||
where: { rentalId },
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: User,
|
|
||||||
as: "submittedByUser",
|
|
||||||
attributes: ["id", "firstName", "lastName"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const checkTypes = [
|
|
||||||
"pre_rental_owner",
|
|
||||||
"rental_start_renter",
|
|
||||||
"rental_end_renter",
|
|
||||||
"post_rental_owner",
|
|
||||||
];
|
|
||||||
|
|
||||||
const timeline = {};
|
|
||||||
|
|
||||||
for (const checkType of checkTypes) {
|
|
||||||
const existingCheck = existingChecks.find(
|
|
||||||
(check) => check.checkType === checkType
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingCheck) {
|
|
||||||
timeline[checkType] = {
|
|
||||||
status: "completed",
|
|
||||||
submittedAt: existingCheck.submittedAt,
|
|
||||||
submittedBy: existingCheck.submittedBy,
|
|
||||||
photoCount: existingCheck.imageFilenames.length,
|
|
||||||
hasNotes: !!existingCheck.notes,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// Calculate if this check type is available
|
|
||||||
const now = new Date();
|
|
||||||
const startDate = new Date(rental.startDateTime);
|
|
||||||
const endDate = new Date(rental.endDateTime);
|
|
||||||
const twentyFourHours = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
let timeWindow = {};
|
|
||||||
let status = "not_available";
|
|
||||||
|
|
||||||
switch (checkType) {
|
|
||||||
case "pre_rental_owner":
|
|
||||||
timeWindow.start = new Date(startDate.getTime() - twentyFourHours);
|
|
||||||
timeWindow.end = startDate;
|
|
||||||
break;
|
|
||||||
case "rental_start_renter":
|
|
||||||
timeWindow.start = startDate;
|
|
||||||
timeWindow.end = new Date(startDate.getTime() + twentyFourHours);
|
|
||||||
break;
|
|
||||||
case "rental_end_renter":
|
|
||||||
timeWindow.start = new Date(endDate.getTime() - twentyFourHours);
|
|
||||||
timeWindow.end = endDate;
|
|
||||||
break;
|
|
||||||
case "post_rental_owner":
|
|
||||||
timeWindow.start = endDate;
|
|
||||||
timeWindow.end = new Date(endDate.getTime() + twentyFourHours);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (now >= timeWindow.start && now <= timeWindow.end) {
|
|
||||||
status = "available";
|
|
||||||
} else if (now < timeWindow.start) {
|
|
||||||
status = "pending";
|
|
||||||
} else {
|
|
||||||
status = "expired";
|
|
||||||
}
|
|
||||||
|
|
||||||
timeline[checkType] = {
|
|
||||||
status,
|
|
||||||
timeWindow,
|
|
||||||
availableFrom: timeWindow.start,
|
|
||||||
availableUntil: timeWindow.end,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
rental: {
|
|
||||||
id: rental.id,
|
|
||||||
startDateTime: rental.startDateTime,
|
|
||||||
endDateTime: rental.endDateTime,
|
|
||||||
status: rental.status,
|
|
||||||
},
|
|
||||||
timeline,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get available condition checks for a user
|
* Get available condition checks for a user
|
||||||
* @param {string} userId - User ID
|
* @param {string} userId - User ID
|
||||||
|
* @param {Array<string>} rentalIds - Array of rental IDs to check
|
||||||
* @returns {Array} - Array of available condition checks
|
* @returns {Array} - Array of available condition checks
|
||||||
*/
|
*/
|
||||||
static async getAvailableChecks(userId) {
|
static async getAvailableChecks(userId, rentalIds) {
|
||||||
|
if (!rentalIds || rentalIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const twentyFourHours = 24 * 60 * 60 * 1000;
|
const twentyFourHours = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
// Find rentals where user is owner or renter
|
// Find specified rentals where user is owner or renter
|
||||||
const rentals = await Rental.findAll({
|
const rentals = await Rental.findAll({
|
||||||
where: {
|
where: {
|
||||||
|
id: { [Op.in]: rentalIds },
|
||||||
[Op.or]: [{ ownerId: userId }, { renterId: userId }],
|
[Op.or]: [{ ownerId: userId }, { renterId: userId }],
|
||||||
status: {
|
status: {
|
||||||
[Op.in]: ["confirmed", "active", "completed"],
|
[Op.in]: ["confirmed", "active", "completed"],
|
||||||
|
|||||||
162
backend/services/disputeService.js
Normal file
162
backend/services/disputeService.js
Normal 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;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
const { SESClient, SendEmailCommand } = require("@aws-sdk/client-ses");
|
const { SESClient, SendEmailCommand } = require("@aws-sdk/client-ses");
|
||||||
const { getAWSConfig } = require("../../../config/aws");
|
const { getAWSConfig } = require("../../../config/aws");
|
||||||
const { htmlToPlainText } = require("./emailUtils");
|
const { htmlToPlainText } = require("./emailUtils");
|
||||||
|
const logger = require("../../../utils/logger");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EmailClient handles AWS SES configuration and core email sending functionality
|
* EmailClient handles AWS SES configuration and core email sending functionality
|
||||||
@@ -44,9 +45,9 @@ class EmailClient {
|
|||||||
this.sesClient = new SESClient(awsConfig);
|
this.sesClient = new SESClient(awsConfig);
|
||||||
|
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
console.log("AWS SES Email Client initialized successfully");
|
logger.info("AWS SES Email Client initialized successfully");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to initialize AWS SES Email Client:", error);
|
logger.error("Failed to initialize AWS SES Email Client", { error });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -69,7 +70,7 @@ class EmailClient {
|
|||||||
|
|
||||||
// Check if email sending is enabled in the environment
|
// Check if email sending is enabled in the environment
|
||||||
if (!process.env.EMAIL_ENABLED || process.env.EMAIL_ENABLED !== "true") {
|
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" };
|
return { success: true, messageId: "disabled" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,12 +116,10 @@ class EmailClient {
|
|||||||
const command = new SendEmailCommand(params);
|
const command = new SendEmailCommand(params);
|
||||||
const result = await this.sesClient.send(command);
|
const result = await this.sesClient.send(command);
|
||||||
|
|
||||||
console.log(
|
logger.info("Email sent successfully", { to, messageId: result.MessageId });
|
||||||
`Email sent successfully to ${to}, MessageId: ${result.MessageId}`
|
|
||||||
);
|
|
||||||
return { success: true, messageId: result.MessageId };
|
return { success: true, messageId: result.MessageId };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send email:", error);
|
logger.error("Failed to send email", { error, to });
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
const fs = require("fs").promises;
|
const fs = require("fs").promises;
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
const logger = require("../../../utils/logger");
|
||||||
|
const { escapeHtml } = require("./emailUtils");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TemplateManager handles loading, caching, and rendering email templates
|
* TemplateManager handles loading, caching, and rendering email templates
|
||||||
@@ -9,6 +11,14 @@ const path = require("path");
|
|||||||
* - Rendering templates with variable substitution
|
* - Rendering templates with variable substitution
|
||||||
* - Providing fallback templates when files can't be loaded
|
* - 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 {
|
class TemplateManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
// Singleton pattern - return existing instance if already created
|
// Singleton pattern - return existing instance if already created
|
||||||
@@ -16,15 +26,76 @@ class TemplateManager {
|
|||||||
return TemplateManager.instance;
|
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.initialized = false;
|
||||||
this.initializationPromise = null;
|
this.initializationPromise = null;
|
||||||
|
this.templatesDir = path.join(
|
||||||
|
__dirname,
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"templates",
|
||||||
|
"emails"
|
||||||
|
);
|
||||||
|
|
||||||
TemplateManager.instance = this;
|
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>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async initialize() {
|
async initialize() {
|
||||||
@@ -38,116 +109,35 @@ class TemplateManager {
|
|||||||
|
|
||||||
// Start initialization and store the promise
|
// Start initialization and store the promise
|
||||||
this.initializationPromise = (async () => {
|
this.initializationPromise = (async () => {
|
||||||
await this.loadEmailTemplates();
|
// Discover all available templates (fast - only reads filenames)
|
||||||
this.initialized = true;
|
await this.discoverTemplates();
|
||||||
console.log("Email Template Manager initialized successfully");
|
|
||||||
})();
|
|
||||||
|
|
||||||
return this.initializationPromise;
|
// Preload critical templates for auth flows
|
||||||
}
|
const missingCritical = [];
|
||||||
|
for (const templateName of CRITICAL_TEMPLATES) {
|
||||||
/**
|
if (!this.templateNames.has(templateName)) {
|
||||||
* Load all email templates from disk into memory
|
missingCritical.push(templateName);
|
||||||
* @returns {Promise<void>}
|
} else {
|
||||||
*/
|
await this.loadTemplate(templateName);
|
||||||
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",
|
|
||||||
];
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
if (missingCritical.length > 0) {
|
||||||
`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) {
|
|
||||||
const error = new Error(
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warn if non-critical templates failed
|
this.initialized = true;
|
||||||
if (failedTemplates.length > 0) {
|
logger.info("Email Template Manager initialized successfully", {
|
||||||
console.warn(
|
discovered: this.templateNames.size,
|
||||||
`⚠️ Non-critical templates failed to load: ${failedTemplates.join(", ")}`
|
preloaded: CRITICAL_TEMPLATES.length,
|
||||||
);
|
});
|
||||||
console.warn("These templates will use fallback versions");
|
})();
|
||||||
}
|
|
||||||
} catch (error) {
|
return this.initializationPromise;
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -159,60 +149,74 @@ class TemplateManager {
|
|||||||
async renderTemplate(templateName, variables = {}) {
|
async renderTemplate(templateName, variables = {}) {
|
||||||
// Ensure service is initialized before rendering
|
// Ensure service is initialized before rendering
|
||||||
if (!this.initialized) {
|
if (!this.initialized) {
|
||||||
console.log(`Template manager not initialized yet, initializing now...`);
|
logger.debug("Template manager not initialized yet, initializing now...");
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
let template = this.templates.get(templateName);
|
let template;
|
||||||
|
|
||||||
if (!template) {
|
// Check if template exists in our discovered templates
|
||||||
console.error(`Template not found: ${templateName}`);
|
if (this.templateNames.has(templateName)) {
|
||||||
console.error(
|
// Lazy load the template if not already cached
|
||||||
`Available templates: ${Array.from(this.templates.keys()).join(", ")}`
|
template = await this.loadTemplate(templateName);
|
||||||
);
|
|
||||||
console.error(`Stack trace:`, new Error().stack);
|
|
||||||
console.log(`Using fallback template for: ${templateName}`);
|
|
||||||
template = this.getFallbackTemplate(templateName);
|
|
||||||
} else {
|
} 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;
|
let rendered = template;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Object.keys(variables).forEach((key) => {
|
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");
|
const regex = new RegExp(`{{${key}}}`, "g");
|
||||||
rendered = rendered.replace(regex, variables[key] || "");
|
rendered = rendered.replace(regex, value);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error rendering template ${templateName}:`, error);
|
logger.error("Error rendering template", {
|
||||||
console.error(`Stack trace:`, error.stack);
|
templateName,
|
||||||
console.error(`Variables provided:`, Object.keys(variables));
|
variableKeys: Object.keys(variables),
|
||||||
|
error,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return rendered;
|
return rendered;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a fallback template when the HTML file is not available
|
* Get a generic fallback template when the HTML file is not available
|
||||||
* @param {string} templateName - Name of the template
|
* This is used as a last resort when a template cannot be loaded
|
||||||
* @returns {string} Fallback HTML template
|
* @param {string} templateName - Name of the template (for logging)
|
||||||
|
* @returns {string} Generic fallback HTML template
|
||||||
*/
|
*/
|
||||||
getFallbackTemplate(templateName) {
|
getFallbackTemplate(templateName) {
|
||||||
const baseTemplate = `
|
logger.warn("Using generic fallback template", { templateName });
|
||||||
|
|
||||||
|
return `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{title}}</title>
|
<title>Village Share</title>
|
||||||
<style>
|
<style>
|
||||||
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5; }
|
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); }
|
.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; }
|
.header { text-align: center; border-bottom: 2px solid #e9ecef; padding-bottom: 20px; margin-bottom: 30px; }
|
||||||
.logo { font-size: 24px; font-weight: bold; color: #333; }
|
.logo { font-size: 24px; font-weight: bold; color: #333; }
|
||||||
.content { line-height: 1.6; color: #555; }
|
.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; }
|
.footer { margin-top: 30px; padding-top: 20px; border-top: 1px solid #e9ecef; text-align: center; font-size: 12px; color: #6c757d; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -222,7 +226,9 @@ class TemplateManager {
|
|||||||
<div class="logo">Village Share</div>
|
<div class="logo">Village Share</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{{content}}
|
<p>Hi {{recipientName}},</p>
|
||||||
|
<h2>{{title}}</h2>
|
||||||
|
<p>{{message}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<p>This email was sent from Village Share. If you have any questions, please contact support.</p>
|
<p>This email was sent from Village Share. If you have any questions, please contact support.</p>
|
||||||
@@ -231,270 +237,6 @@ class TemplateManager {
|
|||||||
</body>
|
</body>
|
||||||
</html>
|
</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>
|
|
||||||
`
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
templates[templateName] ||
|
|
||||||
baseTemplate.replace(
|
|
||||||
"{{content}}",
|
|
||||||
`
|
|
||||||
<h2>{{title}}</h2>
|
|
||||||
<p>{{message}}</p>
|
|
||||||
`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,9 +90,26 @@ function formatCurrency(amount, currency = "USD") {
|
|||||||
}).format(amount / 100);
|
}).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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
htmlToPlainText,
|
htmlToPlainText,
|
||||||
formatEmailDate,
|
formatEmailDate,
|
||||||
formatShortDate,
|
formatShortDate,
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
|
escapeHtml,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const EmailClient = require("../core/EmailClient");
|
const EmailClient = require("../core/EmailClient");
|
||||||
const TemplateManager = require("../core/TemplateManager");
|
const TemplateManager = require("../core/TemplateManager");
|
||||||
|
const logger = require("../../../utils/logger");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AlphaInvitationEmailService handles alpha program invitation emails
|
* AlphaInvitationEmailService handles alpha program invitation emails
|
||||||
@@ -26,7 +27,7 @@ class AlphaInvitationEmailService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
this.initialized = true;
|
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 {
|
try {
|
||||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
const frontendUrl = process.env.FRONTEND_URL;
|
||||||
|
|
||||||
const variables = {
|
const variables = {
|
||||||
code: code,
|
code: code,
|
||||||
@@ -53,16 +54,16 @@ class AlphaInvitationEmailService {
|
|||||||
|
|
||||||
const htmlContent = await this.templateManager.renderTemplate(
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
"alphaInvitationToUser",
|
"alphaInvitationToUser",
|
||||||
variables
|
variables,
|
||||||
);
|
);
|
||||||
|
|
||||||
return await this.emailClient.sendEmail(
|
return await this.emailClient.sendEmail(
|
||||||
email,
|
email,
|
||||||
"Your Alpha Access Code - Village Share",
|
"Your Alpha Access Code - Village Share",
|
||||||
htmlContent
|
htmlContent,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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 };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class AuthEmailService {
|
|||||||
await this.initialize();
|
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 verificationUrl = `${frontendUrl}/verify-email?token=${verificationToken}`;
|
||||||
|
|
||||||
const variables = {
|
const variables = {
|
||||||
@@ -55,13 +55,13 @@ class AuthEmailService {
|
|||||||
|
|
||||||
const htmlContent = await this.templateManager.renderTemplate(
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
"emailVerificationToUser",
|
"emailVerificationToUser",
|
||||||
variables
|
variables,
|
||||||
);
|
);
|
||||||
|
|
||||||
return await this.emailClient.sendEmail(
|
return await this.emailClient.sendEmail(
|
||||||
user.email,
|
user.email,
|
||||||
"Verify Your Email - Village Share",
|
"Verify Your Email - Village Share",
|
||||||
htmlContent
|
htmlContent,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ class AuthEmailService {
|
|||||||
await this.initialize();
|
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 resetUrl = `${frontendUrl}/reset-password?token=${resetToken}`;
|
||||||
|
|
||||||
const variables = {
|
const variables = {
|
||||||
@@ -88,13 +88,13 @@ class AuthEmailService {
|
|||||||
|
|
||||||
const htmlContent = await this.templateManager.renderTemplate(
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
"passwordResetToUser",
|
"passwordResetToUser",
|
||||||
variables
|
variables,
|
||||||
);
|
);
|
||||||
|
|
||||||
return await this.emailClient.sendEmail(
|
return await this.emailClient.sendEmail(
|
||||||
user.email,
|
user.email,
|
||||||
"Reset Your Password - Village Share",
|
"Reset Your Password - Village Share",
|
||||||
htmlContent
|
htmlContent,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,13 +123,13 @@ class AuthEmailService {
|
|||||||
|
|
||||||
const htmlContent = await this.templateManager.renderTemplate(
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
"passwordChangedToUser",
|
"passwordChangedToUser",
|
||||||
variables
|
variables,
|
||||||
);
|
);
|
||||||
|
|
||||||
return await this.emailClient.sendEmail(
|
return await this.emailClient.sendEmail(
|
||||||
user.email,
|
user.email,
|
||||||
"Password Changed Successfully - Village Share",
|
"Password Changed Successfully - Village Share",
|
||||||
htmlContent
|
htmlContent,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,13 +158,157 @@ class AuthEmailService {
|
|||||||
|
|
||||||
const htmlContent = await this.templateManager.renderTemplate(
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
"personalInfoChangedToUser",
|
"personalInfoChangedToUser",
|
||||||
variables
|
variables,
|
||||||
);
|
);
|
||||||
|
|
||||||
return await this.emailClient.sendEmail(
|
return await this.emailClient.sendEmail(
|
||||||
user.email,
|
user.email,
|
||||||
"Personal Information Updated - Village Share",
|
"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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const EmailClient = require("../core/EmailClient");
|
const EmailClient = require("../core/EmailClient");
|
||||||
const TemplateManager = require("../core/TemplateManager");
|
const TemplateManager = require("../core/TemplateManager");
|
||||||
|
const logger = require("../../../utils/logger");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CustomerServiceEmailService handles all customer service alert emails
|
* CustomerServiceEmailService handles all customer service alert emails
|
||||||
@@ -28,7 +29,7 @@ class CustomerServiceEmailService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
this.initialized = true;
|
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 {
|
try {
|
||||||
const csEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
|
const csEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
|
||||||
if (!csEmail) {
|
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" };
|
return { success: false, error: "No customer service email configured" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,14 +93,14 @@ class CustomerServiceEmailService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
console.log(
|
logger.info(
|
||||||
`Late return notification sent to customer service for rental ${rental.id}`
|
`Late return notification sent to customer service for rental ${rental.id}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
logger.error(
|
||||||
"Failed to send late return notification to customer service:",
|
"Failed to send late return notification to customer service:",
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
@@ -148,7 +149,7 @@ class CustomerServiceEmailService {
|
|||||||
try {
|
try {
|
||||||
const csEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
|
const csEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
|
||||||
if (!csEmail) {
|
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" };
|
return { success: false, error: "No customer service email configured" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,14 +207,14 @@ class CustomerServiceEmailService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
console.log(
|
logger.info(
|
||||||
`Damage report notification sent to customer service for rental ${rental.id}`
|
`Damage report notification sent to customer service for rental ${rental.id}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
logger.error(
|
||||||
"Failed to send damage report notification to customer service:",
|
"Failed to send damage report notification to customer service:",
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
@@ -248,7 +249,7 @@ class CustomerServiceEmailService {
|
|||||||
try {
|
try {
|
||||||
const csEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
|
const csEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
|
||||||
if (!csEmail) {
|
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" };
|
return { success: false, error: "No customer service email configured" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,14 +281,14 @@ class CustomerServiceEmailService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
console.log(
|
logger.info(
|
||||||
`Lost item notification sent to customer service for rental ${rental.id}`
|
`Lost item notification sent to customer service for rental ${rental.id}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
logger.error(
|
||||||
"Failed to send lost item notification to customer service:",
|
"Failed to send lost item notification to customer service:",
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,13 +60,13 @@ class FeedbackEmailService {
|
|||||||
|
|
||||||
const htmlContent = await this.templateManager.renderTemplate(
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
"feedbackConfirmationToUser",
|
"feedbackConfirmationToUser",
|
||||||
variables
|
variables,
|
||||||
);
|
);
|
||||||
|
|
||||||
return await this.emailClient.sendEmail(
|
return await this.emailClient.sendEmail(
|
||||||
user.email,
|
user.email,
|
||||||
"Thank You for Your Feedback - Village Share",
|
"Thank You for Your Feedback - Village Share",
|
||||||
htmlContent
|
htmlContent,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,8 +90,7 @@ class FeedbackEmailService {
|
|||||||
await this.initialize();
|
await this.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminEmail =
|
const adminEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
|
||||||
process.env.FEEDBACK_EMAIL || process.env.CUSTOMER_SUPPORT_EMAIL;
|
|
||||||
|
|
||||||
if (!adminEmail) {
|
if (!adminEmail) {
|
||||||
console.warn("No admin email configured for feedback notifications");
|
console.warn("No admin email configured for feedback notifications");
|
||||||
@@ -117,13 +116,13 @@ class FeedbackEmailService {
|
|||||||
|
|
||||||
const htmlContent = await this.templateManager.renderTemplate(
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
"feedbackNotificationToAdmin",
|
"feedbackNotificationToAdmin",
|
||||||
variables
|
variables,
|
||||||
);
|
);
|
||||||
|
|
||||||
return await this.emailClient.sendEmail(
|
return await this.emailClient.sendEmail(
|
||||||
adminEmail,
|
adminEmail,
|
||||||
`New Feedback from ${user.firstName} ${user.lastName}`,
|
`New Feedback from ${user.firstName} ${user.lastName}`,
|
||||||
htmlContent
|
htmlContent,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const EmailClient = require("../core/EmailClient");
|
const EmailClient = require("../core/EmailClient");
|
||||||
const TemplateManager = require("../core/TemplateManager");
|
const TemplateManager = require("../core/TemplateManager");
|
||||||
|
const logger = require("../../../utils/logger");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ForumEmailService handles all forum-related email notifications
|
* ForumEmailService handles all forum-related email notifications
|
||||||
@@ -31,7 +32,7 @@ class ForumEmailService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
console.log("Forum Email Service initialized successfully");
|
logger.info("Forum Email Service initialized successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,7 +57,7 @@ class ForumEmailService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
const frontendUrl = process.env.FRONTEND_URL;
|
||||||
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
|
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
|
||||||
|
|
||||||
const timestamp = new Date(comment.createdAt).toLocaleString("en-US", {
|
const timestamp = new Date(comment.createdAt).toLocaleString("en-US", {
|
||||||
@@ -76,7 +77,7 @@ class ForumEmailService {
|
|||||||
|
|
||||||
const htmlContent = await this.templateManager.renderTemplate(
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
"forumCommentToPostAuthor",
|
"forumCommentToPostAuthor",
|
||||||
variables
|
variables,
|
||||||
);
|
);
|
||||||
|
|
||||||
const subject = `${commenter.firstName} ${commenter.lastName} commented on your post`;
|
const subject = `${commenter.firstName} ${commenter.lastName} commented on your post`;
|
||||||
@@ -84,18 +85,18 @@ class ForumEmailService {
|
|||||||
const result = await this.emailClient.sendEmail(
|
const result = await this.emailClient.sendEmail(
|
||||||
postAuthor.email,
|
postAuthor.email,
|
||||||
subject,
|
subject,
|
||||||
htmlContent
|
htmlContent,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
console.log(
|
logger.info(
|
||||||
`Forum comment notification email sent to ${postAuthor.email}`
|
`Forum comment notification email sent to ${postAuthor.email}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} 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 };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,14 +124,14 @@ class ForumEmailService {
|
|||||||
replier,
|
replier,
|
||||||
post,
|
post,
|
||||||
reply,
|
reply,
|
||||||
parentComment
|
parentComment,
|
||||||
) {
|
) {
|
||||||
if (!this.initialized) {
|
if (!this.initialized) {
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
const frontendUrl = process.env.FRONTEND_URL;
|
||||||
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
|
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
|
||||||
|
|
||||||
const timestamp = new Date(reply.createdAt).toLocaleString("en-US", {
|
const timestamp = new Date(reply.createdAt).toLocaleString("en-US", {
|
||||||
@@ -151,7 +152,7 @@ class ForumEmailService {
|
|||||||
|
|
||||||
const htmlContent = await this.templateManager.renderTemplate(
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
"forumReplyToCommentAuthor",
|
"forumReplyToCommentAuthor",
|
||||||
variables
|
variables,
|
||||||
);
|
);
|
||||||
|
|
||||||
const subject = `${replier.firstName} ${replier.lastName} replied to your comment`;
|
const subject = `${replier.firstName} ${replier.lastName} replied to your comment`;
|
||||||
@@ -159,18 +160,18 @@ class ForumEmailService {
|
|||||||
const result = await this.emailClient.sendEmail(
|
const result = await this.emailClient.sendEmail(
|
||||||
commentAuthor.email,
|
commentAuthor.email,
|
||||||
subject,
|
subject,
|
||||||
htmlContent
|
htmlContent,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
console.log(
|
logger.info(
|
||||||
`Forum reply notification email sent to ${commentAuthor.email}`
|
`Forum reply notification email sent to ${commentAuthor.email}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} 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 };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,14 +195,14 @@ class ForumEmailService {
|
|||||||
commentAuthor,
|
commentAuthor,
|
||||||
postAuthor,
|
postAuthor,
|
||||||
post,
|
post,
|
||||||
comment
|
comment,
|
||||||
) {
|
) {
|
||||||
if (!this.initialized) {
|
if (!this.initialized) {
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
const frontendUrl = process.env.FRONTEND_URL;
|
||||||
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
|
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
|
||||||
|
|
||||||
const variables = {
|
const variables = {
|
||||||
@@ -215,7 +216,7 @@ class ForumEmailService {
|
|||||||
|
|
||||||
const htmlContent = await this.templateManager.renderTemplate(
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
"forumAnswerAcceptedToCommentAuthor",
|
"forumAnswerAcceptedToCommentAuthor",
|
||||||
variables
|
variables,
|
||||||
);
|
);
|
||||||
|
|
||||||
const subject = `Your comment was marked as the accepted answer!`;
|
const subject = `Your comment was marked as the accepted answer!`;
|
||||||
@@ -223,20 +224,20 @@ class ForumEmailService {
|
|||||||
const result = await this.emailClient.sendEmail(
|
const result = await this.emailClient.sendEmail(
|
||||||
commentAuthor.email,
|
commentAuthor.email,
|
||||||
subject,
|
subject,
|
||||||
htmlContent
|
htmlContent,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
console.log(
|
logger.info(
|
||||||
`Forum answer accepted notification email sent to ${commentAuthor.email}`
|
`Forum answer accepted notification email sent to ${commentAuthor.email}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
logger.error(
|
||||||
"Failed to send forum answer accepted notification email:",
|
"Failed to send forum answer accepted notification email:",
|
||||||
error
|
error,
|
||||||
);
|
);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
@@ -262,14 +263,14 @@ class ForumEmailService {
|
|||||||
participant,
|
participant,
|
||||||
commenter,
|
commenter,
|
||||||
post,
|
post,
|
||||||
comment
|
comment,
|
||||||
) {
|
) {
|
||||||
if (!this.initialized) {
|
if (!this.initialized) {
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
const frontendUrl = process.env.FRONTEND_URL;
|
||||||
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
|
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
|
||||||
|
|
||||||
const timestamp = new Date(comment.createdAt).toLocaleString("en-US", {
|
const timestamp = new Date(comment.createdAt).toLocaleString("en-US", {
|
||||||
@@ -289,7 +290,7 @@ class ForumEmailService {
|
|||||||
|
|
||||||
const htmlContent = await this.templateManager.renderTemplate(
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
"forumThreadActivityToParticipant",
|
"forumThreadActivityToParticipant",
|
||||||
variables
|
variables,
|
||||||
);
|
);
|
||||||
|
|
||||||
const subject = `New activity on a post you're following`;
|
const subject = `New activity on a post you're following`;
|
||||||
@@ -297,20 +298,20 @@ class ForumEmailService {
|
|||||||
const result = await this.emailClient.sendEmail(
|
const result = await this.emailClient.sendEmail(
|
||||||
participant.email,
|
participant.email,
|
||||||
subject,
|
subject,
|
||||||
htmlContent
|
htmlContent,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
console.log(
|
logger.info(
|
||||||
`Forum thread activity notification email sent to ${participant.email}`
|
`Forum thread activity notification email sent to ${participant.email}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
logger.error(
|
||||||
"Failed to send forum thread activity notification email:",
|
"Failed to send forum thread activity notification email:",
|
||||||
error
|
error,
|
||||||
);
|
);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
@@ -330,18 +331,13 @@ class ForumEmailService {
|
|||||||
* @param {Date} closedAt - Timestamp when discussion was closed
|
* @param {Date} closedAt - Timestamp when discussion was closed
|
||||||
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
|
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
|
||||||
*/
|
*/
|
||||||
async sendForumPostClosedNotification(
|
async sendForumPostClosedNotification(recipient, closer, post, closedAt) {
|
||||||
recipient,
|
|
||||||
closer,
|
|
||||||
post,
|
|
||||||
closedAt
|
|
||||||
) {
|
|
||||||
if (!this.initialized) {
|
if (!this.initialized) {
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
const frontendUrl = process.env.FRONTEND_URL;
|
||||||
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
|
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
|
||||||
|
|
||||||
const timestamp = new Date(closedAt).toLocaleString("en-US", {
|
const timestamp = new Date(closedAt).toLocaleString("en-US", {
|
||||||
@@ -351,8 +347,7 @@ class ForumEmailService {
|
|||||||
|
|
||||||
const variables = {
|
const variables = {
|
||||||
recipientName: recipient.firstName || "there",
|
recipientName: recipient.firstName || "there",
|
||||||
adminName:
|
adminName: `${closer.firstName} ${closer.lastName}`.trim() || "A user",
|
||||||
`${closer.firstName} ${closer.lastName}`.trim() || "A user",
|
|
||||||
postTitle: post.title,
|
postTitle: post.title,
|
||||||
postUrl: postUrl,
|
postUrl: postUrl,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
@@ -360,7 +355,7 @@ class ForumEmailService {
|
|||||||
|
|
||||||
const htmlContent = await this.templateManager.renderTemplate(
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
"forumPostClosed",
|
"forumPostClosed",
|
||||||
variables
|
variables,
|
||||||
);
|
);
|
||||||
|
|
||||||
const subject = `Discussion closed: ${post.title}`;
|
const subject = `Discussion closed: ${post.title}`;
|
||||||
@@ -368,20 +363,20 @@ class ForumEmailService {
|
|||||||
const result = await this.emailClient.sendEmail(
|
const result = await this.emailClient.sendEmail(
|
||||||
recipient.email,
|
recipient.email,
|
||||||
subject,
|
subject,
|
||||||
htmlContent
|
htmlContent,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
console.log(
|
logger.info(
|
||||||
`Forum post closed notification email sent to ${recipient.email}`
|
`Forum post closed notification email sent to ${recipient.email}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
logger.error(
|
||||||
"Failed to send forum post closed notification email:",
|
"Failed to send forum post closed notification email:",
|
||||||
error
|
error,
|
||||||
);
|
);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
@@ -400,18 +395,24 @@ class ForumEmailService {
|
|||||||
* @param {string} deletionReason - Reason for deletion
|
* @param {string} deletionReason - Reason for deletion
|
||||||
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
|
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
|
||||||
*/
|
*/
|
||||||
async sendForumPostDeletionNotification(postAuthor, admin, post, deletionReason) {
|
async sendForumPostDeletionNotification(
|
||||||
|
postAuthor,
|
||||||
|
admin,
|
||||||
|
post,
|
||||||
|
deletionReason,
|
||||||
|
) {
|
||||||
if (!this.initialized) {
|
if (!this.initialized) {
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const supportEmail = process.env.SUPPORT_EMAIL;
|
const supportEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
|
||||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
const frontendUrl = process.env.FRONTEND_URL;
|
||||||
|
|
||||||
const variables = {
|
const variables = {
|
||||||
postAuthorName: postAuthor.firstName || "there",
|
postAuthorName: postAuthor.firstName || "there",
|
||||||
adminName: `${admin.firstName} ${admin.lastName}`.trim() || "An administrator",
|
adminName:
|
||||||
|
`${admin.firstName} ${admin.lastName}`.trim() || "An administrator",
|
||||||
postTitle: post.title,
|
postTitle: post.title,
|
||||||
deletionReason,
|
deletionReason,
|
||||||
supportEmail,
|
supportEmail,
|
||||||
@@ -420,7 +421,7 @@ class ForumEmailService {
|
|||||||
|
|
||||||
const htmlContent = await this.templateManager.renderTemplate(
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
"forumPostDeletionToAuthor",
|
"forumPostDeletionToAuthor",
|
||||||
variables
|
variables,
|
||||||
);
|
);
|
||||||
|
|
||||||
const subject = `Important: Your forum post "${post.title}" has been removed`;
|
const subject = `Important: Your forum post "${post.title}" has been removed`;
|
||||||
@@ -428,20 +429,20 @@ class ForumEmailService {
|
|||||||
const result = await this.emailClient.sendEmail(
|
const result = await this.emailClient.sendEmail(
|
||||||
postAuthor.email,
|
postAuthor.email,
|
||||||
subject,
|
subject,
|
||||||
htmlContent
|
htmlContent,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
console.log(
|
logger.info(
|
||||||
`Forum post deletion notification email sent to ${postAuthor.email}`
|
`Forum post deletion notification email sent to ${postAuthor.email}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
logger.error(
|
||||||
"Failed to send forum post deletion notification email:",
|
"Failed to send forum post deletion notification email:",
|
||||||
error
|
error,
|
||||||
);
|
);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
@@ -461,19 +462,25 @@ class ForumEmailService {
|
|||||||
* @param {string} deletionReason - Reason for deletion
|
* @param {string} deletionReason - Reason for deletion
|
||||||
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
|
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
|
||||||
*/
|
*/
|
||||||
async sendForumCommentDeletionNotification(commentAuthor, admin, post, deletionReason) {
|
async sendForumCommentDeletionNotification(
|
||||||
|
commentAuthor,
|
||||||
|
admin,
|
||||||
|
post,
|
||||||
|
deletionReason,
|
||||||
|
) {
|
||||||
if (!this.initialized) {
|
if (!this.initialized) {
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const supportEmail = process.env.SUPPORT_EMAIL;
|
const supportEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
|
||||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
const frontendUrl = process.env.FRONTEND_URL;
|
||||||
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
|
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
|
||||||
|
|
||||||
const variables = {
|
const variables = {
|
||||||
commentAuthorName: commentAuthor.firstName || "there",
|
commentAuthorName: commentAuthor.firstName || "there",
|
||||||
adminName: `${admin.firstName} ${admin.lastName}`.trim() || "An administrator",
|
adminName:
|
||||||
|
`${admin.firstName} ${admin.lastName}`.trim() || "An administrator",
|
||||||
postTitle: post.title,
|
postTitle: post.title,
|
||||||
postUrl,
|
postUrl,
|
||||||
deletionReason,
|
deletionReason,
|
||||||
@@ -482,7 +489,7 @@ class ForumEmailService {
|
|||||||
|
|
||||||
const htmlContent = await this.templateManager.renderTemplate(
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
"forumCommentDeletionToAuthor",
|
"forumCommentDeletionToAuthor",
|
||||||
variables
|
variables,
|
||||||
);
|
);
|
||||||
|
|
||||||
const subject = `Your comment on "${post.title}" has been removed`;
|
const subject = `Your comment on "${post.title}" has been removed`;
|
||||||
@@ -490,20 +497,20 @@ class ForumEmailService {
|
|||||||
const result = await this.emailClient.sendEmail(
|
const result = await this.emailClient.sendEmail(
|
||||||
commentAuthor.email,
|
commentAuthor.email,
|
||||||
subject,
|
subject,
|
||||||
htmlContent
|
htmlContent,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
console.log(
|
logger.info(
|
||||||
`Forum comment deletion notification email sent to ${commentAuthor.email}`
|
`Forum comment deletion notification email sent to ${commentAuthor.email}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
logger.error(
|
||||||
"Failed to send forum comment deletion notification email:",
|
"Failed to send forum comment deletion notification email:",
|
||||||
error
|
error,
|
||||||
);
|
);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
@@ -530,7 +537,7 @@ class ForumEmailService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
const frontendUrl = process.env.FRONTEND_URL;
|
||||||
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
|
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
|
||||||
|
|
||||||
const variables = {
|
const variables = {
|
||||||
@@ -545,7 +552,7 @@ class ForumEmailService {
|
|||||||
|
|
||||||
const htmlContent = await this.templateManager.renderTemplate(
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
"forumItemRequestNotification",
|
"forumItemRequestNotification",
|
||||||
variables
|
variables,
|
||||||
);
|
);
|
||||||
|
|
||||||
const subject = `Someone nearby is looking for: ${post.title}`;
|
const subject = `Someone nearby is looking for: ${post.title}`;
|
||||||
@@ -553,18 +560,18 @@ class ForumEmailService {
|
|||||||
const result = await this.emailClient.sendEmail(
|
const result = await this.emailClient.sendEmail(
|
||||||
recipient.email,
|
recipient.email,
|
||||||
subject,
|
subject,
|
||||||
htmlContent
|
htmlContent,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
console.log(
|
logger.info(
|
||||||
`Item request notification email sent to ${recipient.email}`
|
`Item request notification email sent to ${recipient.email}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} 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 };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const EmailClient = require("../core/EmailClient");
|
const EmailClient = require("../core/EmailClient");
|
||||||
const TemplateManager = require("../core/TemplateManager");
|
const TemplateManager = require("../core/TemplateManager");
|
||||||
|
const logger = require("../../../utils/logger");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MessagingEmailService handles all messaging-related email notifications
|
* MessagingEmailService handles all messaging-related email notifications
|
||||||
@@ -26,7 +27,7 @@ class MessagingEmailService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
console.log("Messaging Email Service initialized successfully");
|
logger.info("Messaging Email Service initialized successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,7 +50,7 @@ class MessagingEmailService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
const frontendUrl = process.env.FRONTEND_URL;
|
||||||
const conversationUrl = `${frontendUrl}/messages/conversations/${sender.id}`;
|
const conversationUrl = `${frontendUrl}/messages/conversations/${sender.id}`;
|
||||||
|
|
||||||
const timestamp = new Date(message.createdAt).toLocaleString("en-US", {
|
const timestamp = new Date(message.createdAt).toLocaleString("en-US", {
|
||||||
@@ -67,7 +68,7 @@ class MessagingEmailService {
|
|||||||
|
|
||||||
const htmlContent = await this.templateManager.renderTemplate(
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
"newMessageToUser",
|
"newMessageToUser",
|
||||||
variables
|
variables,
|
||||||
);
|
);
|
||||||
|
|
||||||
const subject = `New message from ${sender.firstName} ${sender.lastName}`;
|
const subject = `New message from ${sender.firstName} ${sender.lastName}`;
|
||||||
@@ -75,18 +76,18 @@ class MessagingEmailService {
|
|||||||
const result = await this.emailClient.sendEmail(
|
const result = await this.emailClient.sendEmail(
|
||||||
receiver.email,
|
receiver.email,
|
||||||
subject,
|
subject,
|
||||||
htmlContent
|
htmlContent,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
console.log(
|
logger.info(
|
||||||
`Message notification email sent to ${receiver.email} from ${sender.firstName} ${sender.lastName}`
|
`Message notification email sent to ${receiver.email} from ${sender.firstName} ${sender.lastName}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} 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 };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
375
backend/services/email/domain/PaymentEmailService.js
Normal file
375
backend/services/email/domain/PaymentEmailService.js
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
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
|
||||||
|
* This service is responsible for:
|
||||||
|
* - Sending payment declined notifications to renters
|
||||||
|
* - Sending payment method updated notifications to owners
|
||||||
|
*/
|
||||||
|
class PaymentEmailService {
|
||||||
|
constructor() {
|
||||||
|
this.emailClient = new EmailClient();
|
||||||
|
this.templateManager = new TemplateManager();
|
||||||
|
this.initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the payment email service
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async initialize() {
|
||||||
|
if (this.initialized) return;
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
this.emailClient.initialize(),
|
||||||
|
this.templateManager.initialize(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.initialized = true;
|
||||||
|
logger.info("Payment Email Service initialized successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send payment declined notification to renter
|
||||||
|
* @param {string} renterEmail - Renter's email address
|
||||||
|
* @param {Object} params - Email parameters
|
||||||
|
* @param {string} params.renterFirstName - Renter's first name
|
||||||
|
* @param {string} params.itemName - Item name
|
||||||
|
* @param {string} params.declineReason - User-friendly decline reason
|
||||||
|
* @param {string} params.rentalId - Rental ID
|
||||||
|
* @param {string} params.updatePaymentUrl - URL to update payment method
|
||||||
|
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
|
||||||
|
*/
|
||||||
|
async sendPaymentDeclinedNotification(renterEmail, params) {
|
||||||
|
if (!this.initialized) {
|
||||||
|
await this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { renterFirstName, itemName, declineReason, updatePaymentUrl } =
|
||||||
|
params;
|
||||||
|
|
||||||
|
const variables = {
|
||||||
|
renterFirstName: renterFirstName || "there",
|
||||||
|
itemName: itemName || "the item",
|
||||||
|
declineReason: declineReason || "Your payment could not be processed.",
|
||||||
|
updatePaymentUrl: updatePaymentUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
|
"paymentDeclinedToRenter",
|
||||||
|
variables,
|
||||||
|
);
|
||||||
|
|
||||||
|
return await this.emailClient.sendEmail(
|
||||||
|
renterEmail,
|
||||||
|
`Action Required: Payment Issue - ${itemName || "Your Rental"}`,
|
||||||
|
htmlContent,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to send payment declined notification", { error });
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send payment method updated notification to owner
|
||||||
|
* @param {string} ownerEmail - Owner's email address
|
||||||
|
* @param {Object} params - Email parameters
|
||||||
|
* @param {string} params.ownerFirstName - Owner's first name
|
||||||
|
* @param {string} params.itemName - Item name
|
||||||
|
* @param {string} params.rentalId - Rental ID
|
||||||
|
* @param {string} params.approvalUrl - URL to approve the rental
|
||||||
|
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
|
||||||
|
*/
|
||||||
|
async sendPaymentMethodUpdatedNotification(ownerEmail, params) {
|
||||||
|
if (!this.initialized) {
|
||||||
|
await this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { ownerFirstName, itemName, approvalUrl } = params;
|
||||||
|
|
||||||
|
const variables = {
|
||||||
|
ownerFirstName: ownerFirstName || "there",
|
||||||
|
itemName: itemName || "the item",
|
||||||
|
approvalUrl: approvalUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
|
"paymentMethodUpdatedToOwner",
|
||||||
|
variables,
|
||||||
|
);
|
||||||
|
|
||||||
|
return await this.emailClient.sendEmail(
|
||||||
|
ownerEmail,
|
||||||
|
`Payment Method Updated - ${itemName || "Your Item"}`,
|
||||||
|
htmlContent,
|
||||||
|
);
|
||||||
|
} catch (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;
|
||||||
@@ -34,7 +34,7 @@ class RentalFlowEmailService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
this.initialized = true;
|
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 {
|
try {
|
||||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
const frontendUrl = process.env.FRONTEND_URL;
|
||||||
const approveUrl = `${frontendUrl}/owning?rentalId=${rental.id}`;
|
const approveUrl = `${frontendUrl}/owning?rentalId=${rental.id}`;
|
||||||
|
|
||||||
const variables = {
|
const variables = {
|
||||||
ownerName: owner.firstName,
|
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",
|
itemName: rental.item?.name || "your item",
|
||||||
startDate: rental.startDateTime
|
startDate: rental.startDateTime
|
||||||
? new Date(rental.startDateTime).toLocaleString("en-US", {
|
? new Date(rental.startDateTime).toLocaleString("en-US", {
|
||||||
@@ -94,16 +95,16 @@ class RentalFlowEmailService {
|
|||||||
|
|
||||||
const htmlContent = await this.templateManager.renderTemplate(
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
"rentalRequestToOwner",
|
"rentalRequestToOwner",
|
||||||
variables
|
variables,
|
||||||
);
|
);
|
||||||
|
|
||||||
return await this.emailClient.sendEmail(
|
return await this.emailClient.sendEmail(
|
||||||
owner.email,
|
owner.email,
|
||||||
`Rental Request for ${rental.item?.name || "Your Item"}`,
|
`Rental Request for ${rental.item?.name || "Your Item"}`,
|
||||||
htmlContent
|
htmlContent,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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 };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,7 +129,7 @@ class RentalFlowEmailService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
const frontendUrl = process.env.FRONTEND_URL;
|
||||||
const viewRentalsUrl = `${frontendUrl}/renting`;
|
const viewRentalsUrl = `${frontendUrl}/renting`;
|
||||||
|
|
||||||
// Determine payment message based on rental amount
|
// Determine payment message based on rental amount
|
||||||
@@ -161,16 +162,18 @@ class RentalFlowEmailService {
|
|||||||
|
|
||||||
const htmlContent = await this.templateManager.renderTemplate(
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
"rentalRequestConfirmationToRenter",
|
"rentalRequestConfirmationToRenter",
|
||||||
variables
|
variables,
|
||||||
);
|
);
|
||||||
|
|
||||||
return await this.emailClient.sendEmail(
|
return await this.emailClient.sendEmail(
|
||||||
renter.email,
|
renter.email,
|
||||||
`Rental Request Submitted - ${rental.item?.name || "Item"}`,
|
`Rental Request Submitted - ${rental.item?.name || "Item"}`,
|
||||||
htmlContent
|
htmlContent,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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 };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,7 +205,7 @@ class RentalFlowEmailService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
const frontendUrl = process.env.FRONTEND_URL;
|
||||||
|
|
||||||
// Determine if Stripe setup is needed
|
// Determine if Stripe setup is needed
|
||||||
const hasStripeAccount = !!owner.stripeConnectedAccountId;
|
const hasStripeAccount = !!owner.stripeConnectedAccountId;
|
||||||
@@ -227,15 +230,15 @@ class RentalFlowEmailService {
|
|||||||
<table class="info-table">
|
<table class="info-table">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Total Rental Amount</th>
|
<th>Total Rental Amount</th>
|
||||||
<td>\\$${totalAmount.toFixed(2)}</td>
|
<td>$${totalAmount.toFixed(2)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Community Upkeep Fee (10%)</th>
|
<th>Community Upkeep Fee (10%)</th>
|
||||||
<td>-\\$${platformFee.toFixed(2)}</td>
|
<td>-$${platformFee.toFixed(2)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Your Payout</th>
|
<th>Your Payout</th>
|
||||||
<td class="highlight">\\$${payoutAmount.toFixed(2)}</td>
|
<td class="highlight">$${payoutAmount.toFixed(2)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
`;
|
`;
|
||||||
@@ -248,8 +251,8 @@ class RentalFlowEmailService {
|
|||||||
stripeSection = `
|
stripeSection = `
|
||||||
<div class="warning-box">
|
<div class="warning-box">
|
||||||
<p><strong>⚠️ Action Required: Set Up Your Earnings Account</strong></p>
|
<p><strong>⚠️ Action Required: Set Up Your Earnings Account</strong></p>
|
||||||
<p>To receive your payout of <strong>\\$${payoutAmount.toFixed(
|
<p>To receive your payout of <strong>$${payoutAmount.toFixed(
|
||||||
2
|
2,
|
||||||
)}</strong> when this rental completes, you need to set up your earnings account.</p>
|
)}</strong> when this rental completes, you need to set up your earnings account.</p>
|
||||||
</div>
|
</div>
|
||||||
<h2>Set Up Earnings to Get Paid</h2>
|
<h2>Set Up Earnings to Get Paid</h2>
|
||||||
@@ -274,8 +277,8 @@ class RentalFlowEmailService {
|
|||||||
stripeSection = `
|
stripeSection = `
|
||||||
<div class="success-box">
|
<div class="success-box">
|
||||||
<p><strong>✓ Earnings Account Active</strong></p>
|
<p><strong>✓ Earnings Account Active</strong></p>
|
||||||
<p>Your earnings account is set up. You'll automatically receive \\$${payoutAmount.toFixed(
|
<p>Your earnings account is set up. You'll automatically receive $${payoutAmount.toFixed(
|
||||||
2
|
2,
|
||||||
)} when this rental completes.</p>
|
)} when this rental completes.</p>
|
||||||
<p><a href="${frontendUrl}/earnings" style="color: #155724; text-decoration: underline;">View your earnings dashboard →</a></p>
|
<p><a href="${frontendUrl}/earnings" style="color: #155724; text-decoration: underline;">View your earnings dashboard →</a></p>
|
||||||
</div>
|
</div>
|
||||||
@@ -312,7 +315,7 @@ class RentalFlowEmailService {
|
|||||||
|
|
||||||
const htmlContent = await this.templateManager.renderTemplate(
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
"rentalApprovalConfirmationToOwner",
|
"rentalApprovalConfirmationToOwner",
|
||||||
variables
|
variables,
|
||||||
);
|
);
|
||||||
|
|
||||||
const subject = `Rental Approved - ${rental.item?.name || "Your Item"}`;
|
const subject = `Rental Approved - ${rental.item?.name || "Your Item"}`;
|
||||||
@@ -320,10 +323,12 @@ class RentalFlowEmailService {
|
|||||||
return await this.emailClient.sendEmail(
|
return await this.emailClient.sendEmail(
|
||||||
owner.email,
|
owner.email,
|
||||||
subject,
|
subject,
|
||||||
htmlContent
|
htmlContent,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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 };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -350,7 +355,7 @@ class RentalFlowEmailService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
const frontendUrl = process.env.FRONTEND_URL;
|
||||||
const browseItemsUrl = `${frontendUrl}/`;
|
const browseItemsUrl = `${frontendUrl}/`;
|
||||||
|
|
||||||
// Determine payment message based on rental amount
|
// Determine payment message based on rental amount
|
||||||
@@ -397,16 +402,16 @@ class RentalFlowEmailService {
|
|||||||
|
|
||||||
const htmlContent = await this.templateManager.renderTemplate(
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
"rentalDeclinedToRenter",
|
"rentalDeclinedToRenter",
|
||||||
variables
|
variables,
|
||||||
);
|
);
|
||||||
|
|
||||||
return await this.emailClient.sendEmail(
|
return await this.emailClient.sendEmail(
|
||||||
renter.email,
|
renter.email,
|
||||||
`Rental Request Declined - ${rental.item?.name || "Item"}`,
|
`Rental Request Declined - ${rental.item?.name || "Item"}`,
|
||||||
htmlContent
|
htmlContent,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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 };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -437,7 +442,7 @@ class RentalFlowEmailService {
|
|||||||
notification,
|
notification,
|
||||||
rental,
|
rental,
|
||||||
recipientName = null,
|
recipientName = null,
|
||||||
isRenter = false
|
isRenter = false,
|
||||||
) {
|
) {
|
||||||
if (!this.initialized) {
|
if (!this.initialized) {
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
@@ -532,7 +537,7 @@ class RentalFlowEmailService {
|
|||||||
|
|
||||||
const htmlContent = await this.templateManager.renderTemplate(
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
"rentalConfirmationToUser",
|
"rentalConfirmationToUser",
|
||||||
variables
|
variables,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use clear, transactional subject line with item name
|
// Use clear, transactional subject line with item name
|
||||||
@@ -540,7 +545,7 @@ class RentalFlowEmailService {
|
|||||||
|
|
||||||
return await this.emailClient.sendEmail(userEmail, subject, htmlContent);
|
return await this.emailClient.sendEmail(userEmail, subject, htmlContent);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send rental confirmation:", error);
|
logger.error("Failed to send rental confirmation", { error });
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -601,24 +606,24 @@ class RentalFlowEmailService {
|
|||||||
ownerNotification,
|
ownerNotification,
|
||||||
rental,
|
rental,
|
||||||
owner.firstName,
|
owner.firstName,
|
||||||
false // isRenter = false for owner
|
false, // isRenter = false for owner
|
||||||
);
|
);
|
||||||
if (ownerResult.success) {
|
if (ownerResult.success) {
|
||||||
console.log(
|
logger.info("Rental confirmation email sent to owner", {
|
||||||
`Rental confirmation email sent to owner: ${owner.email}`
|
email: owner.email,
|
||||||
);
|
});
|
||||||
results.ownerEmailSent = true;
|
results.ownerEmailSent = true;
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
logger.error("Failed to send rental confirmation email to owner", {
|
||||||
`Failed to send rental confirmation email to owner (${owner.email}):`,
|
email: owner.email,
|
||||||
ownerResult.error
|
error: ownerResult.error,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
logger.error("Failed to send rental confirmation email to owner", {
|
||||||
`Failed to send rental confirmation email to owner (${owner.email}):`,
|
email: owner.email,
|
||||||
error.message
|
error,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -630,31 +635,30 @@ class RentalFlowEmailService {
|
|||||||
renterNotification,
|
renterNotification,
|
||||||
rental,
|
rental,
|
||||||
renter.firstName,
|
renter.firstName,
|
||||||
true // isRenter = true for renter (enables payment receipt)
|
true, // isRenter = true for renter (enables payment receipt)
|
||||||
);
|
);
|
||||||
if (renterResult.success) {
|
if (renterResult.success) {
|
||||||
console.log(
|
logger.info("Rental confirmation email sent to renter", {
|
||||||
`Rental confirmation email sent to renter: ${renter.email}`
|
email: renter.email,
|
||||||
);
|
});
|
||||||
results.renterEmailSent = true;
|
results.renterEmailSent = true;
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
logger.error("Failed to send rental confirmation email to renter", {
|
||||||
`Failed to send rental confirmation email to renter (${renter.email}):`,
|
email: renter.email,
|
||||||
renterResult.error
|
error: renterResult.error,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
logger.error("Failed to send rental confirmation email to renter", {
|
||||||
`Failed to send rental confirmation email to renter (${renter.email}):`,
|
email: renter.email,
|
||||||
error.message
|
error,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
logger.error("Error fetching user data for rental confirmation emails", {
|
||||||
"Error fetching user data for rental confirmation emails:",
|
error,
|
||||||
error
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
@@ -693,7 +697,7 @@ class RentalFlowEmailService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
const frontendUrl = process.env.FRONTEND_URL;
|
||||||
const browseUrl = `${frontendUrl}/`;
|
const browseUrl = `${frontendUrl}/`;
|
||||||
|
|
||||||
const cancelledBy = rental.cancelledBy;
|
const cancelledBy = rental.cancelledBy;
|
||||||
@@ -737,7 +741,7 @@ class RentalFlowEmailService {
|
|||||||
<div class="info-box">
|
<div class="info-box">
|
||||||
<p><strong>Full Refund Processed</strong></p>
|
<p><strong>Full Refund Processed</strong></p>
|
||||||
<p>You will receive a full refund of $${refundInfo.amount.toFixed(
|
<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>
|
)}. The refund will appear in your account within 5-10 business days.</p>
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align: center">
|
<div style="text-align: center">
|
||||||
@@ -780,7 +784,7 @@ class RentalFlowEmailService {
|
|||||||
<div class="refund-amount">$${refundInfo.amount.toFixed(2)}</div>
|
<div class="refund-amount">$${refundInfo.amount.toFixed(2)}</div>
|
||||||
<div class="info-box">
|
<div class="info-box">
|
||||||
<p><strong>Refund Amount:</strong> $${refundInfo.amount.toFixed(
|
<p><strong>Refund Amount:</strong> $${refundInfo.amount.toFixed(
|
||||||
2
|
2,
|
||||||
)} (${refundPercentage}% of total)</p>
|
)} (${refundPercentage}% of total)</p>
|
||||||
<p><strong>Reason:</strong> ${refundInfo.reason}</p>
|
<p><strong>Reason:</strong> ${refundInfo.reason}</p>
|
||||||
<p><strong>Processing Time:</strong> Refunds typically appear within 5-10 business days.</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(
|
const confirmationHtml = await this.templateManager.renderTemplate(
|
||||||
"rentalCancellationConfirmationToUser",
|
"rentalCancellationConfirmationToUser",
|
||||||
confirmationVariables
|
confirmationVariables,
|
||||||
);
|
);
|
||||||
|
|
||||||
const confirmationResult = await this.emailClient.sendEmail(
|
const confirmationResult = await this.emailClient.sendEmail(
|
||||||
confirmationRecipient,
|
confirmationRecipient,
|
||||||
`Cancellation Confirmed - ${itemName}`,
|
`Cancellation Confirmed - ${itemName}`,
|
||||||
confirmationHtml
|
confirmationHtml,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmationResult.success) {
|
if (confirmationResult.success) {
|
||||||
console.log(
|
logger.info("Cancellation confirmation email sent", {
|
||||||
`Cancellation confirmation email sent to ${cancelledBy}: ${confirmationRecipient}`
|
cancelledBy,
|
||||||
);
|
email: confirmationRecipient,
|
||||||
|
});
|
||||||
results.confirmationEmailSent = true;
|
results.confirmationEmailSent = true;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
logger.error("Failed to send cancellation confirmation email", {
|
||||||
`Failed to send cancellation confirmation email to ${cancelledBy}:`,
|
cancelledBy,
|
||||||
error.message
|
error,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send notification email to other party
|
// Send notification email to other party
|
||||||
@@ -846,31 +851,29 @@ class RentalFlowEmailService {
|
|||||||
|
|
||||||
const notificationHtml = await this.templateManager.renderTemplate(
|
const notificationHtml = await this.templateManager.renderTemplate(
|
||||||
"rentalCancellationNotificationToUser",
|
"rentalCancellationNotificationToUser",
|
||||||
notificationVariables
|
notificationVariables,
|
||||||
);
|
);
|
||||||
|
|
||||||
const notificationResult = await this.emailClient.sendEmail(
|
const notificationResult = await this.emailClient.sendEmail(
|
||||||
notificationRecipient,
|
notificationRecipient,
|
||||||
`Rental Cancelled - ${itemName}`,
|
`Rental Cancelled - ${itemName}`,
|
||||||
notificationHtml
|
notificationHtml,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (notificationResult.success) {
|
if (notificationResult.success) {
|
||||||
console.log(
|
logger.info("Cancellation notification email sent", {
|
||||||
`Cancellation notification email sent to ${
|
recipientType: cancelledBy === "owner" ? "renter" : "owner",
|
||||||
cancelledBy === "owner" ? "renter" : "owner"
|
email: notificationRecipient,
|
||||||
}: ${notificationRecipient}`
|
});
|
||||||
);
|
|
||||||
results.notificationEmailSent = true;
|
results.notificationEmailSent = true;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
logger.error("Failed to send cancellation notification email", {
|
||||||
`Failed to send cancellation notification email:`,
|
error,
|
||||||
error.message
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error sending cancellation emails:", error);
|
logger.error("Error sending cancellation emails", { error });
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
@@ -905,7 +908,7 @@ class RentalFlowEmailService {
|
|||||||
await this.initialize();
|
await this.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
const frontendUrl = process.env.FRONTEND_URL;
|
||||||
const results = {
|
const results = {
|
||||||
renterEmailSent: false,
|
renterEmailSent: false,
|
||||||
ownerEmailSent: false,
|
ownerEmailSent: false,
|
||||||
@@ -977,32 +980,32 @@ class RentalFlowEmailService {
|
|||||||
|
|
||||||
const renterHtmlContent = await this.templateManager.renderTemplate(
|
const renterHtmlContent = await this.templateManager.renderTemplate(
|
||||||
"rentalCompletionThankYouToRenter",
|
"rentalCompletionThankYouToRenter",
|
||||||
renterVariables
|
renterVariables,
|
||||||
);
|
);
|
||||||
|
|
||||||
const renterResult = await this.emailClient.sendEmail(
|
const renterResult = await this.emailClient.sendEmail(
|
||||||
renter.email,
|
renter.email,
|
||||||
`Thank You for Returning "${rental.item?.name || "Item"}" On Time!`,
|
`Thank You for Returning "${rental.item?.name || "Item"}" On Time!`,
|
||||||
renterHtmlContent
|
renterHtmlContent,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (renterResult.success) {
|
if (renterResult.success) {
|
||||||
console.log(
|
logger.info("Rental completion thank you email sent to renter", {
|
||||||
`Rental completion thank you email sent to renter: ${renter.email}`
|
email: renter.email,
|
||||||
);
|
});
|
||||||
results.renterEmailSent = true;
|
results.renterEmailSent = true;
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
logger.error("Failed to send rental completion email to renter", {
|
||||||
`Failed to send rental completion email to renter (${renter.email}):`,
|
email: renter.email,
|
||||||
renterResult.error
|
error: renterResult.error,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
logger.error("Failed to send rental completion email to renter", {
|
logger.error("Failed to send rental completion email to renter", {
|
||||||
error: emailError.message,
|
error: emailError.message,
|
||||||
stack: emailError.stack,
|
stack: emailError.stack,
|
||||||
renterEmail: renter.email,
|
renterEmail: renter.email,
|
||||||
rentalId: rental.id
|
rentalId: rental.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1021,15 +1024,15 @@ class RentalFlowEmailService {
|
|||||||
<table class="info-table">
|
<table class="info-table">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Total Rental Amount</th>
|
<th>Total Rental Amount</th>
|
||||||
<td>\\$${totalAmount.toFixed(2)}</td>
|
<td>$${totalAmount.toFixed(2)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Community Upkeep Fee (10%)</th>
|
<th>Community Upkeep Fee (10%)</th>
|
||||||
<td>-\\$${platformFee.toFixed(2)}</td>
|
<td>-$${platformFee.toFixed(2)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Your Payout</th>
|
<th>Your Payout</th>
|
||||||
<td class="highlight">\\$${payoutAmount.toFixed(2)}</td>
|
<td class="highlight">$${payoutAmount.toFixed(2)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<p style="font-size: 14px; color: #6c757d;">
|
<p style="font-size: 14px; color: #6c757d;">
|
||||||
@@ -1045,8 +1048,8 @@ class RentalFlowEmailService {
|
|||||||
stripeSection = `
|
stripeSection = `
|
||||||
<div class="warning-box">
|
<div class="warning-box">
|
||||||
<p><strong>⚠️ Action Required: Set Up Your Earnings Account</strong></p>
|
<p><strong>⚠️ Action Required: Set Up Your Earnings Account</strong></p>
|
||||||
<p>To receive your payout of <strong>\\$${payoutAmount.toFixed(
|
<p>To receive your payout of <strong>$${payoutAmount.toFixed(
|
||||||
2
|
2,
|
||||||
)}</strong>, you need to set up your earnings account.</p>
|
)}</strong>, you need to set up your earnings account.</p>
|
||||||
</div>
|
</div>
|
||||||
<h2>Set Up Earnings to Get Paid</h2>
|
<h2>Set Up Earnings to Get Paid</h2>
|
||||||
@@ -1071,8 +1074,8 @@ class RentalFlowEmailService {
|
|||||||
stripeSection = `
|
stripeSection = `
|
||||||
<div class="success-box">
|
<div class="success-box">
|
||||||
<p><strong>✓ Payout Initiated</strong></p>
|
<p><strong>✓ Payout Initiated</strong></p>
|
||||||
<p>Your earnings of <strong>\\$${payoutAmount.toFixed(
|
<p>Your earnings of <strong>$${payoutAmount.toFixed(
|
||||||
2
|
2,
|
||||||
)}</strong> have been transferred to your Stripe account.</p>
|
)}</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 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>
|
<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(
|
const ownerHtmlContent = await this.templateManager.renderTemplate(
|
||||||
"rentalCompletionCongratsToOwner",
|
"rentalCompletionCongratsToOwner",
|
||||||
ownerVariables
|
ownerVariables,
|
||||||
);
|
);
|
||||||
|
|
||||||
const ownerResult = await this.emailClient.sendEmail(
|
const ownerResult = await this.emailClient.sendEmail(
|
||||||
owner.email,
|
owner.email,
|
||||||
`Rental Complete - ${rental.item?.name || "Your Item"}`,
|
`Rental Complete - ${rental.item?.name || "Your Item"}`,
|
||||||
ownerHtmlContent
|
ownerHtmlContent,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (ownerResult.success) {
|
if (ownerResult.success) {
|
||||||
console.log(
|
logger.info("Rental completion congratulations email sent to owner", {
|
||||||
`Rental completion congratulations email sent to owner: ${owner.email}`
|
email: owner.email,
|
||||||
);
|
});
|
||||||
results.ownerEmailSent = true;
|
results.ownerEmailSent = true;
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
logger.error("Failed to send rental completion email to owner", {
|
||||||
`Failed to send rental completion email to owner (${owner.email}):`,
|
email: owner.email,
|
||||||
ownerResult.error
|
error: ownerResult.error,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
logger.error("Failed to send rental completion email to owner", {
|
logger.error("Failed to send rental completion email to owner", {
|
||||||
error: emailError.message,
|
error: emailError.message,
|
||||||
stack: emailError.stack,
|
stack: emailError.stack,
|
||||||
ownerEmail: owner.email,
|
ownerEmail: owner.email,
|
||||||
rentalId: rental.id
|
rentalId: rental.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error sending rental completion emails", {
|
logger.error("Error sending rental completion emails", {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
stack: error.stack,
|
stack: error.stack,
|
||||||
rentalId: rental?.id
|
rentalId: rental?.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1158,7 +1161,7 @@ class RentalFlowEmailService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
const frontendUrl = process.env.FRONTEND_URL;
|
||||||
const earningsDashboardUrl = `${frontendUrl}/earnings`;
|
const earningsDashboardUrl = `${frontendUrl}/earnings`;
|
||||||
|
|
||||||
// Format currency values
|
// Format currency values
|
||||||
@@ -1190,7 +1193,7 @@ class RentalFlowEmailService {
|
|||||||
|
|
||||||
const htmlContent = await this.templateManager.renderTemplate(
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
"payoutReceivedToOwner",
|
"payoutReceivedToOwner",
|
||||||
variables
|
variables,
|
||||||
);
|
);
|
||||||
|
|
||||||
return await this.emailClient.sendEmail(
|
return await this.emailClient.sendEmail(
|
||||||
@@ -1198,10 +1201,54 @@ class RentalFlowEmailService {
|
|||||||
`Earnings Received - $${payoutAmount.toFixed(2)} for ${
|
`Earnings Received - $${payoutAmount.toFixed(2)} for ${
|
||||||
rental.item?.name || "Your Item"
|
rental.item?.name || "Your Item"
|
||||||
}`,
|
}`,
|
||||||
htmlContent
|
htmlContent,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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 };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const EmailClient = require("../core/EmailClient");
|
const EmailClient = require("../core/EmailClient");
|
||||||
const TemplateManager = require("../core/TemplateManager");
|
const TemplateManager = require("../core/TemplateManager");
|
||||||
|
const logger = require("../../../utils/logger");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RentalReminderEmailService handles rental reminder emails
|
* RentalReminderEmailService handles rental reminder emails
|
||||||
@@ -26,7 +27,7 @@ class RentalReminderEmailService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
this.initialized = true;
|
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
|
htmlContent
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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 };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const EmailClient = require("../core/EmailClient");
|
const EmailClient = require("../core/EmailClient");
|
||||||
const TemplateManager = require("../core/TemplateManager");
|
const TemplateManager = require("../core/TemplateManager");
|
||||||
|
const logger = require("../../../utils/logger");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UserEngagementEmailService handles user engagement emails
|
* UserEngagementEmailService handles user engagement emails
|
||||||
@@ -27,7 +28,7 @@ class UserEngagementEmailService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
this.initialized = true;
|
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 {
|
try {
|
||||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
const frontendUrl = process.env.FRONTEND_URL;
|
||||||
|
|
||||||
const variables = {
|
const variables = {
|
||||||
ownerName: owner.firstName || "there",
|
ownerName: owner.firstName || "there",
|
||||||
@@ -57,7 +58,7 @@ class UserEngagementEmailService {
|
|||||||
|
|
||||||
const htmlContent = await this.templateManager.renderTemplate(
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
"firstListingCelebrationToOwner",
|
"firstListingCelebrationToOwner",
|
||||||
variables
|
variables,
|
||||||
);
|
);
|
||||||
|
|
||||||
const subject = `Congratulations! Your first item is live on Village Share`;
|
const subject = `Congratulations! Your first item is live on Village Share`;
|
||||||
@@ -65,10 +66,10 @@ class UserEngagementEmailService {
|
|||||||
return await this.emailClient.sendEmail(
|
return await this.emailClient.sendEmail(
|
||||||
owner.email,
|
owner.email,
|
||||||
subject,
|
subject,
|
||||||
htmlContent
|
htmlContent,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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 };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,8 +91,8 @@ class UserEngagementEmailService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
const frontendUrl = process.env.FRONTEND_URL;
|
||||||
const supportEmail = process.env.SUPPORT_EMAIL;
|
const supportEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
|
||||||
|
|
||||||
const variables = {
|
const variables = {
|
||||||
ownerName: owner.firstName || "there",
|
ownerName: owner.firstName || "there",
|
||||||
@@ -103,7 +104,7 @@ class UserEngagementEmailService {
|
|||||||
|
|
||||||
const htmlContent = await this.templateManager.renderTemplate(
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
"itemDeletionToOwner",
|
"itemDeletionToOwner",
|
||||||
variables
|
variables,
|
||||||
);
|
);
|
||||||
|
|
||||||
const subject = `Important: Your listing "${item.name}" has been removed`;
|
const subject = `Important: Your listing "${item.name}" has been removed`;
|
||||||
@@ -111,10 +112,64 @@ class UserEngagementEmailService {
|
|||||||
return await this.emailClient.sendEmail(
|
return await this.emailClient.sendEmail(
|
||||||
owner.email,
|
owner.email,
|
||||||
subject,
|
subject,
|
||||||
htmlContent
|
htmlContent,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send notification when a user's account is banned
|
||||||
|
* @param {Object} bannedUser - User who was banned
|
||||||
|
* @param {string} bannedUser.firstName - Banned user's first name
|
||||||
|
* @param {string} bannedUser.email - Banned user's email
|
||||||
|
* @param {Object} admin - Admin who performed the ban
|
||||||
|
* @param {string} admin.firstName - Admin's first name
|
||||||
|
* @param {string} admin.lastName - Admin's last name
|
||||||
|
* @param {string} banReason - Reason for the ban
|
||||||
|
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
|
||||||
|
*/
|
||||||
|
async sendUserBannedNotification(bannedUser, admin, banReason) {
|
||||||
|
if (!this.initialized) {
|
||||||
|
await this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supportEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
|
||||||
|
|
||||||
|
const variables = {
|
||||||
|
userName: bannedUser.firstName || "there",
|
||||||
|
banReason: banReason,
|
||||||
|
supportEmail: supportEmail,
|
||||||
|
};
|
||||||
|
|
||||||
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
|
"userBannedNotification",
|
||||||
|
variables,
|
||||||
|
);
|
||||||
|
|
||||||
|
const subject =
|
||||||
|
"Important: Your Village Share Account Has Been Suspended";
|
||||||
|
|
||||||
|
const result = await this.emailClient.sendEmail(
|
||||||
|
bannedUser.email,
|
||||||
|
subject,
|
||||||
|
htmlContent,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
logger.info("User banned notification email sent", {
|
||||||
|
email: bannedUser.email,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to send user banned notification email", { error });
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const RentalFlowEmailService = require("./domain/RentalFlowEmailService");
|
|||||||
const RentalReminderEmailService = require("./domain/RentalReminderEmailService");
|
const RentalReminderEmailService = require("./domain/RentalReminderEmailService");
|
||||||
const UserEngagementEmailService = require("./domain/UserEngagementEmailService");
|
const UserEngagementEmailService = require("./domain/UserEngagementEmailService");
|
||||||
const AlphaInvitationEmailService = require("./domain/AlphaInvitationEmailService");
|
const AlphaInvitationEmailService = require("./domain/AlphaInvitationEmailService");
|
||||||
|
const PaymentEmailService = require("./domain/PaymentEmailService");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EmailServices aggregates all domain-specific email services
|
* EmailServices aggregates all domain-specific email services
|
||||||
@@ -24,6 +25,7 @@ class EmailServices {
|
|||||||
this.rentalReminder = new RentalReminderEmailService();
|
this.rentalReminder = new RentalReminderEmailService();
|
||||||
this.userEngagement = new UserEngagementEmailService();
|
this.userEngagement = new UserEngagementEmailService();
|
||||||
this.alphaInvitation = new AlphaInvitationEmailService();
|
this.alphaInvitation = new AlphaInvitationEmailService();
|
||||||
|
this.payment = new PaymentEmailService();
|
||||||
|
|
||||||
this.initialized = false;
|
this.initialized = false;
|
||||||
}
|
}
|
||||||
@@ -45,6 +47,7 @@ class EmailServices {
|
|||||||
this.rentalReminder.initialize(),
|
this.rentalReminder.initialize(),
|
||||||
this.userEngagement.initialize(),
|
this.userEngagement.initialize(),
|
||||||
this.alphaInvitation.initialize(),
|
this.alphaInvitation.initialize(),
|
||||||
|
this.payment.initialize(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
|
|||||||
292
backend/services/eventBridgeSchedulerService.js
Normal file
292
backend/services/eventBridgeSchedulerService.js
Normal 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();
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
const { Client } = require('@googlemaps/google-maps-services-js');
|
const { Client } = require('@googlemaps/google-maps-services-js');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
class GoogleMapsService {
|
class GoogleMapsService {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -6,9 +7,9 @@ class GoogleMapsService {
|
|||||||
this.apiKey = process.env.GOOGLE_MAPS_API_KEY;
|
this.apiKey = process.env.GOOGLE_MAPS_API_KEY;
|
||||||
|
|
||||||
if (!this.apiKey) {
|
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 {
|
} else {
|
||||||
console.log('✅ Google Maps service initialized');
|
logger.info('Google Maps service initialized');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +62,7 @@ class GoogleMapsService {
|
|||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
} else {
|
} 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 {
|
return {
|
||||||
predictions: [],
|
predictions: [],
|
||||||
error: this.getErrorMessage(response.data.status),
|
error: this.getErrorMessage(response.data.status),
|
||||||
@@ -69,7 +70,7 @@ class GoogleMapsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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');
|
throw new Error('Failed to fetch place predictions');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,11 +146,11 @@ class GoogleMapsService {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
} else {
|
} 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));
|
throw new Error(this.getErrorMessage(response.data.status));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Place Details service error:', error.message);
|
logger.error('Place Details service error', { error });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,14 +201,14 @@ class GoogleMapsService {
|
|||||||
placeId: result.place_id
|
placeId: result.place_id
|
||||||
};
|
};
|
||||||
} else {
|
} 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 {
|
return {
|
||||||
error: this.getErrorMessage(response.data.status),
|
error: this.getErrorMessage(response.data.status),
|
||||||
status: response.data.status
|
status: response.data.status
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Geocoding service error:', error.message);
|
logger.error('Geocoding service error', { error });
|
||||||
throw new Error('Failed to geocode address');
|
throw new Error('Failed to geocode address');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const { sequelize } = require("../models");
|
const { sequelize } = require("../models");
|
||||||
const { QueryTypes } = require("sequelize");
|
const { QueryTypes } = require("sequelize");
|
||||||
|
const logger = require("../utils/logger");
|
||||||
|
|
||||||
class LocationService {
|
class LocationService {
|
||||||
/**
|
/**
|
||||||
@@ -25,7 +26,7 @@ class LocationService {
|
|||||||
// distance = 3959 * acos(cos(radians(lat1)) * cos(radians(lat2))
|
// distance = 3959 * acos(cos(radians(lat1)) * cos(radians(lat2))
|
||||||
// * cos(radians(lng2) - radians(lng1))
|
// * cos(radians(lng2) - radians(lng1))
|
||||||
// + sin(radians(lat1)) * sin(radians(lat2)))
|
// + sin(radians(lat1)) * sin(radians(lat2)))
|
||||||
// Note: 3959 is Earth's radius in miles
|
// 3959 is Earth's radius in miles
|
||||||
const query = `
|
const query = `
|
||||||
SELECT * FROM (
|
SELECT * FROM (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -71,7 +72,7 @@ class LocationService {
|
|||||||
distance: parseFloat(user.distance).toFixed(2), // Round to 2 decimal places
|
distance: parseFloat(user.distance).toFixed(2), // Round to 2 decimal places
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} 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}`);
|
throw new Error(`Failed to find users in radius: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
const { Rental } = require("../models");
|
const { Rental } = require("../models");
|
||||||
const StripeService = require("./stripeService");
|
const StripeService = require("./stripeService");
|
||||||
|
const EventBridgeSchedulerService = require("./eventBridgeSchedulerService");
|
||||||
const { isActive } = require("../utils/rentalStatus");
|
const { isActive } = require("../utils/rentalStatus");
|
||||||
|
const logger = require("../utils/logger");
|
||||||
|
|
||||||
class RefundService {
|
class RefundService {
|
||||||
/**
|
/**
|
||||||
@@ -93,8 +95,12 @@ class RefundService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check payment status - allow cancellation for both paid and free rentals
|
// Allow cancellation for pending rentals (before owner approval) or paid/free rentals
|
||||||
if (rental.paymentStatus !== "paid" && rental.paymentStatus !== "not_required") {
|
const isPendingRequest = rental.status === "pending";
|
||||||
|
const isPaymentSettled =
|
||||||
|
rental.paymentStatus === "paid" || rental.paymentStatus === "not_required";
|
||||||
|
|
||||||
|
if (!isPendingRequest && !isPaymentSettled) {
|
||||||
return {
|
return {
|
||||||
canCancel: false,
|
canCancel: false,
|
||||||
reason: "Cannot cancel rental that hasn't been paid",
|
reason: "Cannot cancel rental that hasn't been paid",
|
||||||
@@ -157,13 +163,14 @@ class RefundService {
|
|||||||
stripeRefundId = refund.id;
|
stripeRefundId = refund.id;
|
||||||
refundProcessedAt = new Date();
|
refundProcessedAt = new Date();
|
||||||
} catch (error) {
|
} 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}`);
|
throw new Error(`Failed to process refund: ${error.message}`);
|
||||||
}
|
}
|
||||||
} else if (refundCalculation.refundAmount > 0) {
|
} else if (refundCalculation.refundAmount > 0) {
|
||||||
// Log warning if we should refund but don't have payment intent
|
// Log warning if we should refund but don't have payment intent
|
||||||
console.warn(
|
logger.warn(
|
||||||
`Refund amount calculated but no payment intent ID for rental ${rentalId}`
|
"Refund amount calculated but no payment intent ID for rental",
|
||||||
|
{ rentalId }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +188,17 @@ class RefundService {
|
|||||||
payoutStatus: "pending",
|
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 {
|
return {
|
||||||
rental: updatedRental,
|
rental: updatedRental,
|
||||||
refund: {
|
refund: {
|
||||||
|
|||||||
@@ -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
|
* Get a presigned URL for uploading a file directly to S3
|
||||||
* @param {string} uploadType - Type of upload (profile, item, message, forum, condition-check)
|
* @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 {string} fileName - Original filename (used for extension)
|
||||||
* @param {number} fileSize - File size in bytes (required for size enforcement)
|
* @param {number} fileSize - File size in bytes (required for size enforcement)
|
||||||
* @param {string} [baseKey] - Optional base key (UUID) for coordinated variant uploads
|
* @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) {
|
async getPresignedUploadUrl(uploadType, contentType, fileName, fileSize, baseKey = null) {
|
||||||
if (!this.enabled) {
|
if (!this.enabled) {
|
||||||
@@ -150,12 +159,19 @@ class S3Service {
|
|||||||
|
|
||||||
// Use provided baseKey or generate new UUID
|
// Use provided baseKey or generate new UUID
|
||||||
const uuid = baseKey || uuidv4();
|
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 cacheDirective = config.public ? "public" : "private";
|
||||||
const command = new PutObjectCommand({
|
const command = new PutObjectCommand({
|
||||||
Bucket: this.bucket,
|
Bucket: this.bucket,
|
||||||
Key: key,
|
Key: uploadKey,
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
ContentLength: fileSize, // Enforce exact file size
|
ContentLength: fileSize, // Enforce exact file size
|
||||||
CacheControl: `${cacheDirective}, max-age=${config.cacheMaxAge}`,
|
CacheControl: `${cacheDirective}, max-age=${config.cacheMaxAge}`,
|
||||||
@@ -167,9 +183,10 @@ class S3Service {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
uploadUrl,
|
uploadUrl,
|
||||||
key,
|
key: finalKey, // Frontend stores this in database
|
||||||
|
stagingKey: useStaging ? uploadKey : null, // Actual upload location (if staging enabled)
|
||||||
publicUrl: config.public
|
publicUrl: config.public
|
||||||
? `https://${this.bucket}.s3.${this.region}.amazonaws.com/${key}`
|
? `https://${this.bucket}.s3.${this.region}.amazonaws.com/${finalKey}`
|
||||||
: null,
|
: null,
|
||||||
expiresAt: new Date(Date.now() + PRESIGN_EXPIRY * 1000),
|
expiresAt: new Date(Date.now() + PRESIGN_EXPIRY * 1000),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
|
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
|
||||||
const logger = require("../utils/logger");
|
const logger = require("../utils/logger");
|
||||||
|
const { parseStripeError, PaymentError } = require("../utils/stripeErrors");
|
||||||
|
const { User } = require("../models");
|
||||||
|
const emailServices = require("./email");
|
||||||
|
|
||||||
class StripeService {
|
class StripeService {
|
||||||
|
|
||||||
static async getCheckoutSession(sessionId) {
|
static async getCheckoutSession(sessionId) {
|
||||||
try {
|
try {
|
||||||
return await stripe.checkout.sessions.retrieve(sessionId, {
|
return await stripe.checkout.sessions.retrieve(sessionId, {
|
||||||
expand: ['setup_intent', 'setup_intent.payment_method']
|
expand: ["setup_intent", "setup_intent.payment_method"],
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,7 +32,10 @@ class StripeService {
|
|||||||
|
|
||||||
return account;
|
return account;
|
||||||
} catch (error) {
|
} 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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,7 +51,10 @@ class StripeService {
|
|||||||
|
|
||||||
return accountLink;
|
return accountLink;
|
||||||
} catch (error) {
|
} 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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,7 +70,10 @@ class StripeService {
|
|||||||
requirements: account.requirements,
|
requirements: account.requirements,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} 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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,7 +89,10 @@ class StripeService {
|
|||||||
|
|
||||||
return accountSession;
|
return accountSession;
|
||||||
} catch (error) {
|
} 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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,20 +104,119 @@ class StripeService {
|
|||||||
metadata = {},
|
metadata = {},
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const transfer = await stripe.transfers.create({
|
// Generate idempotency key from rental ID to prevent duplicate transfers
|
||||||
amount: Math.round(amount * 100), // Convert to cents
|
const idempotencyKey = metadata?.rentalId
|
||||||
currency,
|
? `transfer_rental_${metadata.rentalId}`
|
||||||
destination,
|
: undefined;
|
||||||
metadata,
|
|
||||||
});
|
const transfer = await stripe.transfers.create(
|
||||||
|
{
|
||||||
|
amount: Math.round(amount * 100), // Convert to cents
|
||||||
|
currency,
|
||||||
|
destination,
|
||||||
|
metadata,
|
||||||
|
},
|
||||||
|
idempotencyKey ? { idempotencyKey } : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
return transfer;
|
return transfer;
|
||||||
} catch (error) {
|
} 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;
|
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({
|
static async createRefund({
|
||||||
paymentIntentId,
|
paymentIntentId,
|
||||||
amount,
|
amount,
|
||||||
@@ -108,16 +224,27 @@ class StripeService {
|
|||||||
reason = "requested_by_customer",
|
reason = "requested_by_customer",
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const refund = await stripe.refunds.create({
|
// Generate idempotency key - include amount to allow multiple partial refunds
|
||||||
payment_intent: paymentIntentId,
|
const idempotencyKey = metadata?.rentalId
|
||||||
amount: Math.round(amount * 100), // Convert to cents
|
? `refund_rental_${metadata.rentalId}_${Math.round(amount * 100)}`
|
||||||
metadata,
|
: undefined;
|
||||||
reason,
|
|
||||||
});
|
const refund = await stripe.refunds.create(
|
||||||
|
{
|
||||||
|
payment_intent: paymentIntentId,
|
||||||
|
amount: Math.round(amount * 100), // Convert to cents
|
||||||
|
metadata,
|
||||||
|
reason,
|
||||||
|
},
|
||||||
|
idempotencyKey ? { idempotencyKey } : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
return refund;
|
return refund;
|
||||||
} catch (error) {
|
} 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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,66 +253,115 @@ class StripeService {
|
|||||||
try {
|
try {
|
||||||
return await stripe.refunds.retrieve(refundId);
|
return await stripe.refunds.retrieve(refundId);
|
||||||
} catch (error) {
|
} 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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async chargePaymentMethod(paymentMethodId, amount, customerId, metadata = {}) {
|
static async chargePaymentMethod(
|
||||||
|
paymentMethodId,
|
||||||
|
amount,
|
||||||
|
customerId,
|
||||||
|
metadata = {},
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
// Create a payment intent with the stored payment method
|
// Generate idempotency key to prevent duplicate charges for same rental
|
||||||
const paymentIntent = await stripe.paymentIntents.create({
|
const idempotencyKey = metadata?.rentalId
|
||||||
amount: Math.round(amount * 100), // Convert to cents
|
? `charge_rental_${metadata.rentalId}`
|
||||||
currency: "usd",
|
: undefined;
|
||||||
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`,
|
|
||||||
metadata,
|
|
||||||
expand: ['charges.data.payment_method_details'], // Expand to get payment method details
|
|
||||||
});
|
|
||||||
|
|
||||||
// Extract payment method details from charges
|
// Create a payment intent with the stored payment method
|
||||||
const charge = paymentIntent.charges?.data?.[0];
|
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}/complete-payment`,
|
||||||
|
metadata,
|
||||||
|
expand: ["latest_charge.payment_method_details"], // Expand to get payment method details
|
||||||
|
},
|
||||||
|
idempotencyKey ? { idempotencyKey } : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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;
|
const paymentMethodDetails = charge?.payment_method_details;
|
||||||
|
|
||||||
// Build payment method info object
|
// Build payment method info object
|
||||||
let paymentMethod = null;
|
let paymentMethod = null;
|
||||||
if (paymentMethodDetails) {
|
if (paymentMethodDetails) {
|
||||||
const type = paymentMethodDetails.type;
|
const type = paymentMethodDetails.type;
|
||||||
if (type === 'card') {
|
if (type === "card") {
|
||||||
paymentMethod = {
|
paymentMethod = {
|
||||||
type: 'card',
|
type: "card",
|
||||||
brand: paymentMethodDetails.card?.brand || 'card',
|
brand: paymentMethodDetails.card?.brand || "card",
|
||||||
last4: paymentMethodDetails.card?.last4 || '****',
|
last4: paymentMethodDetails.card?.last4 || "****",
|
||||||
};
|
};
|
||||||
} else if (type === 'us_bank_account') {
|
} else if (type === "us_bank_account") {
|
||||||
paymentMethod = {
|
paymentMethod = {
|
||||||
type: 'bank',
|
type: "bank",
|
||||||
brand: 'bank_account',
|
brand: "bank_account",
|
||||||
last4: paymentMethodDetails.us_bank_account?.last4 || '****',
|
last4: paymentMethodDetails.us_bank_account?.last4 || "****",
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
paymentMethod = {
|
paymentMethod = {
|
||||||
type: type || 'unknown',
|
type: type || "unknown",
|
||||||
brand: type || 'payment',
|
brand: type || "payment",
|
||||||
last4: null,
|
last4: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
status: "succeeded",
|
||||||
paymentIntentId: paymentIntent.id,
|
paymentIntentId: paymentIntent.id,
|
||||||
status: paymentIntent.status,
|
|
||||||
clientSecret: paymentIntent.client_secret,
|
clientSecret: paymentIntent.client_secret,
|
||||||
paymentMethod: paymentMethod,
|
paymentMethod: paymentMethod,
|
||||||
chargedAt: new Date(paymentIntent.created * 1000), // Convert Unix timestamp to Date
|
chargedAt: new Date(paymentIntent.created * 1000), // Convert Unix timestamp to Date
|
||||||
amountCharged: amount, // Original amount in dollars
|
amountCharged: amount, // Original amount in dollars
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error charging payment method", { error: error.message, stack: error.stack });
|
// Handle authentication_required error (thrown for off-session 3DS)
|
||||||
throw error;
|
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);
|
||||||
|
|
||||||
|
logger.error("Payment failed", {
|
||||||
|
code: parsedError.code,
|
||||||
|
ownerMessage: parsedError.ownerMessage,
|
||||||
|
originalError: parsedError._originalMessage,
|
||||||
|
stripeCode: parsedError._stripeCode,
|
||||||
|
paymentMethodId,
|
||||||
|
customerId,
|
||||||
|
amount,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new PaymentError(parsedError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,29 +375,52 @@ class StripeService {
|
|||||||
|
|
||||||
return customer;
|
return customer;
|
||||||
} catch (error) {
|
} 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;
|
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,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static async createSetupCheckoutSession({ customerId, metadata = {} }) {
|
static async createSetupCheckoutSession({ customerId, metadata = {} }) {
|
||||||
try {
|
try {
|
||||||
const session = await stripe.checkout.sessions.create({
|
const session = await stripe.checkout.sessions.create({
|
||||||
customer: customerId,
|
customer: customerId,
|
||||||
payment_method_types: ['card', 'us_bank_account', 'link'],
|
payment_method_types: ["card", "link"],
|
||||||
mode: 'setup',
|
mode: "setup",
|
||||||
ui_mode: 'embedded',
|
ui_mode: "embedded",
|
||||||
redirect_on_completion: 'never',
|
redirect_on_completion: "never",
|
||||||
|
// Configure for off-session usage - triggers 3DS during setup
|
||||||
|
payment_method_options: {
|
||||||
|
card: {
|
||||||
|
request_three_d_secure: "any",
|
||||||
|
},
|
||||||
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
type: 'payment_method_setup',
|
type: "payment_method_setup",
|
||||||
...metadata
|
...metadata,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return session;
|
return session;
|
||||||
} catch (error) {
|
} 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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ const { User, Rental, Item } = require("../models");
|
|||||||
const PayoutService = require("./payoutService");
|
const PayoutService = require("./payoutService");
|
||||||
const logger = require("../utils/logger");
|
const logger = require("../utils/logger");
|
||||||
const { Op } = require("sequelize");
|
const { Op } = require("sequelize");
|
||||||
|
const { getPayoutFailureMessage } = require("../utils/payoutErrors");
|
||||||
|
const emailServices = require("./email");
|
||||||
|
|
||||||
class StripeWebhookService {
|
class StripeWebhookService {
|
||||||
/**
|
/**
|
||||||
@@ -14,19 +16,23 @@ class StripeWebhookService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle account.updated webhook event.
|
* 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
|
* @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) {
|
static async handleAccountUpdated(account) {
|
||||||
const accountId = account.id;
|
const accountId = account.id;
|
||||||
const payoutsEnabled = account.payouts_enabled;
|
const payoutsEnabled = account.payouts_enabled;
|
||||||
|
const requirements = account.requirements || {};
|
||||||
|
|
||||||
logger.info("Processing account.updated webhook", {
|
logger.info("Processing account.updated webhook", {
|
||||||
accountId,
|
accountId,
|
||||||
payoutsEnabled,
|
payoutsEnabled,
|
||||||
chargesEnabled: account.charges_enabled,
|
chargesEnabled: account.charges_enabled,
|
||||||
detailsSubmitted: account.details_submitted,
|
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
|
// Find user with this Stripe account
|
||||||
@@ -39,18 +45,33 @@ class StripeWebhookService {
|
|||||||
return { processed: false, reason: "user_not_found" };
|
return { processed: false, reason: "user_not_found" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store previous state before update
|
||||||
const previousPayoutsEnabled = user.stripePayoutsEnabled;
|
const previousPayoutsEnabled = user.stripePayoutsEnabled;
|
||||||
|
|
||||||
// Update user's payouts_enabled status
|
// Update user with all account status fields
|
||||||
await user.update({ stripePayoutsEnabled: payoutsEnabled });
|
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,
|
userId: user.id,
|
||||||
accountId,
|
accountId,
|
||||||
previousPayoutsEnabled,
|
previousPayoutsEnabled,
|
||||||
newPayoutsEnabled: payoutsEnabled,
|
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 payouts just became enabled (false -> true), process pending payouts
|
||||||
if (payoutsEnabled && !previousPayoutsEnabled) {
|
if (payoutsEnabled && !previousPayoutsEnabled) {
|
||||||
logger.info("Payouts enabled for user, processing pending payouts", {
|
logger.info("Payouts enabled for user, processing pending payouts", {
|
||||||
@@ -58,15 +79,69 @@ class StripeWebhookService {
|
|||||||
accountId,
|
accountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await this.processPayoutsForOwner(user.id);
|
result.payoutsTriggered = true;
|
||||||
return {
|
result.payoutResults = await this.processPayoutsForOwner(user.id);
|
||||||
processed: true,
|
|
||||||
payoutsTriggered: true,
|
|
||||||
payoutResults: result,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
* 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 {Object} payout - The Stripe payout object
|
||||||
* @param {string} connectedAccountId - The connected account ID (from event.account)
|
* @param {string} connectedAccountId - The connected account ID (from event.account)
|
||||||
* @returns {Object} - { processed, rentalsUpdated }
|
* @returns {Object} - { processed, rentalsUpdated, notificationSent }
|
||||||
*/
|
*/
|
||||||
static async handlePayoutFailed(payout, connectedAccountId) {
|
static async handlePayoutFailed(payout, connectedAccountId) {
|
||||||
logger.info("Processing payout.failed webhook", {
|
logger.info("Processing payout.failed webhook", {
|
||||||
@@ -259,7 +334,7 @@ class StripeWebhookService {
|
|||||||
payoutId: payout.id,
|
payoutId: payout.id,
|
||||||
connectedAccountId,
|
connectedAccountId,
|
||||||
});
|
});
|
||||||
return { processed: true, rentalsUpdated: 0 };
|
return { processed: true, rentalsUpdated: 0, notificationSent: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update all rentals with matching stripeTransferId
|
// Update all rentals with matching stripeTransferId
|
||||||
@@ -282,7 +357,49 @@ class StripeWebhookService {
|
|||||||
failureCode: payout.failure_code,
|
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) {
|
} catch (error) {
|
||||||
logger.error("Error processing payout.failed webhook", {
|
logger.error("Error processing payout.failed webhook", {
|
||||||
payoutId: payout.id,
|
payoutId: payout.id,
|
||||||
@@ -293,6 +410,412 @@ class StripeWebhookService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 payout.paid, payout.failed, or payout.canceled webhooks were missed.
|
||||||
|
*
|
||||||
|
* Checks paid, failed, and canceled payouts to ensure accurate status tracking.
|
||||||
|
*
|
||||||
|
* @param {string} ownerId - The owner's user ID
|
||||||
|
* @returns {Object} - { reconciled, updated, failed, notificationsSent, errors }
|
||||||
|
*/
|
||||||
|
static async reconcilePayoutStatuses(ownerId) {
|
||||||
|
const results = {
|
||||||
|
reconciled: 0,
|
||||||
|
updated: 0,
|
||||||
|
failed: 0,
|
||||||
|
notificationsSent: 0,
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find rentals that need reconciliation
|
||||||
|
const rentalsToReconcile = await Rental.findAll({
|
||||||
|
where: {
|
||||||
|
ownerId,
|
||||||
|
payoutStatus: "completed",
|
||||||
|
stripeTransferId: { [Op.not]: null },
|
||||||
|
bankDepositStatus: { [Op.is]: null },
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: "owner",
|
||||||
|
attributes: ["id", "email", "firstName", "lastName", "stripeConnectedAccountId"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rentalsToReconcile.length === 0) {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Reconciling payout statuses", {
|
||||||
|
ownerId,
|
||||||
|
rentalsCount: rentalsToReconcile.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the connected account ID (same for all rentals of this owner)
|
||||||
|
const connectedAccountId = rentalsToReconcile[0].owner?.stripeConnectedAccountId;
|
||||||
|
if (!connectedAccountId) {
|
||||||
|
logger.warn("Owner has no connected account ID", { ownerId });
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 }
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
// First check if this transfer is in a failed payout
|
||||||
|
const failedPayout = failedPayoutTransferMap.get(rental.stripeTransferId);
|
||||||
|
|
||||||
|
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 (matchingPaidPayout) {
|
||||||
|
await rental.update({
|
||||||
|
bankDepositStatus: "paid",
|
||||||
|
bankDepositAt: new Date(matchingPaidPayout.arrival_date * 1000),
|
||||||
|
stripePayoutId: matchingPaidPayout.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
results.updated++;
|
||||||
|
|
||||||
|
logger.info("Reconciled rental payout status to paid", {
|
||||||
|
rentalId: rental.id,
|
||||||
|
payoutId: matchingPaidPayout.id,
|
||||||
|
arrivalDate: matchingPaidPayout.arrival_date,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (rentalError) {
|
||||||
|
results.errors.push({
|
||||||
|
rentalId: rental.id,
|
||||||
|
error: rentalError.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.error("Error reconciling rental payout status", {
|
||||||
|
rentalId: rental.id,
|
||||||
|
error: rentalError.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Payout reconciliation complete", {
|
||||||
|
ownerId,
|
||||||
|
reconciled: results.reconciled,
|
||||||
|
updated: results.updated,
|
||||||
|
failed: results.failed,
|
||||||
|
canceled: results.canceled || 0,
|
||||||
|
notificationsSent: results.notificationsSent,
|
||||||
|
errors: results.errors.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error in reconcilePayoutStatuses", {
|
||||||
|
ownerId,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = StripeWebhookService;
|
module.exports = StripeWebhookService;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const cookie = require("cookie");
|
|||||||
* Verifies JWT token and attaches user to socket
|
* Verifies JWT token and attaches user to socket
|
||||||
* Tokens can be provided via:
|
* Tokens can be provided via:
|
||||||
* 1. Cookie (accessToken) - preferred for browser clients
|
* 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) => {
|
const authenticateSocket = async (socket, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -20,16 +20,11 @@ const authenticateSocket = async (socket, next) => {
|
|||||||
token = cookies.accessToken;
|
token = cookies.accessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to query parameter (mobile/other clients)
|
// Auth object for mobile/native clients
|
||||||
if (!token && socket.handshake.auth?.token) {
|
if (!token && socket.handshake.auth?.token) {
|
||||||
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) {
|
if (!token) {
|
||||||
logger.warn("Socket connection rejected - no token provided", {
|
logger.warn("Socket connection rejected - no token provided", {
|
||||||
socketId: socket.id,
|
socketId: socket.id,
|
||||||
@@ -69,7 +64,9 @@ const authenticateSocket = async (socket, next) => {
|
|||||||
userVersion: user.jwtVersion,
|
userVersion: user.jwtVersion,
|
||||||
});
|
});
|
||||||
return next(
|
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."
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
321
backend/templates/emails/accountDisconnectedToOwner.html
Normal file
321
backend/templates/emails/accountDisconnectedToOwner.html
Normal 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">⚠</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>© 2025 Village Share. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,283 +1,322 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
<title>Your Alpha Access Code - Village Share</title>
|
<title>Your Alpha Access Code - Village Share</title>
|
||||||
<style>
|
<style>
|
||||||
/* Reset styles */
|
/* Reset styles */
|
||||||
body, table, td, p, a, li, blockquote {
|
body,
|
||||||
-webkit-text-size-adjust: 100%;
|
table,
|
||||||
-ms-text-size-adjust: 100%;
|
td,
|
||||||
}
|
p,
|
||||||
table, td {
|
a,
|
||||||
mso-table-lspace: 0pt;
|
li,
|
||||||
mso-table-rspace: 0pt;
|
blockquote {
|
||||||
}
|
-webkit-text-size-adjust: 100%;
|
||||||
img {
|
-ms-text-size-adjust: 100%;
|
||||||
-ms-interpolation-mode: bicubic;
|
}
|
||||||
}
|
table,
|
||||||
|
td {
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
-ms-interpolation-mode: bicubic;
|
||||||
|
}
|
||||||
|
|
||||||
/* Base styles */
|
/* Base styles */
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
font-family:
|
||||||
line-height: 1.6;
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
|
||||||
color: #212529;
|
Cantarell, sans-serif;
|
||||||
}
|
line-height: 1.6;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
/* Container */
|
/* Container */
|
||||||
|
.email-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
padding: 40px 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
color: #e0e7ff;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.content {
|
||||||
|
padding: 40px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content p {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
color: #6c757d;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content strong {
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code box */
|
||||||
|
.code-box {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 30px;
|
||||||
|
margin: 30px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-label {
|
||||||
|
color: #e0e7ff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
margin: 10px 0;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button */
|
||||||
|
.button {
|
||||||
|
display: inline-block;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: #ffffff !important;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 16px 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 20px 0;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info box */
|
||||||
|
.info-box {
|
||||||
|
background-color: #e7f3ff;
|
||||||
|
border-left: 4px solid #667eea;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box p {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
color: #004085;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box ul {
|
||||||
|
margin: 10px 0 0 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
color: #004085;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box li {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer p {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
.email-container {
|
.email-container {
|
||||||
max-width: 600px;
|
margin: 0;
|
||||||
margin: 0 auto;
|
border-radius: 0;
|
||||||
background-color: #ffffff;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header */
|
.header,
|
||||||
.header {
|
.content,
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
.footer {
|
||||||
padding: 40px 30px;
|
padding: 20px;
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
font-size: 32px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
|
||||||
color: #ffffff;
|
|
||||||
text-decoration: none;
|
|
||||||
letter-spacing: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagline {
|
|
||||||
color: #e0e7ff;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Content */
|
|
||||||
.content {
|
|
||||||
padding: 40px 30px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content h1 {
|
.content h1 {
|
||||||
font-size: 24px;
|
font-size: 22px;
|
||||||
font-weight: 600;
|
|
||||||
margin: 0 0 20px 0;
|
|
||||||
color: #212529;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content p {
|
|
||||||
margin: 0 0 16px 0;
|
|
||||||
color: #6c757d;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content strong {
|
|
||||||
color: #495057;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Code box */
|
|
||||||
.code-box {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 30px;
|
|
||||||
margin: 30px 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-label {
|
|
||||||
color: #e0e7ff;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.code {
|
.code {
|
||||||
font-family: 'Courier New', Courier, monospace;
|
font-size: 24px;
|
||||||
font-size: 32px;
|
letter-spacing: 2px;
|
||||||
font-weight: 700;
|
|
||||||
color: #ffffff;
|
|
||||||
letter-spacing: 4px;
|
|
||||||
margin: 10px 0;
|
|
||||||
user-select: all;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Button */
|
|
||||||
.button {
|
.button {
|
||||||
display: inline-block;
|
display: block;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
width: 100%;
|
||||||
color: #ffffff !important;
|
box-sizing: border-box;
|
||||||
text-decoration: none;
|
|
||||||
padding: 16px 32px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 20px 0;
|
|
||||||
text-align: center;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Info box */
|
|
||||||
.info-box {
|
|
||||||
background-color: #e7f3ff;
|
|
||||||
border-left: 4px solid #667eea;
|
|
||||||
padding: 20px;
|
|
||||||
margin: 20px 0;
|
|
||||||
border-radius: 0 6px 6px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-box p {
|
|
||||||
margin: 0 0 10px 0;
|
|
||||||
color: #004085;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-box p:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-box ul {
|
|
||||||
margin: 10px 0 0 0;
|
|
||||||
padding-left: 20px;
|
|
||||||
color: #004085;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-box li {
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer */
|
|
||||||
.footer {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
padding: 30px;
|
|
||||||
text-align: center;
|
|
||||||
border-top: 1px solid #e9ecef;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer p {
|
|
||||||
margin: 0 0 10px 0;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer a {
|
|
||||||
color: #667eea;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media only screen and (max-width: 600px) {
|
|
||||||
.email-container {
|
|
||||||
margin: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header, .content, .footer {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
font-size: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content h1 {
|
|
||||||
font-size: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.code {
|
|
||||||
font-size: 24px;
|
|
||||||
letter-spacing: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="email-container">
|
<div class="email-container">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="logo">Village Share</div>
|
<div class="logo">Village Share</div>
|
||||||
<div class="tagline">Alpha Access Invitation</div>
|
<div class="tagline">Alpha Access Invitation</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<h1>Welcome to Alpha Testing!</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Congratulations! You've been selected to participate in the exclusive
|
||||||
|
alpha testing program for Village Share, the community-powered rental
|
||||||
|
marketplace.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Your unique alpha access code is:
|
||||||
|
<strong style="font-family: monospace">{{code}}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>To get started:</p>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<p><strong>Steps to Access:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
Visit
|
||||||
|
<a href="{{frontendUrl}}" style="color: #667eea; font-weight: 600"
|
||||||
|
>{{frontendUrl}}</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>Enter your alpha access code when prompted</li>
|
||||||
|
<li>
|
||||||
|
Register with <strong>this email address</strong> ({{email}})
|
||||||
|
</li>
|
||||||
|
<li>Start exploring the platform!</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content">
|
<div style="text-align: center">
|
||||||
<h1>Welcome to Alpha Testing!</h1>
|
<a href="{{frontendUrl}}" class="button"
|
||||||
|
>Access Village Share Alpha</a
|
||||||
<p>Congratulations! You've been selected to participate in the exclusive alpha testing program for Village Share, the community-powered rental marketplace.</p>
|
>
|
||||||
|
|
||||||
<p>Your unique alpha access code is: <strong style="font-family: monospace;">{{code}}</strong></p>
|
|
||||||
|
|
||||||
<p>To get started:</p>
|
|
||||||
|
|
||||||
<div class="info-box">
|
|
||||||
<p><strong>Steps to Access:</strong></p>
|
|
||||||
<ul>
|
|
||||||
<li>Visit <a href="{{frontendUrl}}" style="color: #667eea; font-weight: 600;">{{frontendUrl}}</a></li>
|
|
||||||
<li>Enter your alpha access code when prompted</li>
|
|
||||||
<li>Register with <strong>this email address</strong> ({{email}})</li>
|
|
||||||
<li>Start exploring the platform!</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="text-align: center;">
|
|
||||||
<a href="{{frontendUrl}}" class="button">Access Village Share Alpha</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p><strong>What to expect as an alpha tester:</strong></p>
|
|
||||||
|
|
||||||
<div class="info-box">
|
|
||||||
<ul>
|
|
||||||
<li>Early access to new features before public launch</li>
|
|
||||||
<li>Opportunity to shape the product with your feedback</li>
|
|
||||||
<li>Direct communication with the development team</li>
|
|
||||||
<li>Special recognition as an early supporter</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p><strong>Important notes:</strong></p>
|
|
||||||
<ul style="color: #6c757d; font-size: 14px;">
|
|
||||||
<li>Your code is tied to this email address only</li>
|
|
||||||
<li>This is a permanent access code (no expiration)</li>
|
|
||||||
<li>Please keep your code confidential</li>
|
|
||||||
<li>We value your feedback - let us know what you think!</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p>We're excited to have you as part of our alpha testing community. Your feedback will be invaluable in making Village Share the best it can be.</p>
|
|
||||||
|
|
||||||
<p>If you have any questions or encounter any issues, please don't hesitate to reach out to us.</p>
|
|
||||||
|
|
||||||
<p>Happy renting!</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footer">
|
<p><strong>What to expect as an alpha tester:</strong></p>
|
||||||
<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>
|
<div class="info-box">
|
||||||
<p>© 2025 Village Share. All rights reserved.</p>
|
<ul>
|
||||||
|
<li>Early access to new features before public launch</li>
|
||||||
|
<li>Opportunity to shape the product with your feedback</li>
|
||||||
|
<li>Direct communication with the development team</li>
|
||||||
|
<li>Special recognition as an early supporter</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p><strong>Important notes:</strong></p>
|
||||||
|
<ul style="color: #6c757d; font-size: 14px">
|
||||||
|
<li>Your code is tied to this email address only</li>
|
||||||
|
<li>This is a permanent access code (no expiration)</li>
|
||||||
|
<li>Please keep your code confidential</li>
|
||||||
|
<li>We value your feedback - let us know what you think!</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
We're excited to have you as part of our alpha testing community. Your
|
||||||
|
feedback will be invaluable in making Village Share the best it can
|
||||||
|
be.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If you have any questions or encounter any issues, please don't
|
||||||
|
hesitate to reach out to us.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>Happy renting!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p><strong>Village Share Alpha Testing Program</strong></p>
|
||||||
|
<p>
|
||||||
|
Need help? Contact us at
|
||||||
|
<a href="mailto:community-support@village-share.com"
|
||||||
|
>community-support@village-share.com</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
296
backend/templates/emails/authenticationRequiredToRenter.html
Normal file
296
backend/templates/emails/authenticationRequiredToRenter.html
Normal 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>© 2025 Village Share. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
@@ -34,8 +34,9 @@
|
|||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
font-family:
|
||||||
Oxygen, Ubuntu, Cantarell, sans-serif;
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
|
||||||
|
Cantarell, sans-serif;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #212529;
|
color: #212529;
|
||||||
}
|
}
|
||||||
@@ -260,7 +261,8 @@
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
If you have any questions, please
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
354
backend/templates/emails/disputeAlertToAdmin.html
Normal file
354
backend/templates/emails/disputeAlertToAdmin.html
Normal 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>© 2025 Village Share. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
359
backend/templates/emails/disputeLostAlertToAdmin.html
Normal file
359
backend/templates/emails/disputeLostAlertToAdmin.html
Normal 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>© 2025 Village Share. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
@@ -34,8 +34,9 @@
|
|||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
font-family:
|
||||||
Oxygen, Ubuntu, Cantarell, sans-serif;
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
|
||||||
|
Cantarell, sans-serif;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #212529;
|
color: #212529;
|
||||||
}
|
}
|
||||||
@@ -246,8 +247,8 @@
|
|||||||
<p>
|
<p>
|
||||||
<strong>Didn't change your password?</strong> If you did not make
|
<strong>Didn't change your password?</strong> If you did not make
|
||||||
this change, your account may be compromised. Please contact our
|
this change, your account may be compromised. Please contact our
|
||||||
support team immediately at support@villageshare.app to secure your
|
support team immediately at community-support@village-share.com to
|
||||||
account.
|
secure your account.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -255,7 +256,7 @@
|
|||||||
<p>
|
<p>
|
||||||
<strong>Security reminder:</strong> Keep your password secure and
|
<strong>Security reminder:</strong> Keep your password secure and
|
||||||
never share it with anyone. We recommend using a strong, unique
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
308
backend/templates/emails/paymentDeclinedToRenter.html
Normal file
308
backend/templates/emails/paymentDeclinedToRenter.html
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
<!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 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, #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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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">Action Required: Payment Issue</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<p>Hi {{renterFirstName}},</p>
|
||||||
|
|
||||||
|
<div class="warning-box">
|
||||||
|
<p><strong>Payment Issue with Your Rental Request</strong></p>
|
||||||
|
<p>
|
||||||
|
The owner tried to approve your rental for
|
||||||
|
<strong>{{itemName}}</strong>, but there was an issue processing
|
||||||
|
your payment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>What Happened</h2>
|
||||||
|
<p>{{declineReason}}</p>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<p><strong>What You Need To Do</strong></p>
|
||||||
|
<p>To update your payment method:</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 your rentals and find your pending request for
|
||||||
|
<strong>{{itemName}}</strong>
|
||||||
|
</li>
|
||||||
|
<li>Click "Update Payment Method" to enter new payment details</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Once you update your payment method, the owner will be notified and
|
||||||
|
can try to approve your rental again.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If you have any questions or need assistance, please don't hesitate to
|
||||||
|
contact our support team.
|
||||||
|
</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 tried to approve your rental but there was a
|
||||||
|
payment issue.
|
||||||
|
</p>
|
||||||
|
<p>If you have any questions, please contact our support team.</p>
|
||||||
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
257
backend/templates/emails/paymentMethodUpdatedToOwner.html
Normal file
257
backend/templates/emails/paymentMethodUpdatedToOwner.html
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
<!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 Method Updated - Village Share</title>
|
||||||
|
<style>
|
||||||
|
/* Reset styles */
|
||||||
|
body,
|
||||||
|
table,
|
||||||
|
td,
|
||||||
|
p,
|
||||||
|
a,
|
||||||
|
li,
|
||||||
|
blockquote {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
table,
|
||||||
|
td {
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
-ms-interpolation-mode: bicubic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success box */
|
||||||
|
.success-box {
|
||||||
|
background-color: #d4edda;
|
||||||
|
border-left: 4px solid #28a745;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-box p {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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">Payment Update</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<p>Hi {{ownerFirstName}},</p>
|
||||||
|
|
||||||
|
<div class="success-box">
|
||||||
|
<p><strong>Payment Method Updated</strong></p>
|
||||||
|
<p>
|
||||||
|
The renter has updated their payment method for the rental of
|
||||||
|
<strong>{{itemName}}</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div style="text-align: center">
|
||||||
|
<a href="{{approvalUrl}}" class="button">Review & Approve Rental</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If you have any questions or need assistance, please don't hesitate
|
||||||
|
to contact our support team.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p><strong>Village Share</strong></p>
|
||||||
|
<p>
|
||||||
|
This is a notification about a rental request for your item.
|
||||||
|
You received this message because the renter updated their payment method.
|
||||||
|
</p>
|
||||||
|
<p>If you have any questions, please contact our support team.</p>
|
||||||
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
366
backend/templates/emails/payoutFailedToOwner.html
Normal file
366
backend/templates/emails/payoutFailedToOwner.html
Normal 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>© 2025 Village Share. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
311
backend/templates/emails/payoutsDisabledToOwner.html
Normal file
311
backend/templates/emails/payoutsDisabledToOwner.html
Normal 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>© 2025 Village Share. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,246 +1,279 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
<title>Personal Information Updated - Village Share</title>
|
<title>Personal Information Updated - Village Share</title>
|
||||||
<style>
|
<style>
|
||||||
/* Reset styles */
|
/* Reset styles */
|
||||||
body, table, td, p, a, li, blockquote {
|
body,
|
||||||
-webkit-text-size-adjust: 100%;
|
table,
|
||||||
-ms-text-size-adjust: 100%;
|
td,
|
||||||
}
|
p,
|
||||||
table, td {
|
a,
|
||||||
mso-table-lspace: 0pt;
|
li,
|
||||||
mso-table-rspace: 0pt;
|
blockquote {
|
||||||
}
|
-webkit-text-size-adjust: 100%;
|
||||||
img {
|
-ms-text-size-adjust: 100%;
|
||||||
-ms-interpolation-mode: bicubic;
|
}
|
||||||
}
|
table,
|
||||||
|
td {
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
-ms-interpolation-mode: bicubic;
|
||||||
|
}
|
||||||
|
|
||||||
/* Base styles */
|
/* Base styles */
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
font-family:
|
||||||
line-height: 1.6;
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
|
||||||
color: #212529;
|
Cantarell, sans-serif;
|
||||||
}
|
line-height: 1.6;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
/* Container */
|
/* Container */
|
||||||
|
.email-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||||
|
padding: 40px 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
color: #d4edda;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.content {
|
||||||
|
padding: 40px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content p {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
color: #6c757d;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content strong {
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success box */
|
||||||
|
.success-box {
|
||||||
|
background-color: #d4edda;
|
||||||
|
border-left: 4px solid #28a745;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-box p {
|
||||||
|
margin: 0;
|
||||||
|
color: #155724;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info box */
|
||||||
|
.info-box {
|
||||||
|
background-color: #e7f3ff;
|
||||||
|
border-left: 4px solid #0066cc;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box p {
|
||||||
|
margin: 0;
|
||||||
|
color: #004085;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Security box */
|
||||||
|
.security-box {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
border-left: 4px solid #dc3545;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-box p {
|
||||||
|
margin: 0;
|
||||||
|
color: #721c24;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Details table */
|
||||||
|
.details-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-table td {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-table td:first-child {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #495057;
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-table td:last-child {
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer p {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
.email-container {
|
.email-container {
|
||||||
max-width: 600px;
|
margin: 0;
|
||||||
margin: 0 auto;
|
border-radius: 0;
|
||||||
background-color: #ffffff;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header */
|
.header,
|
||||||
.header {
|
.content,
|
||||||
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
.footer {
|
||||||
padding: 40px 30px;
|
padding: 20px;
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
font-size: 32px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
|
||||||
color: #ffffff;
|
|
||||||
text-decoration: none;
|
|
||||||
letter-spacing: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagline {
|
|
||||||
color: #d4edda;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Content */
|
|
||||||
.content {
|
|
||||||
padding: 40px 30px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content h1 {
|
.content h1 {
|
||||||
font-size: 24px;
|
font-size: 22px;
|
||||||
font-weight: 600;
|
|
||||||
margin: 0 0 20px 0;
|
|
||||||
color: #212529;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content p {
|
|
||||||
margin: 0 0 16px 0;
|
|
||||||
color: #6c757d;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content strong {
|
|
||||||
color: #495057;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Success box */
|
|
||||||
.success-box {
|
|
||||||
background-color: #d4edda;
|
|
||||||
border-left: 4px solid #28a745;
|
|
||||||
padding: 20px;
|
|
||||||
margin: 20px 0;
|
|
||||||
border-radius: 0 6px 6px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-box p {
|
|
||||||
margin: 0;
|
|
||||||
color: #155724;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Info box */
|
|
||||||
.info-box {
|
|
||||||
background-color: #e7f3ff;
|
|
||||||
border-left: 4px solid #0066cc;
|
|
||||||
padding: 20px;
|
|
||||||
margin: 20px 0;
|
|
||||||
border-radius: 0 6px 6px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-box p {
|
|
||||||
margin: 0;
|
|
||||||
color: #004085;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Security box */
|
|
||||||
.security-box {
|
|
||||||
background-color: #f8d7da;
|
|
||||||
border-left: 4px solid #dc3545;
|
|
||||||
padding: 15px;
|
|
||||||
margin: 20px 0;
|
|
||||||
border-radius: 0 6px 6px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.security-box p {
|
|
||||||
margin: 0;
|
|
||||||
color: #721c24;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Details table */
|
|
||||||
.details-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.details-table td {
|
|
||||||
padding: 12px;
|
|
||||||
border-bottom: 1px solid #e9ecef;
|
|
||||||
}
|
|
||||||
|
|
||||||
.details-table td:first-child {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #495057;
|
|
||||||
width: 40%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.details-table td:last-child {
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer */
|
|
||||||
.footer {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
padding: 30px;
|
|
||||||
text-align: center;
|
|
||||||
border-top: 1px solid #e9ecef;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer p {
|
|
||||||
margin: 0 0 10px 0;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer a {
|
|
||||||
color: #667eea;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media only screen and (max-width: 600px) {
|
|
||||||
.email-container {
|
|
||||||
margin: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header, .content, .footer {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
font-size: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content h1 {
|
|
||||||
font-size: 22px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="email-container">
|
<div class="email-container">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="logo">Village Share</div>
|
<div class="logo">Village Share</div>
|
||||||
<div class="tagline">Personal Information Updated</div>
|
<div class="tagline">Personal Information Updated</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<p>Hi {{recipientName}},</p>
|
||||||
|
|
||||||
|
<h1>Your Personal Information Has Been Updated</h1>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<p>
|
||||||
|
<strong>Your account information was recently updated.</strong> This
|
||||||
|
email is to notify you that changes were made to your personal
|
||||||
|
information on your Village Share account.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content">
|
<p>
|
||||||
<p>Hi {{recipientName}},</p>
|
We're sending you this notification as part of our commitment to
|
||||||
|
keeping your account secure. If you made these changes, no further
|
||||||
|
action is required.
|
||||||
|
</p>
|
||||||
|
|
||||||
<h1>Your Personal Information Has Been Updated</h1>
|
<table class="details-table">
|
||||||
|
<tr>
|
||||||
|
<td>Date & Time:</td>
|
||||||
|
<td>{{timestamp}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Account Email:</td>
|
||||||
|
<td>{{email}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
<div class="info-box">
|
<div class="security-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>
|
||||||
</div>
|
<strong>Didn't make these changes?</strong> If you did not update
|
||||||
|
your personal information, your account may be compromised. Please
|
||||||
<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>
|
contact our support team immediately at
|
||||||
|
community-support@village-share.com and consider changing your
|
||||||
<table class="details-table">
|
password.
|
||||||
<tr>
|
</p>
|
||||||
<td>Date & Time:</td>
|
|
||||||
<td>{{timestamp}}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Account Email:</td>
|
|
||||||
<td>{{email}}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div class="security-box">
|
|
||||||
<p><strong>Didn't make these changes?</strong> If you did not update your personal information, your account may be compromised. Please contact our support team immediately at support@villageshare.app and consider changing your password.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="info-box">
|
|
||||||
<p><strong>Security tip:</strong> Regularly review your account information to ensure it's accurate and up to date. If you notice any suspicious activity, contact our support team right away.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>Thanks for using Village Share!</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footer">
|
<div class="info-box">
|
||||||
<p><strong>Village Share</strong></p>
|
<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>
|
<strong>Security tip:</strong> Regularly review your account
|
||||||
<p>© 2025 Village Share. All rights reserved.</p>
|
information to ensure it's accurate and up to date. If you notice
|
||||||
|
any suspicious activity, contact our support team right away.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p>Thanks for using Village Share!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p><strong>Village Share</strong></p>
|
||||||
|
<p>
|
||||||
|
This is a security notification sent to confirm changes to your
|
||||||
|
account. If you have any concerns about your account security, please
|
||||||
|
contact our support team immediately.
|
||||||
|
</p>
|
||||||
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
232
backend/templates/emails/recoveryCodeUsedToUser.html
Normal file
232
backend/templates/emails/recoveryCodeUsedToUser.html
Normal 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>© 2025 Village Share. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
195
backend/templates/emails/twoFactorDisabledToUser.html
Normal file
195
backend/templates/emails/twoFactorDisabledToUser.html
Normal 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>© 2025 Village Share. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
195
backend/templates/emails/twoFactorEnabledToUser.html
Normal file
195
backend/templates/emails/twoFactorEnabledToUser.html
Normal 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>© 2025 Village Share. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
193
backend/templates/emails/twoFactorOtpToUser.html
Normal file
193
backend/templates/emails/twoFactorOtpToUser.html
Normal 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>© 2025 Village Share. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
277
backend/templates/emails/userBannedNotification.html
Normal file
277
backend/templates/emails/userBannedNotification.html
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
<!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>Account Suspended</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 - Warning red gradient */
|
||||||
|
.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: 16px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.content {
|
||||||
|
padding: 40px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h2 {
|
||||||
|
font-size: 22px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info box */
|
||||||
|
.info-box {
|
||||||
|
background-color: #e7f3ff;
|
||||||
|
border-left: 4px solid #667eea;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box p {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
color: #004085;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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">Important Account Notice</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<p>Hi {{userName}},</p>
|
||||||
|
|
||||||
|
<h1>Your Account Has Been Suspended</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
We're writing to inform you that your Village Share account has been
|
||||||
|
suspended by our moderation team.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="alert-box">
|
||||||
|
<p><strong>Reason for Suspension:</strong></p>
|
||||||
|
<p>{{banReason}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<p><strong>What this means:</strong></p>
|
||||||
|
<ul style="margin: 10px 0; padding-left: 20px; color: #004085">
|
||||||
|
<li>You have been logged out of all devices</li>
|
||||||
|
<li>You cannot log in to your account</li>
|
||||||
|
<li>Your listings are no longer visible to other users</li>
|
||||||
|
<li>Any pending rental requests have been affected</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Need Help or Have Questions?</h2>
|
||||||
|
<p>
|
||||||
|
If you believe this suspension was made in error or if you would like
|
||||||
|
to appeal this decision, please contact our support team:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="text-align: center">
|
||||||
|
<a href="mailto:{{supportEmail}}" class="button">Contact Support</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Best regards,</strong><br />
|
||||||
|
The Village Share Team
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p><strong>Village Share</strong></p>
|
||||||
|
<p>Building a community of sharing and trust</p>
|
||||||
|
<p>
|
||||||
|
This email was sent because your account status has changed.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you have questions, please contact
|
||||||
|
<a href="mailto:{{supportEmail}}">{{supportEmail}}</a>
|
||||||
|
</p>
|
||||||
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,13 +1,30 @@
|
|||||||
// Integration test setup
|
// 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.NODE_ENV = "test";
|
||||||
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';
|
|
||||||
|
|
||||||
// Set other required env vars if not already set
|
// Required environment variables - fail fast if missing
|
||||||
process.env.GOOGLE_MAPS_API_KEY = process.env.GOOGLE_MAPS_API_KEY || 'test-key';
|
const requiredEnvVars = [
|
||||||
process.env.STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY || 'sk_test_key';
|
"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";
|
||||||
|
|||||||
@@ -6,6 +6,17 @@
|
|||||||
* and password reset functionality.
|
* 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 request = require('supertest');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cookieParser = require('cookie-parser');
|
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 { sequelize, User, AlphaInvitation } = require('../../models');
|
||||||
const authRoutes = require('../../routes/auth');
|
const authRoutes = require('../../routes/auth');
|
||||||
|
|
||||||
@@ -48,6 +116,14 @@ const createTestApp = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.use('/auth', authRoutes);
|
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;
|
return app;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -98,9 +174,9 @@ describe('Auth Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// Clean up users before each test
|
// Use destroy without truncate for safer cleanup with foreign keys
|
||||||
await User.destroy({ where: {}, truncate: true, cascade: true });
|
await User.destroy({ where: {}, force: true });
|
||||||
await AlphaInvitation.destroy({ where: {}, truncate: true, cascade: true });
|
await AlphaInvitation.destroy({ where: {}, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /auth/register', () => {
|
describe('POST /auth/register', () => {
|
||||||
@@ -226,7 +302,7 @@ describe('Auth Integration Tests', () => {
|
|||||||
})
|
})
|
||||||
.expect(401);
|
.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 () => {
|
it('should reject login with non-existent email', async () => {
|
||||||
@@ -238,7 +314,7 @@ describe('Auth Integration Tests', () => {
|
|||||||
})
|
})
|
||||||
.expect(401);
|
.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 () => {
|
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 () => {
|
it('should lock account after too many failed attempts', async () => {
|
||||||
// Make 5 failed login attempts
|
// Make 10 failed login attempts (MAX_LOGIN_ATTEMPTS is 10)
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
await request(app)
|
await request(app)
|
||||||
.post('/auth/login')
|
.post('/auth/login')
|
||||||
.send({
|
.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)
|
const response = await request(app)
|
||||||
.post('/auth/login')
|
.post('/auth/login')
|
||||||
.send({
|
.send({
|
||||||
|
|||||||
@@ -6,12 +6,12 @@
|
|||||||
* cancellation flows.
|
* cancellation flows.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const request = require('supertest');
|
const request = require("supertest");
|
||||||
const express = require('express');
|
const express = require("express");
|
||||||
const cookieParser = require('cookie-parser');
|
const cookieParser = require("cookie-parser");
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require("jsonwebtoken");
|
||||||
const { sequelize, User, Item, Rental } = require('../../models');
|
const { sequelize, User, Item, Rental } = require("../../models");
|
||||||
const rentalRoutes = require('../../routes/rentals');
|
const rentalRoutes = require("../../routes/rentals");
|
||||||
|
|
||||||
// Test app setup
|
// Test app setup
|
||||||
const createTestApp = () => {
|
const createTestApp = () => {
|
||||||
@@ -21,11 +21,11 @@ const createTestApp = () => {
|
|||||||
|
|
||||||
// Add request ID middleware
|
// Add request ID middleware
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
req.id = 'test-request-id';
|
req.id = "test-request-id";
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use('/rentals', rentalRoutes);
|
app.use("/rentals", rentalRoutes);
|
||||||
return app;
|
return app;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ const generateAuthToken = (user) => {
|
|||||||
return jwt.sign(
|
return jwt.sign(
|
||||||
{ id: user.id, jwtVersion: user.jwtVersion || 0 },
|
{ id: user.id, jwtVersion: user.jwtVersion || 0 },
|
||||||
process.env.JWT_ACCESS_SECRET,
|
process.env.JWT_ACCESS_SECRET,
|
||||||
{ expiresIn: '15m' }
|
{ expiresIn: "15m" },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,11 +42,11 @@ const generateAuthToken = (user) => {
|
|||||||
const createTestUser = async (overrides = {}) => {
|
const createTestUser = async (overrides = {}) => {
|
||||||
const defaultData = {
|
const defaultData = {
|
||||||
email: `user-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`,
|
email: `user-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`,
|
||||||
password: 'TestPassword123!',
|
password: "TestPassword123!",
|
||||||
firstName: 'Test',
|
firstName: "Test",
|
||||||
lastName: 'User',
|
lastName: "User",
|
||||||
isVerified: true,
|
isVerified: true,
|
||||||
authProvider: 'local',
|
authProvider: "local",
|
||||||
};
|
};
|
||||||
|
|
||||||
return User.create({ ...defaultData, ...overrides });
|
return User.create({ ...defaultData, ...overrides });
|
||||||
@@ -54,17 +54,17 @@ const createTestUser = async (overrides = {}) => {
|
|||||||
|
|
||||||
const createTestItem = async (ownerId, overrides = {}) => {
|
const createTestItem = async (ownerId, overrides = {}) => {
|
||||||
const defaultData = {
|
const defaultData = {
|
||||||
name: 'Test Item',
|
name: "Test Item",
|
||||||
description: 'A test item for rental',
|
description: "A test item for rental",
|
||||||
pricePerDay: 25.00,
|
pricePerDay: 25.0,
|
||||||
pricePerHour: 5.00,
|
pricePerHour: 5.0,
|
||||||
replacementCost: 500.00,
|
replacementCost: 500.0,
|
||||||
condition: 'excellent',
|
condition: "excellent",
|
||||||
isAvailable: true,
|
isAvailable: true,
|
||||||
pickUpAvailable: true,
|
pickUpAvailable: true,
|
||||||
ownerId,
|
ownerId,
|
||||||
city: 'Test City',
|
city: "Test City",
|
||||||
state: 'California',
|
state: "California",
|
||||||
};
|
};
|
||||||
|
|
||||||
return Item.create({ ...defaultData, ...overrides });
|
return Item.create({ ...defaultData, ...overrides });
|
||||||
@@ -84,15 +84,15 @@ const createTestRental = async (itemId, renterId, ownerId, overrides = {}) => {
|
|||||||
totalAmount: 0,
|
totalAmount: 0,
|
||||||
platformFee: 0,
|
platformFee: 0,
|
||||||
payoutAmount: 0,
|
payoutAmount: 0,
|
||||||
status: 'pending',
|
status: "pending",
|
||||||
paymentStatus: 'pending',
|
paymentStatus: "pending",
|
||||||
deliveryMethod: 'pickup',
|
deliveryMethod: "pickup",
|
||||||
};
|
};
|
||||||
|
|
||||||
return Rental.create({ ...defaultData, ...overrides });
|
return Rental.create({ ...defaultData, ...overrides });
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('Rental Integration Tests', () => {
|
describe("Rental Integration Tests", () => {
|
||||||
let app;
|
let app;
|
||||||
let owner;
|
let owner;
|
||||||
let renter;
|
let renter;
|
||||||
@@ -100,9 +100,9 @@ describe('Rental Integration Tests', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Set test environment variables
|
// Set test environment variables
|
||||||
process.env.NODE_ENV = 'test';
|
process.env.NODE_ENV = "test";
|
||||||
process.env.JWT_ACCESS_SECRET = 'test-access-secret';
|
process.env.JWT_ACCESS_SECRET = "test-access-secret";
|
||||||
process.env.JWT_REFRESH_SECRET = 'test-refresh-secret';
|
process.env.JWT_REFRESH_SECRET = "test-refresh-secret";
|
||||||
|
|
||||||
// Sync database
|
// Sync database
|
||||||
await sequelize.sync({ force: true });
|
await sequelize.sync({ force: true });
|
||||||
@@ -122,32 +122,32 @@ describe('Rental Integration Tests', () => {
|
|||||||
|
|
||||||
// Create test users
|
// Create test users
|
||||||
owner = await createTestUser({
|
owner = await createTestUser({
|
||||||
email: 'owner@example.com',
|
email: "owner@example.com",
|
||||||
firstName: 'Item',
|
firstName: "Item",
|
||||||
lastName: 'Owner',
|
lastName: "Owner",
|
||||||
stripeConnectedAccountId: 'acct_test_owner',
|
stripeConnectedAccountId: "acct_test_owner",
|
||||||
});
|
});
|
||||||
|
|
||||||
renter = await createTestUser({
|
renter = await createTestUser({
|
||||||
email: 'renter@example.com',
|
email: "renter@example.com",
|
||||||
firstName: 'Item',
|
firstName: "Item",
|
||||||
lastName: 'Renter',
|
lastName: "Renter",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create test item
|
// Create test item
|
||||||
item = await createTestItem(owner.id);
|
item = await createTestItem(owner.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /rentals/renting', () => {
|
describe("GET /rentals/renting", () => {
|
||||||
it('should return rentals where user is the renter', async () => {
|
it("should return rentals where user is the renter", async () => {
|
||||||
// Create a rental where renter is the renter
|
// Create a rental where renter is the renter
|
||||||
await createTestRental(item.id, renter.id, owner.id);
|
await createTestRental(item.id, renter.id, owner.id);
|
||||||
|
|
||||||
const token = generateAuthToken(renter);
|
const token = generateAuthToken(renter);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.get('/rentals/renting')
|
.get("/rentals/renting")
|
||||||
.set('Cookie', [`accessToken=${token}`])
|
.set("Cookie", [`accessToken=${token}`])
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(Array.isArray(response.body)).toBe(true);
|
expect(Array.isArray(response.body)).toBe(true);
|
||||||
@@ -155,37 +155,35 @@ describe('Rental Integration Tests', () => {
|
|||||||
expect(response.body[0].renterId).toBe(renter.id);
|
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 token = generateAuthToken(renter);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.get('/rentals/renting')
|
.get("/rentals/renting")
|
||||||
.set('Cookie', [`accessToken=${token}`])
|
.set("Cookie", [`accessToken=${token}`])
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(Array.isArray(response.body)).toBe(true);
|
expect(Array.isArray(response.body)).toBe(true);
|
||||||
expect(response.body.length).toBe(0);
|
expect(response.body.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should require authentication', async () => {
|
it("should require authentication", async () => {
|
||||||
const response = await request(app)
|
const response = await request(app).get("/rentals/renting").expect(401);
|
||||||
.get('/rentals/renting')
|
|
||||||
.expect(401);
|
|
||||||
|
|
||||||
expect(response.body.code).toBeDefined();
|
expect(response.body.code).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /rentals/owning', () => {
|
describe("GET /rentals/owning", () => {
|
||||||
it('should return rentals where user is the owner', async () => {
|
it("should return rentals where user is the owner", async () => {
|
||||||
// Create a rental where owner is the item owner
|
// Create a rental where owner is the item owner
|
||||||
await createTestRental(item.id, renter.id, owner.id);
|
await createTestRental(item.id, renter.id, owner.id);
|
||||||
|
|
||||||
const token = generateAuthToken(owner);
|
const token = generateAuthToken(owner);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.get('/rentals/owning')
|
.get("/rentals/owning")
|
||||||
.set('Cookie', [`accessToken=${token}`])
|
.set("Cookie", [`accessToken=${token}`])
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(Array.isArray(response.body)).toBe(true);
|
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;
|
let rental;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
rental = await createTestRental(item.id, renter.id, owner.id);
|
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 token = generateAuthToken(owner);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.put(`/rentals/${rental.id}/status`)
|
.put(`/rentals/${rental.id}/status`)
|
||||||
.set('Cookie', [`accessToken=${token}`])
|
.set("Cookie", [`accessToken=${token}`])
|
||||||
.send({ status: 'confirmed' })
|
.send({ status: "confirmed" })
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(response.body.status).toBe('confirmed');
|
expect(response.body.status).toBe("confirmed");
|
||||||
|
|
||||||
// Verify in database
|
// Verify in database
|
||||||
await rental.reload();
|
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 token = generateAuthToken(renter);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.put(`/rentals/${rental.id}/status`)
|
.put(`/rentals/${rental.id}/status`)
|
||||||
.set('Cookie', [`accessToken=${token}`])
|
.set("Cookie", [`accessToken=${token}`])
|
||||||
.send({ status: 'confirmed' })
|
.send({ status: "confirmed" })
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
// Note: API currently allows both owner and renter to update status
|
|
||||||
// Owner-specific logic (payment processing) only runs for owner
|
// Owner-specific logic (payment processing) only runs for owner
|
||||||
await rental.reload();
|
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
|
// First confirm it
|
||||||
await rental.update({ status: 'confirmed' });
|
await rental.update({ status: "confirmed" });
|
||||||
|
|
||||||
const token = generateAuthToken(owner);
|
const token = generateAuthToken(owner);
|
||||||
|
|
||||||
// API allows re-confirming (idempotent operation)
|
// API allows re-confirming (idempotent operation)
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.put(`/rentals/${rental.id}/status`)
|
.put(`/rentals/${rental.id}/status`)
|
||||||
.set('Cookie', [`accessToken=${token}`])
|
.set("Cookie", [`accessToken=${token}`])
|
||||||
.send({ status: 'confirmed' })
|
.send({ status: "confirmed" })
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
// Status should remain confirmed
|
// Status should remain confirmed
|
||||||
await rental.reload();
|
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;
|
let rental;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
rental = await createTestRental(item.id, renter.id, owner.id);
|
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 token = generateAuthToken(owner);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.put(`/rentals/${rental.id}/decline`)
|
.put(`/rentals/${rental.id}/decline`)
|
||||||
.set('Cookie', [`accessToken=${token}`])
|
.set("Cookie", [`accessToken=${token}`])
|
||||||
.send({ reason: 'Item not available for those dates' })
|
.send({ reason: "Item not available for those dates" })
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(response.body.status).toBe('declined');
|
expect(response.body.status).toBe("declined");
|
||||||
|
|
||||||
// Verify in database
|
// Verify in database
|
||||||
await rental.reload();
|
await rental.reload();
|
||||||
expect(rental.status).toBe('declined');
|
expect(rental.status).toBe("declined");
|
||||||
expect(rental.declineReason).toBe('Item not available for those dates');
|
expect(rental.declineReason).toBe("Item not available for those dates");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not allow declining already declined rental', async () => {
|
it("should not allow declining already declined rental", async () => {
|
||||||
await rental.update({ status: 'declined' });
|
await rental.update({ status: "declined" });
|
||||||
|
|
||||||
const token = generateAuthToken(owner);
|
const token = generateAuthToken(owner);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.put(`/rentals/${rental.id}/decline`)
|
.put(`/rentals/${rental.id}/decline`)
|
||||||
.set('Cookie', [`accessToken=${token}`])
|
.set("Cookie", [`accessToken=${token}`])
|
||||||
.send({ reason: 'Already declined' })
|
.send({ reason: "Already declined" })
|
||||||
.expect(400);
|
.expect(400);
|
||||||
|
|
||||||
expect(response.body.error).toBeDefined();
|
expect(response.body.error).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /rentals/:id/cancel', () => {
|
describe("POST /rentals/:id/cancel", () => {
|
||||||
let rental;
|
let rental;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
rental = await createTestRental(item.id, renter.id, owner.id, {
|
rental = await createTestRental(item.id, renter.id, owner.id, {
|
||||||
status: 'confirmed',
|
status: "confirmed",
|
||||||
paymentStatus: 'paid',
|
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 token = generateAuthToken(renter);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.post(`/rentals/${rental.id}/cancel`)
|
.post(`/rentals/${rental.id}/cancel`)
|
||||||
.set('Cookie', [`accessToken=${token}`])
|
.set("Cookie", [`accessToken=${token}`])
|
||||||
.send({ reason: 'Change of plans' })
|
.send({ reason: "Change of plans" })
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
// Response format is { rental: {...}, refund: {...} }
|
// Response format is { rental: {...}, refund: {...} }
|
||||||
expect(response.body.rental.status).toBe('cancelled');
|
expect(response.body.rental.status).toBe("cancelled");
|
||||||
expect(response.body.rental.cancelledBy).toBe('renter');
|
expect(response.body.rental.cancelledBy).toBe("renter");
|
||||||
|
|
||||||
// Verify in database
|
// Verify in database
|
||||||
await rental.reload();
|
await rental.reload();
|
||||||
expect(rental.status).toBe('cancelled');
|
expect(rental.status).toBe("cancelled");
|
||||||
expect(rental.cancelledBy).toBe('renter');
|
expect(rental.cancelledBy).toBe("renter");
|
||||||
expect(rental.cancelledAt).toBeDefined();
|
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 token = generateAuthToken(owner);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.post(`/rentals/${rental.id}/cancel`)
|
.post(`/rentals/${rental.id}/cancel`)
|
||||||
.set('Cookie', [`accessToken=${token}`])
|
.set("Cookie", [`accessToken=${token}`])
|
||||||
.send({ reason: 'Item broken' })
|
.send({ reason: "Item broken" })
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(response.body.rental.status).toBe('cancelled');
|
expect(response.body.rental.status).toBe("cancelled");
|
||||||
expect(response.body.rental.cancelledBy).toBe('owner');
|
expect(response.body.rental.cancelledBy).toBe("owner");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not allow cancelling completed rental', async () => {
|
it("should not allow cancelling completed rental", async () => {
|
||||||
await rental.update({ status: 'completed', paymentStatus: 'paid' });
|
await rental.update({ status: "completed", paymentStatus: "paid" });
|
||||||
|
|
||||||
const token = generateAuthToken(renter);
|
const token = generateAuthToken(renter);
|
||||||
|
|
||||||
// RefundService throws error which becomes 500 via next(error)
|
// RefundService throws error which becomes 500 via next(error)
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.post(`/rentals/${rental.id}/cancel`)
|
.post(`/rentals/${rental.id}/cancel`)
|
||||||
.set('Cookie', [`accessToken=${token}`])
|
.set("Cookie", [`accessToken=${token}`])
|
||||||
.send({ reason: 'Too late' });
|
.send({ reason: "Too late" });
|
||||||
|
|
||||||
// Expect error (could be 400 or 500 depending on error middleware)
|
// Expect error (could be 400 or 500 depending on error middleware)
|
||||||
expect(response.status).toBeGreaterThanOrEqual(400);
|
expect(response.status).toBeGreaterThanOrEqual(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not allow unauthorized user to cancel rental', async () => {
|
it("should not allow unauthorized user to cancel rental", async () => {
|
||||||
const otherUser = await createTestUser({ email: 'other@example.com' });
|
const otherUser = await createTestUser({ email: "other@example.com" });
|
||||||
const token = generateAuthToken(otherUser);
|
const token = generateAuthToken(otherUser);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.post(`/rentals/${rental.id}/cancel`)
|
.post(`/rentals/${rental.id}/cancel`)
|
||||||
.set('Cookie', [`accessToken=${token}`])
|
.set("Cookie", [`accessToken=${token}`])
|
||||||
.send({ reason: 'Not my rental' });
|
.send({ reason: "Not my rental" });
|
||||||
|
|
||||||
// Expect error (could be 403 or 500 depending on error middleware)
|
// Expect error (could be 403 or 500 depending on error middleware)
|
||||||
expect(response.status).toBeGreaterThanOrEqual(400);
|
expect(response.status).toBeGreaterThanOrEqual(400);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /rentals/pending-requests-count', () => {
|
describe("GET /rentals/pending-requests-count", () => {
|
||||||
it('should return count of pending rental requests for owner', async () => {
|
it("should return count of pending rental requests for owner", async () => {
|
||||||
// Create multiple pending rentals
|
// Create multiple pending rentals
|
||||||
await createTestRental(item.id, renter.id, owner.id, { status: 'pending' });
|
await createTestRental(item.id, renter.id, owner.id, {
|
||||||
await createTestRental(item.id, renter.id, owner.id, { status: 'pending' });
|
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: "confirmed",
|
||||||
|
});
|
||||||
|
|
||||||
const token = generateAuthToken(owner);
|
const token = generateAuthToken(owner);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.get('/rentals/pending-requests-count')
|
.get("/rentals/pending-requests-count")
|
||||||
.set('Cookie', [`accessToken=${token}`])
|
.set("Cookie", [`accessToken=${token}`])
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(response.body.count).toBe(2);
|
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 token = generateAuthToken(renter);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.get('/rentals/pending-requests-count')
|
.get("/rentals/pending-requests-count")
|
||||||
.set('Cookie', [`accessToken=${token}`])
|
.set("Cookie", [`accessToken=${token}`])
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(response.body.count).toBe(0);
|
expect(response.body.count).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Rental Lifecycle', () => {
|
describe("Rental Lifecycle", () => {
|
||||||
it('should complete full rental lifecycle: pending -> confirmed -> active -> completed', async () => {
|
it("should complete full rental lifecycle: pending -> confirmed -> active -> completed", async () => {
|
||||||
// Create pending free rental (totalAmount: 0 is default)
|
// Create pending free rental (totalAmount: 0 is default)
|
||||||
const rental = await createTestRental(item.id, renter.id, owner.id, {
|
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
|
startDateTime: new Date(Date.now() - 60 * 60 * 1000), // Started 1 hour ago
|
||||||
endDateTime: new Date(Date.now() + 60 * 60 * 1000), // Ends in 1 hour
|
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)
|
// Step 1: Owner confirms rental (works for free rentals)
|
||||||
let response = await request(app)
|
let response = await request(app)
|
||||||
.put(`/rentals/${rental.id}/status`)
|
.put(`/rentals/${rental.id}/status`)
|
||||||
.set('Cookie', [`accessToken=${ownerToken}`])
|
.set("Cookie", [`accessToken=${ownerToken}`])
|
||||||
.send({ status: 'confirmed' })
|
.send({ status: "confirmed" })
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(response.body.status).toBe('confirmed');
|
expect(response.body.status).toBe("confirmed");
|
||||||
|
|
||||||
// Step 2: Rental becomes active (typically done by system/webhook)
|
// Step 2: Rental is now "active" because status is confirmed and startDateTime has passed.
|
||||||
await rental.update({ status: 'active' });
|
// "active" is a computed status, not stored. The stored status remains "confirmed"
|
||||||
|
|
||||||
// Verify status
|
|
||||||
await rental.reload();
|
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)
|
response = await request(app)
|
||||||
.post(`/rentals/${rental.id}/mark-completed`)
|
.post(`/rentals/${rental.id}/mark-return`)
|
||||||
.set('Cookie', [`accessToken=${ownerToken}`])
|
.set("Cookie", [`accessToken=${ownerToken}`])
|
||||||
|
.send({ status: "returned" })
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(response.body.status).toBe('completed');
|
expect(response.body.rental.status).toBe("completed");
|
||||||
|
|
||||||
// Verify final state
|
// Verify final state
|
||||||
await rental.reload();
|
await rental.reload();
|
||||||
expect(rental.status).toBe('completed');
|
expect(rental.status).toBe("completed");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Review System', () => {
|
describe("Review System", () => {
|
||||||
let completedRental;
|
let completedRental;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
completedRental = await createTestRental(item.id, renter.id, owner.id, {
|
completedRental = await createTestRental(item.id, renter.id, owner.id, {
|
||||||
status: 'completed',
|
status: "completed",
|
||||||
paymentStatus: 'paid',
|
paymentStatus: "paid",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow renter to review item', async () => {
|
it("should allow renter to review item", async () => {
|
||||||
const token = generateAuthToken(renter);
|
const token = generateAuthToken(renter);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.post(`/rentals/${completedRental.id}/review-item`)
|
.post(`/rentals/${completedRental.id}/review-item`)
|
||||||
.set('Cookie', [`accessToken=${token}`])
|
.set("Cookie", [`accessToken=${token}`])
|
||||||
.send({
|
.send({
|
||||||
rating: 5,
|
rating: 5,
|
||||||
review: 'Great item, worked perfectly!',
|
review: "Great item, worked perfectly!",
|
||||||
})
|
})
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
@@ -459,19 +462,19 @@ describe('Rental Integration Tests', () => {
|
|||||||
// Verify in database
|
// Verify in database
|
||||||
await completedRental.reload();
|
await completedRental.reload();
|
||||||
expect(completedRental.itemRating).toBe(5);
|
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();
|
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 token = generateAuthToken(owner);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.post(`/rentals/${completedRental.id}/review-renter`)
|
.post(`/rentals/${completedRental.id}/review-renter`)
|
||||||
.set('Cookie', [`accessToken=${token}`])
|
.set("Cookie", [`accessToken=${token}`])
|
||||||
.send({
|
.send({
|
||||||
rating: 4,
|
rating: 4,
|
||||||
review: 'Good renter, returned on time.',
|
review: "Good renter, returned on time.",
|
||||||
})
|
})
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
@@ -480,33 +483,40 @@ describe('Rental Integration Tests', () => {
|
|||||||
// Verify in database
|
// Verify in database
|
||||||
await completedRental.reload();
|
await completedRental.reload();
|
||||||
expect(completedRental.renterRating).toBe(4);
|
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 () => {
|
it("should not allow review of non-completed rental", async () => {
|
||||||
const pendingRental = await createTestRental(item.id, renter.id, owner.id, {
|
const pendingRental = await createTestRental(
|
||||||
status: 'pending',
|
item.id,
|
||||||
});
|
renter.id,
|
||||||
|
owner.id,
|
||||||
|
{
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const token = generateAuthToken(renter);
|
const token = generateAuthToken(renter);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.post(`/rentals/${pendingRental.id}/review-item`)
|
.post(`/rentals/${pendingRental.id}/review-item`)
|
||||||
.set('Cookie', [`accessToken=${token}`])
|
.set("Cookie", [`accessToken=${token}`])
|
||||||
.send({
|
.send({
|
||||||
rating: 5,
|
rating: 5,
|
||||||
review: 'Cannot review yet',
|
review: "Cannot review yet",
|
||||||
})
|
})
|
||||||
.expect(400);
|
.expect(400);
|
||||||
|
|
||||||
expect(response.body.error).toBeDefined();
|
expect(response.body.error).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not allow duplicate reviews', async () => {
|
it("should not allow duplicate reviews", async () => {
|
||||||
// First review
|
// First review
|
||||||
await completedRental.update({
|
await completedRental.update({
|
||||||
itemRating: 5,
|
itemRating: 5,
|
||||||
itemReview: 'First review',
|
itemReview: "First review",
|
||||||
itemReviewSubmittedAt: new Date(),
|
itemReviewSubmittedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -514,31 +524,39 @@ describe('Rental Integration Tests', () => {
|
|||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.post(`/rentals/${completedRental.id}/review-item`)
|
.post(`/rentals/${completedRental.id}/review-item`)
|
||||||
.set('Cookie', [`accessToken=${token}`])
|
.set("Cookie", [`accessToken=${token}`])
|
||||||
.send({
|
.send({
|
||||||
rating: 3,
|
rating: 3,
|
||||||
review: 'Second review attempt',
|
review: "Second review attempt",
|
||||||
})
|
})
|
||||||
.expect(400);
|
.expect(400);
|
||||||
|
|
||||||
expect(response.body.error).toContain('already');
|
expect(response.body.error).toContain("already");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Database Constraints', () => {
|
describe("Database Constraints", () => {
|
||||||
it('should not allow rental with invalid item ID', async () => {
|
it("should not allow rental with invalid item ID", async () => {
|
||||||
await expect(
|
await expect(
|
||||||
createTestRental('00000000-0000-0000-0000-000000000000', renter.id, owner.id)
|
createTestRental(
|
||||||
|
"00000000-0000-0000-0000-000000000000",
|
||||||
|
renter.id,
|
||||||
|
owner.id,
|
||||||
|
),
|
||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not allow rental with invalid user IDs', async () => {
|
it("should not allow rental with invalid user IDs", async () => {
|
||||||
await expect(
|
await expect(
|
||||||
createTestRental(item.id, '00000000-0000-0000-0000-000000000000', owner.id)
|
createTestRental(
|
||||||
|
item.id,
|
||||||
|
"00000000-0000-0000-0000-000000000000",
|
||||||
|
owner.id,
|
||||||
|
),
|
||||||
).rejects.toThrow();
|
).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);
|
const rental = await createTestRental(item.id, renter.id, owner.id);
|
||||||
|
|
||||||
// Delete the item
|
// Delete the item
|
||||||
@@ -550,10 +568,10 @@ describe('Rental Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Concurrent Operations', () => {
|
describe("Concurrent Operations", () => {
|
||||||
it('should handle concurrent status updates (last write wins)', async () => {
|
it("should handle concurrent status updates (last write wins)", async () => {
|
||||||
const rental = await createTestRental(item.id, renter.id, owner.id, {
|
const rental = await createTestRental(item.id, renter.id, owner.id, {
|
||||||
status: 'pending',
|
status: "pending",
|
||||||
});
|
});
|
||||||
|
|
||||||
const ownerToken = generateAuthToken(owner);
|
const ownerToken = generateAuthToken(owner);
|
||||||
@@ -562,22 +580,22 @@ describe('Rental Integration Tests', () => {
|
|||||||
const [confirmResult, declineResult] = await Promise.allSettled([
|
const [confirmResult, declineResult] = await Promise.allSettled([
|
||||||
request(app)
|
request(app)
|
||||||
.put(`/rentals/${rental.id}/status`)
|
.put(`/rentals/${rental.id}/status`)
|
||||||
.set('Cookie', [`accessToken=${ownerToken}`])
|
.set("Cookie", [`accessToken=${ownerToken}`])
|
||||||
.send({ status: 'confirmed' }),
|
.send({ status: "confirmed" }),
|
||||||
request(app)
|
request(app)
|
||||||
.put(`/rentals/${rental.id}/decline`)
|
.put(`/rentals/${rental.id}/decline`)
|
||||||
.set('Cookie', [`accessToken=${ownerToken}`])
|
.set("Cookie", [`accessToken=${ownerToken}`])
|
||||||
.send({ reason: 'Declining instead' }),
|
.send({ reason: "Declining instead" }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Both requests may succeed (no optimistic locking)
|
// Both requests may succeed (no optimistic locking)
|
||||||
// Verify rental ends up in a valid state
|
// Verify rental ends up in a valid state
|
||||||
await rental.reload();
|
await rental.reload();
|
||||||
expect(['confirmed', 'declined']).toContain(rental.status);
|
expect(["confirmed", "declined"]).toContain(rental.status);
|
||||||
|
|
||||||
// At least one should have succeeded
|
// At least one should have succeeded
|
||||||
const successes = [confirmResult, declineResult].filter(
|
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);
|
expect(successes.length).toBeGreaterThanOrEqual(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
process.env.NODE_ENV = 'test';
|
process.env.NODE_ENV = "test";
|
||||||
process.env.JWT_SECRET = 'test-secret';
|
process.env.JWT_SECRET = "test-secret";
|
||||||
process.env.DATABASE_URL = 'postgresql://test';
|
process.env.DATABASE_URL = "db://test";
|
||||||
process.env.GOOGLE_MAPS_API_KEY = 'test-key';
|
process.env.GOOGLE_MAPS_API_KEY = "test-key";
|
||||||
process.env.STRIPE_SECRET_KEY = 'sk_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
|
// Silence console
|
||||||
global.console = {
|
global.console = {
|
||||||
...console,
|
...console,
|
||||||
log: jest.fn(),
|
log: jest.fn(),
|
||||||
error: jest.fn(),
|
error: jest.fn(),
|
||||||
warn: jest.fn()
|
warn: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { authenticateToken, requireVerifiedEmail } = require('../../../middleware/auth');
|
const { authenticateToken, optionalAuth, requireVerifiedEmail, requireAdmin } = require('../../../middleware/auth');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
jest.mock('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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,29 +1,47 @@
|
|||||||
|
// Set CSRF_SECRET before requiring the middleware
|
||||||
|
process.env.CSRF_SECRET = "test-csrf-secret";
|
||||||
|
|
||||||
const mockTokensInstance = {
|
const mockTokensInstance = {
|
||||||
secretSync: jest.fn().mockReturnValue('mock-secret'),
|
secretSync: jest.fn().mockReturnValue(process.env.CSRF_SECRET),
|
||||||
create: jest.fn().mockReturnValue('mock-token-123'),
|
create: jest.fn().mockReturnValue("mock-token-123"),
|
||||||
verify: jest.fn().mockReturnValue(true)
|
verify: jest.fn().mockReturnValue(true),
|
||||||
};
|
};
|
||||||
|
|
||||||
jest.mock('csrf', () => {
|
jest.mock("csrf", () => {
|
||||||
return jest.fn().mockImplementation(() => mockTokensInstance);
|
return jest.fn().mockImplementation(() => mockTokensInstance);
|
||||||
});
|
});
|
||||||
|
|
||||||
jest.mock('cookie-parser', () => {
|
jest.mock("cookie-parser", () => {
|
||||||
return jest.fn().mockReturnValue((req, res, next) => next());
|
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;
|
let req, res, next;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
req = {
|
req = {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {},
|
headers: {},
|
||||||
body: {},
|
body: {},
|
||||||
query: {},
|
query: {},
|
||||||
cookies: {}
|
cookies: {},
|
||||||
};
|
};
|
||||||
res = {
|
res = {
|
||||||
status: jest.fn().mockReturnThis(),
|
status: jest.fn().mockReturnThis(),
|
||||||
@@ -31,16 +49,16 @@ describe('CSRF Middleware', () => {
|
|||||||
send: jest.fn(),
|
send: jest.fn(),
|
||||||
cookie: jest.fn(),
|
cookie: jest.fn(),
|
||||||
set: jest.fn(),
|
set: jest.fn(),
|
||||||
locals: {}
|
locals: {},
|
||||||
};
|
};
|
||||||
next = jest.fn();
|
next = jest.fn();
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('csrfProtection', () => {
|
describe("csrfProtection", () => {
|
||||||
describe('Safe methods', () => {
|
describe("Safe methods", () => {
|
||||||
it('should skip CSRF protection for GET requests', () => {
|
it("should skip CSRF protection for GET requests", () => {
|
||||||
req.method = 'GET';
|
req.method = "GET";
|
||||||
|
|
||||||
csrfProtection(req, res, next);
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
@@ -48,8 +66,8 @@ describe('CSRF Middleware', () => {
|
|||||||
expect(res.status).not.toHaveBeenCalled();
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip CSRF protection for HEAD requests', () => {
|
it("should skip CSRF protection for HEAD requests", () => {
|
||||||
req.method = 'HEAD';
|
req.method = "HEAD";
|
||||||
|
|
||||||
csrfProtection(req, res, next);
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
@@ -57,8 +75,8 @@ describe('CSRF Middleware', () => {
|
|||||||
expect(res.status).not.toHaveBeenCalled();
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip CSRF protection for OPTIONS requests', () => {
|
it("should skip CSRF protection for OPTIONS requests", () => {
|
||||||
req.method = 'OPTIONS';
|
req.method = "OPTIONS";
|
||||||
|
|
||||||
csrfProtection(req, res, next);
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
@@ -67,418 +85,427 @@ describe('CSRF Middleware', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Token validation', () => {
|
describe("Token validation", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
req.cookies = { 'csrf-token': 'mock-token-123' };
|
req.cookies = { "csrf-token": "mock-token-123" };
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should validate token from x-csrf-token header', () => {
|
it("should validate token from x-csrf-token header", () => {
|
||||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
req.headers["x-csrf-token"] = "mock-token-123";
|
||||||
|
|
||||||
csrfProtection(req, res, next);
|
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(next).toHaveBeenCalled();
|
||||||
expect(res.status).not.toHaveBeenCalled();
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should validate token from request body', () => {
|
it("should validate token from request body", () => {
|
||||||
req.body.csrfToken = 'mock-token-123';
|
req.body.csrfToken = "mock-token-123";
|
||||||
|
|
||||||
csrfProtection(req, res, next);
|
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(next).toHaveBeenCalled();
|
||||||
expect(res.status).not.toHaveBeenCalled();
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should validate token from query parameters', () => {
|
it("should prefer header token over body token", () => {
|
||||||
req.query.csrfToken = 'mock-token-123';
|
req.headers["x-csrf-token"] = "mock-token-123";
|
||||||
|
req.body.csrfToken = "different-token";
|
||||||
|
|
||||||
csrfProtection(req, res, next);
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
expect(mockTokensInstance.verify).toHaveBeenCalledWith(
|
||||||
expect(next).toHaveBeenCalled();
|
process.env.CSRF_SECRET,
|
||||||
expect(res.status).not.toHaveBeenCalled();
|
"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();
|
|
||||||
});
|
|
||||||
|
|
||||||
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(next).toHaveBeenCalled();
|
expect(next).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Missing tokens', () => {
|
describe("Missing tokens", () => {
|
||||||
it('should return 403 when no token provided', () => {
|
it("should return 403 when no token provided", () => {
|
||||||
req.cookies = { 'csrf-token': 'mock-token-123' };
|
req.cookies = { "csrf-token": "mock-token-123" };
|
||||||
|
|
||||||
csrfProtection(req, res, next);
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(403);
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
expect(res.json).toHaveBeenCalledWith({
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
error: 'Invalid CSRF token',
|
error: "Invalid CSRF token",
|
||||||
code: 'CSRF_TOKEN_MISMATCH'
|
code: "CSRF_TOKEN_MISMATCH",
|
||||||
});
|
});
|
||||||
expect(next).not.toHaveBeenCalled();
|
expect(next).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 403 when no cookie token provided', () => {
|
it("should return 403 when no cookie token provided", () => {
|
||||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
req.headers["x-csrf-token"] = "mock-token-123";
|
||||||
req.cookies = {};
|
req.cookies = {};
|
||||||
|
|
||||||
csrfProtection(req, res, next);
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(403);
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
expect(res.json).toHaveBeenCalledWith({
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
error: 'Invalid CSRF token',
|
error: "Invalid CSRF token",
|
||||||
code: 'CSRF_TOKEN_MISMATCH'
|
code: "CSRF_TOKEN_MISMATCH",
|
||||||
});
|
});
|
||||||
expect(next).not.toHaveBeenCalled();
|
expect(next).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 403 when cookies object is missing', () => {
|
it("should return 403 when cookies object is missing", () => {
|
||||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
req.headers["x-csrf-token"] = "mock-token-123";
|
||||||
req.cookies = undefined;
|
req.cookies = undefined;
|
||||||
|
|
||||||
csrfProtection(req, res, next);
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(403);
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
expect(res.json).toHaveBeenCalledWith({
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
error: 'Invalid CSRF token',
|
error: "Invalid CSRF token",
|
||||||
code: 'CSRF_TOKEN_MISMATCH'
|
code: "CSRF_TOKEN_MISMATCH",
|
||||||
});
|
});
|
||||||
expect(next).not.toHaveBeenCalled();
|
expect(next).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 403 when both tokens are missing', () => {
|
it("should return 403 when both tokens are missing", () => {
|
||||||
req.cookies = {};
|
req.cookies = {};
|
||||||
|
|
||||||
csrfProtection(req, res, next);
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(403);
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
expect(res.json).toHaveBeenCalledWith({
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
error: 'Invalid CSRF token',
|
error: "Invalid CSRF token",
|
||||||
code: 'CSRF_TOKEN_MISMATCH'
|
code: "CSRF_TOKEN_MISMATCH",
|
||||||
});
|
});
|
||||||
expect(next).not.toHaveBeenCalled();
|
expect(next).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Token mismatch', () => {
|
describe("Token mismatch", () => {
|
||||||
it('should return 403 when tokens do not match', () => {
|
it("should return 403 when tokens do not match", () => {
|
||||||
req.headers['x-csrf-token'] = 'token-from-header';
|
req.headers["x-csrf-token"] = "token-from-header";
|
||||||
req.cookies = { 'csrf-token': 'token-from-cookie' };
|
req.cookies = { "csrf-token": "token-from-cookie" };
|
||||||
|
|
||||||
csrfProtection(req, res, next);
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(403);
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
expect(res.json).toHaveBeenCalledWith({
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
error: 'Invalid CSRF token',
|
error: "Invalid CSRF token",
|
||||||
code: 'CSRF_TOKEN_MISMATCH'
|
code: "CSRF_TOKEN_MISMATCH",
|
||||||
});
|
});
|
||||||
expect(next).not.toHaveBeenCalled();
|
expect(next).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 403 when header token is empty but cookie exists', () => {
|
it("should return 403 when header token is empty but cookie exists", () => {
|
||||||
req.headers['x-csrf-token'] = '';
|
req.headers["x-csrf-token"] = "";
|
||||||
req.cookies = { 'csrf-token': 'mock-token-123' };
|
req.cookies = { "csrf-token": "mock-token-123" };
|
||||||
|
|
||||||
csrfProtection(req, res, next);
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(403);
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
expect(res.json).toHaveBeenCalledWith({
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
error: 'Invalid CSRF token',
|
error: "Invalid CSRF token",
|
||||||
code: 'CSRF_TOKEN_MISMATCH'
|
code: "CSRF_TOKEN_MISMATCH",
|
||||||
});
|
});
|
||||||
expect(next).not.toHaveBeenCalled();
|
expect(next).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 403 when cookie token is empty but header exists', () => {
|
it("should return 403 when cookie token is empty but header exists", () => {
|
||||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
req.headers["x-csrf-token"] = "mock-token-123";
|
||||||
req.cookies = { 'csrf-token': '' };
|
req.cookies = { "csrf-token": "" };
|
||||||
|
|
||||||
csrfProtection(req, res, next);
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(403);
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
expect(res.json).toHaveBeenCalledWith({
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
error: 'Invalid CSRF token',
|
error: "Invalid CSRF token",
|
||||||
code: 'CSRF_TOKEN_MISMATCH'
|
code: "CSRF_TOKEN_MISMATCH",
|
||||||
});
|
});
|
||||||
expect(next).not.toHaveBeenCalled();
|
expect(next).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Token verification', () => {
|
describe("Token verification", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
req.headers["x-csrf-token"] = "mock-token-123";
|
||||||
req.cookies = { '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);
|
mockTokensInstance.verify.mockReturnValue(false);
|
||||||
|
|
||||||
csrfProtection(req, res, next);
|
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.status).toHaveBeenCalledWith(403);
|
||||||
expect(res.json).toHaveBeenCalledWith({
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
error: 'Invalid CSRF token',
|
error: "Invalid CSRF token",
|
||||||
code: 'CSRF_TOKEN_INVALID'
|
code: "CSRF_TOKEN_INVALID",
|
||||||
});
|
});
|
||||||
expect(next).not.toHaveBeenCalled();
|
expect(next).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call next when token verification succeeds', () => {
|
it("should call next when token verification succeeds", () => {
|
||||||
mockTokensInstance.verify.mockReturnValue(true);
|
mockTokensInstance.verify.mockReturnValue(true);
|
||||||
|
|
||||||
csrfProtection(req, res, next);
|
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(next).toHaveBeenCalled();
|
||||||
expect(res.status).not.toHaveBeenCalled();
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Edge cases', () => {
|
describe("Edge cases", () => {
|
||||||
it('should handle case-insensitive HTTP methods', () => {
|
it("should handle case-insensitive HTTP methods", () => {
|
||||||
req.method = 'post';
|
req.method = "post";
|
||||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
req.headers["x-csrf-token"] = "mock-token-123";
|
||||||
req.cookies = { 'csrf-token': 'mock-token-123' };
|
req.cookies = { "csrf-token": "mock-token-123" };
|
||||||
|
|
||||||
csrfProtection(req, res, next);
|
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(next).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle PUT requests', () => {
|
it("should handle PUT requests", () => {
|
||||||
req.method = 'PUT';
|
req.method = "PUT";
|
||||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
req.headers["x-csrf-token"] = "mock-token-123";
|
||||||
req.cookies = { 'csrf-token': 'mock-token-123' };
|
req.cookies = { "csrf-token": "mock-token-123" };
|
||||||
|
|
||||||
csrfProtection(req, res, next);
|
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(next).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle DELETE requests', () => {
|
it("should handle DELETE requests", () => {
|
||||||
req.method = 'DELETE';
|
req.method = "DELETE";
|
||||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
req.headers["x-csrf-token"] = "mock-token-123";
|
||||||
req.cookies = { 'csrf-token': 'mock-token-123' };
|
req.cookies = { "csrf-token": "mock-token-123" };
|
||||||
|
|
||||||
csrfProtection(req, res, next);
|
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(next).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle PATCH requests', () => {
|
it("should handle PATCH requests", () => {
|
||||||
req.method = 'PATCH';
|
req.method = "PATCH";
|
||||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
req.headers["x-csrf-token"] = "mock-token-123";
|
||||||
req.cookies = { 'csrf-token': 'mock-token-123' };
|
req.cookies = { "csrf-token": "mock-token-123" };
|
||||||
|
|
||||||
csrfProtection(req, res, next);
|
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(next).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('generateCSRFToken', () => {
|
describe("generateCSRFToken", () => {
|
||||||
it('should generate token and set cookie with proper options', () => {
|
it("should generate token and set cookie with proper options", () => {
|
||||||
process.env.NODE_ENV = 'production';
|
process.env.NODE_ENV = "production";
|
||||||
|
|
||||||
generateCSRFToken(req, res, next);
|
generateCSRFToken(req, res, next);
|
||||||
|
|
||||||
expect(mockTokensInstance.create).toHaveBeenCalledWith('mock-secret');
|
expect(mockTokensInstance.create).toHaveBeenCalledWith(
|
||||||
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
process.env.CSRF_SECRET,
|
||||||
|
);
|
||||||
|
expect(res.cookie).toHaveBeenCalledWith("csrf-token", "mock-token-123", {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: true,
|
secure: true,
|
||||||
sameSite: 'strict',
|
sameSite: "strict",
|
||||||
maxAge: 60 * 60 * 1000
|
maxAge: 60 * 60 * 1000,
|
||||||
});
|
});
|
||||||
expect(next).toHaveBeenCalled();
|
expect(next).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set secure flag to false in dev environment', () => {
|
it("should set secure flag to false in dev environment", () => {
|
||||||
process.env.NODE_ENV = 'dev';
|
process.env.NODE_ENV = "dev";
|
||||||
|
|
||||||
generateCSRFToken(req, res, next);
|
generateCSRFToken(req, res, next);
|
||||||
|
|
||||||
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
expect(res.cookie).toHaveBeenCalledWith("csrf-token", "mock-token-123", {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
sameSite: 'strict',
|
sameSite: "strict",
|
||||||
maxAge: 60 * 60 * 1000
|
maxAge: 60 * 60 * 1000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set secure flag to true in non-dev environment', () => {
|
it("should set secure flag to true in non-dev environment", () => {
|
||||||
process.env.NODE_ENV = 'production';
|
process.env.NODE_ENV = "production";
|
||||||
|
|
||||||
generateCSRFToken(req, res, next);
|
generateCSRFToken(req, res, next);
|
||||||
|
|
||||||
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
expect(res.cookie).toHaveBeenCalledWith("csrf-token", "mock-token-123", {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: true,
|
secure: true,
|
||||||
sameSite: 'strict',
|
sameSite: "strict",
|
||||||
maxAge: 60 * 60 * 1000
|
maxAge: 60 * 60 * 1000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set token in response header', () => {
|
it("should set token in response header", () => {
|
||||||
generateCSRFToken(req, res, next);
|
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);
|
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);
|
generateCSRFToken(req, res, next);
|
||||||
|
|
||||||
expect(next).toHaveBeenCalled();
|
expect(next).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle test environment', () => {
|
it("should handle test environment", () => {
|
||||||
process.env.NODE_ENV = 'test';
|
process.env.NODE_ENV = "test";
|
||||||
|
|
||||||
generateCSRFToken(req, res, next);
|
generateCSRFToken(req, res, next);
|
||||||
|
|
||||||
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
expect(res.cookie).toHaveBeenCalledWith("csrf-token", "mock-token-123", {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: true,
|
secure: false,
|
||||||
sameSite: 'strict',
|
sameSite: "strict",
|
||||||
maxAge: 60 * 60 * 1000
|
maxAge: 60 * 60 * 1000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle undefined NODE_ENV', () => {
|
it("should handle undefined NODE_ENV", () => {
|
||||||
delete process.env.NODE_ENV;
|
delete process.env.NODE_ENV;
|
||||||
|
|
||||||
generateCSRFToken(req, res, next);
|
generateCSRFToken(req, res, next);
|
||||||
|
|
||||||
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
expect(res.cookie).toHaveBeenCalledWith("csrf-token", "mock-token-123", {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: true,
|
secure: false,
|
||||||
sameSite: 'strict',
|
sameSite: "strict",
|
||||||
maxAge: 60 * 60 * 1000
|
maxAge: 60 * 60 * 1000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getCSRFToken', () => {
|
describe("getCSRFToken", () => {
|
||||||
it('should generate token and return it in response', () => {
|
it("should generate token and return it in response", () => {
|
||||||
process.env.NODE_ENV = 'production';
|
process.env.NODE_ENV = "production";
|
||||||
|
|
||||||
getCSRFToken(req, res);
|
getCSRFToken(req, res);
|
||||||
|
|
||||||
expect(mockTokensInstance.create).toHaveBeenCalledWith('mock-secret');
|
expect(mockTokensInstance.create).toHaveBeenCalledWith(
|
||||||
|
process.env.CSRF_SECRET,
|
||||||
|
);
|
||||||
expect(res.status).toHaveBeenCalledWith(204);
|
expect(res.status).toHaveBeenCalledWith(204);
|
||||||
expect(res.send).toHaveBeenCalled();
|
expect(res.send).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set token in cookie with proper options', () => {
|
it("should set token in cookie with proper options", () => {
|
||||||
process.env.NODE_ENV = 'production';
|
process.env.NODE_ENV = "production";
|
||||||
|
|
||||||
getCSRFToken(req, res);
|
getCSRFToken(req, res);
|
||||||
|
|
||||||
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
expect(res.cookie).toHaveBeenCalledWith("csrf-token", "mock-token-123", {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: true,
|
secure: true,
|
||||||
sameSite: 'strict',
|
sameSite: "strict",
|
||||||
maxAge: 60 * 60 * 1000
|
maxAge: 60 * 60 * 1000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set secure flag to false in dev environment', () => {
|
it("should set secure flag to false in dev environment", () => {
|
||||||
process.env.NODE_ENV = 'dev';
|
process.env.NODE_ENV = "dev";
|
||||||
|
|
||||||
getCSRFToken(req, res);
|
getCSRFToken(req, res);
|
||||||
|
|
||||||
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
expect(res.cookie).toHaveBeenCalledWith("csrf-token", "mock-token-123", {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
sameSite: 'strict',
|
sameSite: "strict",
|
||||||
maxAge: 60 * 60 * 1000
|
maxAge: 60 * 60 * 1000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set secure flag to true in production environment', () => {
|
it("should set secure flag to true in production environment", () => {
|
||||||
process.env.NODE_ENV = 'production';
|
process.env.NODE_ENV = "production";
|
||||||
|
|
||||||
getCSRFToken(req, res);
|
getCSRFToken(req, res);
|
||||||
|
|
||||||
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
expect(res.cookie).toHaveBeenCalledWith("csrf-token", "mock-token-123", {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: true,
|
secure: true,
|
||||||
sameSite: 'strict',
|
sameSite: "strict",
|
||||||
maxAge: 60 * 60 * 1000
|
maxAge: 60 * 60 * 1000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle test environment', () => {
|
it("should handle test environment", () => {
|
||||||
process.env.NODE_ENV = 'test';
|
process.env.NODE_ENV = "test";
|
||||||
|
|
||||||
getCSRFToken(req, res);
|
getCSRFToken(req, res);
|
||||||
|
|
||||||
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
expect(res.cookie).toHaveBeenCalledWith("csrf-token", "mock-token-123", {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: true,
|
secure: false,
|
||||||
sameSite: 'strict',
|
sameSite: "strict",
|
||||||
maxAge: 60 * 60 * 1000
|
maxAge: 60 * 60 * 1000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should generate new token each time', () => {
|
it("should generate new token each time", () => {
|
||||||
mockTokensInstance.create
|
mockTokensInstance.create
|
||||||
.mockReturnValueOnce('token-1')
|
.mockReturnValueOnce("token-1")
|
||||||
.mockReturnValueOnce('token-2');
|
.mockReturnValueOnce("token-2");
|
||||||
|
|
||||||
getCSRFToken(req, res);
|
getCSRFToken(req, res);
|
||||||
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'token-1', expect.any(Object));
|
expect(res.cookie).toHaveBeenCalledWith(
|
||||||
expect(res.set).toHaveBeenCalledWith('X-CSRF-Token', 'token-1');
|
"csrf-token",
|
||||||
|
"token-1",
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
expect(res.set).toHaveBeenCalledWith("X-CSRF-Token", "token-1");
|
||||||
|
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
getCSRFToken(req, res);
|
getCSRFToken(req, res);
|
||||||
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'token-2', expect.any(Object));
|
expect(res.cookie).toHaveBeenCalledWith(
|
||||||
expect(res.set).toHaveBeenCalledWith('X-CSRF-Token', 'token-2');
|
"csrf-token",
|
||||||
|
"token-2",
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
expect(res.set).toHaveBeenCalledWith("X-CSRF-Token", "token-2");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Integration scenarios', () => {
|
describe("Integration scenarios", () => {
|
||||||
it('should handle complete CSRF flow', () => {
|
it("should handle complete CSRF flow", () => {
|
||||||
// First, generate a token
|
// First, generate a token
|
||||||
generateCSRFToken(req, res, next);
|
generateCSRFToken(req, res, next);
|
||||||
const generatedToken = res.locals.csrfToken;
|
const generatedToken = res.locals.csrfToken;
|
||||||
@@ -487,9 +514,9 @@ describe('CSRF Middleware', () => {
|
|||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|
||||||
// Now test protection with the generated token
|
// Now test protection with the generated token
|
||||||
req.method = 'POST';
|
req.method = "POST";
|
||||||
req.headers['x-csrf-token'] = generatedToken;
|
req.headers["x-csrf-token"] = generatedToken;
|
||||||
req.cookies = { 'csrf-token': generatedToken };
|
req.cookies = { "csrf-token": generatedToken };
|
||||||
|
|
||||||
csrfProtection(req, res, next);
|
csrfProtection(req, res, next);
|
||||||
|
|
||||||
@@ -497,16 +524,16 @@ describe('CSRF Middleware', () => {
|
|||||||
expect(res.status).not.toHaveBeenCalled();
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle token generation endpoint flow', () => {
|
it("should handle token generation endpoint flow", () => {
|
||||||
getCSRFToken(req, res);
|
getCSRFToken(req, res);
|
||||||
|
|
||||||
const cookieCall = res.cookie.mock.calls[0];
|
const cookieCall = res.cookie.mock.calls[0];
|
||||||
const headerCall = res.set.mock.calls[0];
|
const headerCall = res.set.mock.calls[0];
|
||||||
|
|
||||||
expect(cookieCall[0]).toBe('csrf-token');
|
expect(cookieCall[0]).toBe("csrf-token");
|
||||||
expect(cookieCall[1]).toBe('mock-token-123');
|
expect(cookieCall[1]).toBe("mock-token-123");
|
||||||
expect(headerCall[0]).toBe('X-CSRF-Token');
|
expect(headerCall[0]).toBe("X-CSRF-Token");
|
||||||
expect(headerCall[1]).toBe('mock-token-123');
|
expect(headerCall[1]).toBe("mock-token-123");
|
||||||
expect(res.status).toHaveBeenCalledWith(204);
|
expect(res.status).toHaveBeenCalledWith(204);
|
||||||
expect(res.send).toHaveBeenCalled();
|
expect(res.send).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
355
backend/tests/unit/middleware/stepUpAuth.test.js
Normal file
355
backend/tests/unit/middleware/stepUpAuth.test.js
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,7 +13,15 @@ jest.mock('express-validator', () => ({
|
|||||||
trim: jest.fn().mockReturnThis(),
|
trim: jest.fn().mockReturnThis(),
|
||||||
optional: jest.fn().mockReturnThis(),
|
optional: jest.fn().mockReturnThis(),
|
||||||
isMobilePhone: 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()
|
validationResult: jest.fn()
|
||||||
}));
|
}));
|
||||||
@@ -38,7 +46,16 @@ const {
|
|||||||
validateLogin,
|
validateLogin,
|
||||||
validateGoogleAuth,
|
validateGoogleAuth,
|
||||||
validateProfileUpdate,
|
validateProfileUpdate,
|
||||||
validatePasswordChange
|
validatePasswordChange,
|
||||||
|
validateForgotPassword,
|
||||||
|
validateResetPassword,
|
||||||
|
validateVerifyResetToken,
|
||||||
|
validateFeedback,
|
||||||
|
validateCoordinatesQuery,
|
||||||
|
validateCoordinatesBody,
|
||||||
|
validateTotpCode,
|
||||||
|
validateEmailOtp,
|
||||||
|
validateRecoveryCode
|
||||||
} = require('../../../middleware/validation');
|
} = require('../../../middleware/validation');
|
||||||
|
|
||||||
describe('Validation Middleware', () => {
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
@@ -3,6 +3,27 @@ const crypto = require('crypto');
|
|||||||
// Mock crypto module
|
// Mock crypto module
|
||||||
jest.mock('crypto');
|
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
|
// Mock the entire models module
|
||||||
jest.mock('../../../models', () => {
|
jest.mock('../../../models', () => {
|
||||||
const mockUser = {
|
const mockUser = {
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ describe('Auth Routes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(response.status).toBe(401);
|
expect(response.status).toBe(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 invalid password', async () => {
|
it('should reject login with invalid password', async () => {
|
||||||
@@ -316,7 +316,7 @@ describe('Auth Routes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(response.status).toBe(401);
|
expect(response.status).toBe(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.');
|
||||||
expect(mockUser.incLoginAttempts).toHaveBeenCalled();
|
expect(mockUser.incLoginAttempts).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user