Compare commits
112 Commits
f3a356d64b
...
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 | ||
|
|
76102d48a9 | ||
|
|
493921b723 | ||
|
|
6853ae264c | ||
|
|
e9bc87da99 | ||
|
|
b89a0e3de7 | ||
|
|
4209dcc8fc | ||
|
|
bc01c818aa | ||
|
|
0104f369a9 | ||
|
|
0682494ee0 | ||
|
|
fe38ef430a | ||
|
|
9e41f328e0 | ||
|
|
fd2312fe47 | ||
|
|
3d0e553620 | ||
|
|
f66dccdfa3 | ||
|
|
3ff98fbe1e | ||
|
|
1b4e86be29 | ||
|
|
807082eebf | ||
|
|
3e31b9d08b | ||
|
|
e3acf45ba0 | ||
|
|
4bb4e7bcb6 | ||
|
|
6cf8a009ff | ||
|
|
546c881701 | ||
|
|
7dd3aff0f8 | ||
|
|
ac1e22f194 | ||
|
|
e153614993 | ||
|
|
2e18137b5b | ||
|
|
36cf5b65fa | ||
|
|
4f85243815 | ||
|
|
76e4039ba8 | ||
|
|
b02ec19d5c | ||
|
|
2a32470758 | ||
|
|
5ec22c2a5b | ||
|
|
426f974ed3 | ||
|
|
347f709f72 | ||
|
|
07e5a2a320 | ||
|
|
955517347e | ||
|
|
bd1bd5014c | ||
|
|
4e0a4ef019 | ||
|
|
4b4584bc0f | ||
|
|
996e815d57 | ||
|
|
38e0b6a16d | ||
|
|
27a7b641dd | ||
|
|
372ab093ef | ||
|
|
5e01bb8cff | ||
|
|
55e08e14b8 | ||
|
|
3f319bfdd0 | ||
|
|
25bbf5d20b | ||
|
|
1dee5232a0 | ||
|
|
763945fef4 | ||
|
|
b0268a2fb7 | ||
|
|
11593606aa | ||
|
|
f2d3aac029 | ||
|
|
fab79e64ee | ||
|
|
8b10103ae4 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -17,6 +17,7 @@ node_modules/
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.dev
|
||||
.mcp.json
|
||||
.claude
|
||||
|
||||
@@ -65,3 +66,9 @@ frontend/.env.local
|
||||
# Uploads
|
||||
uploads/
|
||||
temp/
|
||||
|
||||
# Infrastructure CDK
|
||||
infrastructure/cdk/dist/
|
||||
infrastructure/cdk/cdk.out/
|
||||
infrastructure/cdk/*.js
|
||||
infrastructure/cdk/*.d.ts
|
||||
113
README.md
113
README.md
@@ -1,112 +1 @@
|
||||
# Rentall App
|
||||
|
||||
A full-stack marketplace application for renting items, built with React and Node.js.
|
||||
|
||||
## Features
|
||||
|
||||
- **User Authentication**: Secure JWT-based authentication
|
||||
- **Item Listings**: Create, edit, and manage rental items
|
||||
- **Smart Search**: Browse and filter available items
|
||||
- **Availability Calendar**: Visual calendar for managing item availability
|
||||
- **Rental Requests**: Accept or reject rental requests with custom reasons
|
||||
- **Delivery Options**: Support for pickup, delivery, and in-place use
|
||||
- **User Profiles**: Manage profile information and view rental statistics
|
||||
- **Responsive Design**: Mobile-friendly interface with Bootstrap
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Frontend
|
||||
- React with TypeScript
|
||||
- React Router for navigation
|
||||
- Bootstrap for styling
|
||||
- Axios for API calls
|
||||
- Google Places API for address autocomplete
|
||||
|
||||
### Backend
|
||||
- Node.js with Express
|
||||
- SQLite database with Sequelize ORM
|
||||
- JWT for authentication
|
||||
- Bcrypt for password hashing
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
- Node.js (v14 or higher)
|
||||
- npm or yarn
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone the repository
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/rentall-app.git
|
||||
cd rentall-app
|
||||
```
|
||||
|
||||
2. Install backend dependencies
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Set up backend environment variables
|
||||
Create a `.env` file in the backend directory:
|
||||
```
|
||||
JWT_SECRET=your_jwt_secret_here
|
||||
PORT=5001
|
||||
```
|
||||
|
||||
4. Install frontend dependencies
|
||||
```bash
|
||||
cd ../frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
5. Set up frontend environment variables
|
||||
Create a `.env` file in the frontend directory:
|
||||
```
|
||||
REACT_APP_API_URL=http://localhost:5001
|
||||
REACT_APP_GOOGLE_MAPS_API_KEY=your_google_maps_api_key
|
||||
```
|
||||
|
||||
### Running the Application
|
||||
|
||||
1. Start the backend server
|
||||
```bash
|
||||
cd backend
|
||||
npm start
|
||||
```
|
||||
|
||||
2. In a new terminal, start the frontend
|
||||
```bash
|
||||
cd frontend
|
||||
npm start
|
||||
```
|
||||
|
||||
The application will be available at `http://localhost:3000`
|
||||
|
||||
## Key Features Explained
|
||||
|
||||
### Item Management
|
||||
- Create listings with multiple images, pricing options, and delivery methods
|
||||
- Set availability using an intuitive calendar interface
|
||||
- Manage rental rules and requirements
|
||||
|
||||
### Rental Process
|
||||
- Browse available items with search and filter options
|
||||
- Select rental dates with calendar interface
|
||||
- Secure payment information collection
|
||||
- Real-time rental request notifications
|
||||
|
||||
### User Dashboard
|
||||
- View and manage your listings
|
||||
- Track rental requests and accepted rentals
|
||||
- Monitor rental statistics
|
||||
- Update profile information
|
||||
|
||||
## Contributing
|
||||
|
||||
Feel free to submit issues and enhancement requests!
|
||||
|
||||
## License
|
||||
|
||||
This project is open source and available under the MIT License.
|
||||
# Village Share
|
||||
|
||||
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@@ -1,6 +1,5 @@
|
||||
node_modules/
|
||||
.env
|
||||
.env.*
|
||||
uploads/
|
||||
*.log
|
||||
.DS_Store
|
||||
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() {
|
||||
const config = {
|
||||
region: process.env.AWS_REGION || "us-east-1",
|
||||
region: process.env.AWS_REGION,
|
||||
};
|
||||
|
||||
const credentials = getAWSCredentials();
|
||||
|
||||
@@ -8,7 +8,7 @@ if (!process.env.DB_NAME && process.env.NODE_ENV) {
|
||||
const result = dotenv.config({ path: envFile });
|
||||
if (result.error && process.env.NODE_ENV !== "production") {
|
||||
console.warn(
|
||||
`Warning: Could not load ${envFile}, using existing environment variables`
|
||||
`Warning: Could not load ${envFile}, using existing environment variables`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ const dbConfig = {
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT || 5432,
|
||||
port: process.env.DB_PORT,
|
||||
dialect: "postgres",
|
||||
logging: false,
|
||||
pool: {
|
||||
@@ -34,7 +34,6 @@ const dbConfig = {
|
||||
// Configuration for Sequelize CLI (supports multiple environments)
|
||||
// All environments use the same configuration from environment variables
|
||||
const cliConfig = {
|
||||
development: dbConfig,
|
||||
dev: dbConfig,
|
||||
test: dbConfig,
|
||||
qa: dbConfig,
|
||||
@@ -53,7 +52,7 @@ const sequelize = new Sequelize(
|
||||
dialect: dbConfig.dialect,
|
||||
logging: dbConfig.logging,
|
||||
pool: dbConfig.pool,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Export the sequelize instance as default (for backward compatibility)
|
||||
|
||||
14
backend/config/imageLimits.js
Normal file
14
backend/config/imageLimits.js
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Image upload limits configuration
|
||||
* Keep in sync with frontend/src/config/imageLimits.ts
|
||||
*/
|
||||
const IMAGE_LIMITS = {
|
||||
items: 10,
|
||||
forum: 10,
|
||||
conditionChecks: 10,
|
||||
damageReports: 10,
|
||||
profile: 1,
|
||||
messages: 1,
|
||||
};
|
||||
|
||||
module.exports = { IMAGE_LIMITS };
|
||||
@@ -1,18 +1,40 @@
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
projects: [
|
||||
{
|
||||
displayName: 'unit',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/tests/unit/**/*.test.js'],
|
||||
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
|
||||
testTimeout: 10000,
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!(@scure|@otplib|otplib|@noble)/)'
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'integration',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/tests/integration/**/*.test.js'],
|
||||
setupFilesAfterEnv: ['<rootDir>/tests/integration-setup.js'],
|
||||
testTimeout: 30000,
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!(@scure|@otplib|otplib|@noble)/)'
|
||||
],
|
||||
},
|
||||
],
|
||||
// Run tests sequentially to avoid module cache conflicts between unit and integration tests
|
||||
maxWorkers: 1,
|
||||
coverageDirectory: 'coverage',
|
||||
collectCoverageFrom: [
|
||||
'**/*.js',
|
||||
'!**/node_modules/**',
|
||||
'!**/coverage/**',
|
||||
'!**/tests/**',
|
||||
'!jest.config.js'
|
||||
'!**/migrations/**',
|
||||
'!**/scripts/**',
|
||||
'!jest.config.js',
|
||||
'!babel.config.js',
|
||||
],
|
||||
coverageReporters: ['text', 'lcov', 'html'],
|
||||
testMatch: ['**/tests/**/*.test.js'],
|
||||
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
|
||||
forceExit: true,
|
||||
testTimeout: 10000,
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
lines: 80,
|
||||
|
||||
@@ -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,90 +0,0 @@
|
||||
const cron = require("node-cron");
|
||||
const PayoutService = require("../services/payoutService");
|
||||
|
||||
const paymentsSchedule = "0 * * * *"; // Run every hour at minute 0
|
||||
const retrySchedule = "0 7 * * *"; // Retry failed payouts once daily at 7 AM
|
||||
|
||||
class PayoutProcessor {
|
||||
static startScheduledPayouts() {
|
||||
console.log("Starting automated payout processor...");
|
||||
|
||||
const payoutJob = cron.schedule(
|
||||
paymentsSchedule,
|
||||
async () => {
|
||||
console.log("Running scheduled payout processing...");
|
||||
|
||||
try {
|
||||
const results = await PayoutService.processAllEligiblePayouts();
|
||||
|
||||
if (results.totalProcessed > 0) {
|
||||
console.log(
|
||||
`Payout batch completed: ${results.successful.length} successful, ${results.failed.length} failed`
|
||||
);
|
||||
|
||||
// Log any failures for monitoring
|
||||
if (results.failed.length > 0) {
|
||||
console.warn("Failed payouts:", results.failed);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in scheduled payout processing:", error);
|
||||
}
|
||||
},
|
||||
{
|
||||
scheduled: false,
|
||||
timezone: "America/New_York",
|
||||
}
|
||||
);
|
||||
|
||||
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 jobs
|
||||
payoutJob.start();
|
||||
retryJob.start();
|
||||
|
||||
console.log("Payout processor jobs scheduled:");
|
||||
console.log("- Hourly payout processing: " + paymentsSchedule);
|
||||
console.log("- Daily retry processing: " + retrySchedule);
|
||||
|
||||
return {
|
||||
payoutJob,
|
||||
retryJob,
|
||||
|
||||
stop() {
|
||||
payoutJob.stop();
|
||||
retryJob.stop();
|
||||
console.log("Payout processor jobs stopped");
|
||||
},
|
||||
|
||||
getStatus() {
|
||||
return {
|
||||
payoutJobRunning: payoutJob.getStatus() === "scheduled",
|
||||
retryJobRunning: retryJob.getStatus() === "scheduled",
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PayoutProcessor;
|
||||
@@ -1,101 +0,0 @@
|
||||
const cron = require("node-cron");
|
||||
const { Rental } = require("../models");
|
||||
const { Op } = require("sequelize");
|
||||
const logger = require("../utils/logger");
|
||||
|
||||
const statusUpdateSchedule = "*/15 * * * *"; // Run every 15 minutes
|
||||
|
||||
class RentalStatusJob {
|
||||
static startScheduledStatusUpdates() {
|
||||
console.log("Starting automated rental status updates...");
|
||||
|
||||
const statusJob = cron.schedule(
|
||||
statusUpdateSchedule,
|
||||
async () => {
|
||||
try {
|
||||
await this.activateStartedRentals();
|
||||
} catch (error) {
|
||||
logger.error("Error in scheduled rental status update", {
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
scheduled: false,
|
||||
timezone: "America/New_York",
|
||||
}
|
||||
);
|
||||
|
||||
// Start the job
|
||||
statusJob.start();
|
||||
|
||||
console.log("Rental status job scheduled:");
|
||||
console.log("- Status updates every 15 minutes: " + statusUpdateSchedule);
|
||||
|
||||
return {
|
||||
statusJob,
|
||||
|
||||
stop() {
|
||||
statusJob.stop();
|
||||
console.log("Rental status job stopped");
|
||||
},
|
||||
|
||||
getStatus() {
|
||||
return {
|
||||
statusJobRunning: statusJob.getStatus() === "scheduled",
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static async activateStartedRentals() {
|
||||
try {
|
||||
const now = new Date();
|
||||
|
||||
// Find all confirmed rentals where start time has arrived
|
||||
const rentalsToActivate = await Rental.findAll({
|
||||
where: {
|
||||
status: "confirmed",
|
||||
startDateTime: {
|
||||
[Op.lte]: now,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (rentalsToActivate.length === 0) {
|
||||
return { activated: 0 };
|
||||
}
|
||||
|
||||
// Update all matching rentals to active status
|
||||
const rentalIds = rentalsToActivate.map((r) => r.id);
|
||||
const [updateCount] = await Rental.update(
|
||||
{ status: "active" },
|
||||
{
|
||||
where: {
|
||||
id: {
|
||||
[Op.in]: rentalIds,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
logger.info("Activated started rentals", {
|
||||
count: updateCount,
|
||||
rentalIds: rentalIds,
|
||||
});
|
||||
|
||||
console.log(`Activated ${updateCount} rentals that have started`);
|
||||
|
||||
return { activated: updateCount, rentalIds };
|
||||
} catch (error) {
|
||||
logger.error("Error activating started rentals", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RentalStatusJob;
|
||||
@@ -36,6 +36,34 @@ const apiLogger = (req, res, next) => {
|
||||
userId: req.user?.id || 'anonymous'
|
||||
};
|
||||
|
||||
// Parse response body for error responses to include error details
|
||||
if (res.statusCode >= 400) {
|
||||
let errorDetails = null;
|
||||
if (body) {
|
||||
try {
|
||||
const parsed = typeof body === 'string' ? JSON.parse(body) : body;
|
||||
// Extract error message, validation errors, or full response
|
||||
errorDetails = {
|
||||
error: parsed.error || parsed.message || null,
|
||||
errors: parsed.errors || null, // validation errors array
|
||||
details: parsed.details || null
|
||||
};
|
||||
// Remove null values
|
||||
Object.keys(errorDetails).forEach(key => {
|
||||
if (errorDetails[key] === null) delete errorDetails[key];
|
||||
});
|
||||
if (Object.keys(errorDetails).length > 0) {
|
||||
responseData.errorDetails = errorDetails;
|
||||
}
|
||||
} catch (e) {
|
||||
// Body is not JSON, include as string (truncated)
|
||||
if (typeof body === 'string' && body.length > 0) {
|
||||
responseData.errorDetails = { raw: body.substring(0, 500) };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (res.statusCode >= 400 && res.statusCode < 500) {
|
||||
// Don't log 401s for /users/profile - these are expected auth checks
|
||||
if (!(res.statusCode === 401 && req.url === '/profile')) {
|
||||
|
||||
@@ -14,7 +14,7 @@ const authenticateToken = async (req, res, next) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const decoded = jwt.verify(token, process.env.JWT_ACCESS_SECRET);
|
||||
const userId = decoded.id;
|
||||
|
||||
if (!userId) {
|
||||
@@ -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
|
||||
if (decoded.jwtVersion !== user.jwtVersion) {
|
||||
return res.status(401).json({
|
||||
@@ -78,7 +86,7 @@ const optionalAuth = async (req, res, next) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const decoded = jwt.verify(token, process.env.JWT_ACCESS_SECRET);
|
||||
const userId = decoded.id;
|
||||
|
||||
if (!userId) {
|
||||
@@ -93,6 +101,12 @@ const optionalAuth = async (req, res, 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
|
||||
if (decoded.jwtVersion !== user.jwtVersion) {
|
||||
req.user = null;
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
const csrf = require("csrf");
|
||||
const cookieParser = require("cookie-parser");
|
||||
const logger = require("../utils/logger");
|
||||
|
||||
// Initialize CSRF token generator
|
||||
const tokens = new csrf();
|
||||
|
||||
// Generate a secret for signing tokens
|
||||
const secret = tokens.secretSync();
|
||||
// Use persistent secret from environment variable to prevent token invalidation on restart
|
||||
const secret = process.env.CSRF_SECRET;
|
||||
|
||||
if (!secret) {
|
||||
const errorMsg = "CSRF_SECRET environment variable is required.";
|
||||
logger.error(errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
if (secret.length < 32) {
|
||||
const errorMsg = "CSRF_SECRET must be at least 32 characters for security";
|
||||
logger.error(errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
// CSRF middleware using double submit cookie pattern
|
||||
const csrfProtection = (req, res, next) => {
|
||||
@@ -15,8 +28,7 @@ const csrfProtection = (req, res, next) => {
|
||||
}
|
||||
|
||||
// Get token from header or body
|
||||
const token =
|
||||
req.headers["x-csrf-token"] || req.body.csrfToken || req.query.csrfToken;
|
||||
const token = req.headers["x-csrf-token"] || req.body.csrfToken;
|
||||
|
||||
// Get token from cookie
|
||||
const cookieToken = req.cookies && req.cookies["csrf-token"];
|
||||
@@ -47,7 +59,7 @@ const generateCSRFToken = (req, res, next) => {
|
||||
// Set token in cookie (httpOnly for security)
|
||||
res.cookie("csrf-token", token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV !== "dev",
|
||||
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
|
||||
sameSite: "strict",
|
||||
maxAge: 60 * 60 * 1000, // 1 hour
|
||||
});
|
||||
@@ -67,12 +79,13 @@ const getCSRFToken = (req, res) => {
|
||||
|
||||
res.cookie("csrf-token", token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV !== "dev",
|
||||
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
|
||||
sameSite: "strict",
|
||||
maxAge: 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
res.json({ csrfToken: token });
|
||||
res.set("X-CSRF-Token", token);
|
||||
res.status(204).send();
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const rateLimit = require("express-rate-limit");
|
||||
const logger = require("../utils/logger");
|
||||
|
||||
// General rate limiter for Maps API endpoints
|
||||
const createMapsRateLimiter = (windowMs, max, message) => {
|
||||
@@ -104,6 +105,28 @@ const burstProtection = createUserBasedRateLimiter(
|
||||
"Too many requests in a short period. Please slow down."
|
||||
);
|
||||
|
||||
// Upload presign rate limiter - 30 requests per minute
|
||||
const uploadPresignLimiter = createUserBasedRateLimiter(
|
||||
60 * 1000, // 1 minute window
|
||||
30, // 30 presign requests per minute per user
|
||||
"Too many upload requests. Please slow down."
|
||||
);
|
||||
|
||||
// Helper to create a rate limit handler that logs the event
|
||||
const createRateLimitHandler = (limiterName) => (req, res, next, options) => {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.warn('Rate limit exceeded', {
|
||||
limiter: limiterName,
|
||||
ip: req.ip,
|
||||
userId: req.user?.id || 'anonymous',
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
userAgent: req.get('User-Agent'),
|
||||
message: options.message?.error || 'Rate limit exceeded'
|
||||
});
|
||||
res.status(options.statusCode).json(options.message);
|
||||
};
|
||||
|
||||
// Authentication rate limiters
|
||||
const authRateLimiters = {
|
||||
// Login rate limiter - stricter to prevent brute force
|
||||
@@ -117,6 +140,7 @@ const authRateLimiters = {
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skipSuccessfulRequests: true, // Don't count successful logins
|
||||
handler: createRateLimitHandler('login'),
|
||||
}),
|
||||
|
||||
// Registration rate limiter
|
||||
@@ -129,6 +153,7 @@ const authRateLimiters = {
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
handler: createRateLimitHandler('register'),
|
||||
}),
|
||||
|
||||
// Password reset rate limiter
|
||||
@@ -141,6 +166,7 @@ const authRateLimiters = {
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
handler: createRateLimitHandler('passwordReset'),
|
||||
}),
|
||||
|
||||
// Alpha code validation rate limiter
|
||||
@@ -153,6 +179,20 @@ const authRateLimiters = {
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
handler: createRateLimitHandler('alphaCodeValidation'),
|
||||
}),
|
||||
|
||||
// Email verification rate limiter - protect against brute force on 6-digit codes
|
||||
emailVerification: rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 10, // 10 verification attempts per 15 minutes per IP
|
||||
message: {
|
||||
error: "Too many verification attempts. Please try again later.",
|
||||
retryAfter: 900,
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
handler: createRateLimitHandler('emailVerification'),
|
||||
}),
|
||||
|
||||
// General API rate limiter
|
||||
@@ -165,6 +205,58 @@ const authRateLimiters = {
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
handler: createRateLimitHandler('general'),
|
||||
}),
|
||||
|
||||
// Two-Factor Authentication rate limiters
|
||||
twoFactorVerification: rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 10, // 10 verification attempts per 15 minutes
|
||||
message: {
|
||||
error: "Too many verification attempts. Please try again later.",
|
||||
retryAfter: 900,
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skipSuccessfulRequests: true,
|
||||
handler: createRateLimitHandler('twoFactorVerification'),
|
||||
}),
|
||||
|
||||
twoFactorSetup: rateLimit({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 5, // 5 setup attempts per hour
|
||||
message: {
|
||||
error: "Too many setup attempts. Please try again later.",
|
||||
retryAfter: 3600,
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
handler: createRateLimitHandler('twoFactorSetup'),
|
||||
}),
|
||||
|
||||
recoveryCode: rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 3, // 3 recovery code attempts per 15 minutes
|
||||
message: {
|
||||
error: "Too many recovery code attempts. Please try again later.",
|
||||
retryAfter: 900,
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skipSuccessfulRequests: false, // Count all attempts for security
|
||||
handler: createRateLimitHandler('recoveryCode'),
|
||||
}),
|
||||
|
||||
emailOtpSend: rateLimit({
|
||||
windowMs: 10 * 60 * 1000, // 10 minutes
|
||||
max: 2, // 2 OTP sends per 10 minutes
|
||||
message: {
|
||||
error: "Please wait before requesting another code.",
|
||||
retryAfter: 600,
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
handler: createRateLimitHandler('emailOtpSend'),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -179,11 +271,21 @@ module.exports = {
|
||||
registerLimiter: authRateLimiters.register,
|
||||
passwordResetLimiter: authRateLimiters.passwordReset,
|
||||
alphaCodeValidationLimiter: authRateLimiters.alphaCodeValidation,
|
||||
emailVerificationLimiter: authRateLimiters.emailVerification,
|
||||
generalLimiter: authRateLimiters.general,
|
||||
|
||||
// Two-Factor Authentication rate limiters
|
||||
twoFactorVerificationLimiter: authRateLimiters.twoFactorVerification,
|
||||
twoFactorSetupLimiter: authRateLimiters.twoFactorSetup,
|
||||
recoveryCodeLimiter: authRateLimiters.recoveryCode,
|
||||
emailOtpSendLimiter: authRateLimiters.emailOtpSend,
|
||||
|
||||
// Burst protection
|
||||
burstProtection,
|
||||
|
||||
// Upload rate limiter
|
||||
uploadPresignLimiter,
|
||||
|
||||
// Utility functions
|
||||
createMapsRateLimiter,
|
||||
createUserBasedRateLimiter,
|
||||
|
||||
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,94 +0,0 @@
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
// Configure storage for profile images
|
||||
const profileImageStorage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
cb(null, path.join(__dirname, '../uploads/profiles'));
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
// Generate unique filename: uuid + original extension
|
||||
const uniqueId = uuidv4();
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `${uniqueId}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
// File filter to accept only images
|
||||
const imageFileFilter = (req, file, cb) => {
|
||||
// Accept images only
|
||||
const allowedMimes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
||||
if (allowedMimes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Invalid file type. Only JPEG, PNG, GIF and WebP images are allowed.'), false);
|
||||
}
|
||||
};
|
||||
|
||||
// Create multer upload middleware for profile images
|
||||
const uploadProfileImage = multer({
|
||||
storage: profileImageStorage,
|
||||
fileFilter: imageFileFilter,
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024 // 5MB limit
|
||||
}
|
||||
}).single('profileImage');
|
||||
|
||||
// Configure storage for message images
|
||||
const messageImageStorage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
cb(null, path.join(__dirname, '../uploads/messages'));
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
// Generate unique filename: uuid + original extension
|
||||
const uniqueId = uuidv4();
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `${uniqueId}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Create multer upload middleware for message images
|
||||
const uploadMessageImage = multer({
|
||||
storage: messageImageStorage,
|
||||
fileFilter: imageFileFilter,
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024 // 5MB limit
|
||||
}
|
||||
}).single('image');
|
||||
|
||||
// Configure storage for forum images
|
||||
const forumImageStorage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
cb(null, path.join(__dirname, '../uploads/forum'));
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
const uniqueId = uuidv4();
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `${uniqueId}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Factory function to create forum image upload middleware
|
||||
const createForumImageUpload = (maxFiles) => {
|
||||
return multer({
|
||||
storage: forumImageStorage,
|
||||
fileFilter: imageFileFilter,
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024 // 5MB limit per file
|
||||
}
|
||||
}).array('images', maxFiles);
|
||||
};
|
||||
|
||||
// Create multer upload middleware for forum post images (up to 5 images)
|
||||
const uploadForumPostImages = createForumImageUpload(5);
|
||||
|
||||
// Create multer upload middleware for forum comment images (up to 3 images)
|
||||
const uploadForumCommentImages = createForumImageUpload(3);
|
||||
|
||||
module.exports = {
|
||||
uploadProfileImage,
|
||||
uploadMessageImage,
|
||||
uploadForumPostImages,
|
||||
uploadForumCommentImages
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
const { body, validationResult } = require("express-validator");
|
||||
const { body, query, validationResult } = require("express-validator");
|
||||
const DOMPurify = require("dompurify");
|
||||
const { JSDOM } = require("jsdom");
|
||||
|
||||
@@ -81,7 +81,7 @@ const validateRegistration = [
|
||||
.withMessage("Password must be between 8 and 128 characters")
|
||||
.matches(passwordStrengthRegex)
|
||||
.withMessage(
|
||||
"Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character"
|
||||
"Password does not meet requirements"
|
||||
)
|
||||
.custom((value) => {
|
||||
if (commonPasswords.includes(value.toLowerCase())) {
|
||||
@@ -275,7 +275,7 @@ const validateResetPassword = [
|
||||
.withMessage("Password must be between 8 and 128 characters")
|
||||
.matches(passwordStrengthRegex)
|
||||
.withMessage(
|
||||
"Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character"
|
||||
"Password does not meet requirements"
|
||||
)
|
||||
.custom((value) => {
|
||||
if (commonPasswords.includes(value.toLowerCase())) {
|
||||
@@ -316,6 +316,60 @@ const validateFeedback = [
|
||||
handleValidationErrors,
|
||||
];
|
||||
|
||||
// Coordinate validation for query parameters (e.g., location search)
|
||||
const validateCoordinatesQuery = [
|
||||
query("lat")
|
||||
.optional()
|
||||
.isFloat({ min: -90, max: 90 })
|
||||
.withMessage("Latitude must be between -90 and 90"),
|
||||
query("lng")
|
||||
.optional()
|
||||
.isFloat({ min: -180, max: 180 })
|
||||
.withMessage("Longitude must be between -180 and 180"),
|
||||
query("radius")
|
||||
.optional()
|
||||
.isFloat({ min: 0.1, max: 100 })
|
||||
.withMessage("Radius must be between 0.1 and 100 miles"),
|
||||
handleValidationErrors,
|
||||
];
|
||||
|
||||
// Coordinate validation for body parameters (e.g., user addresses, forum posts)
|
||||
const validateCoordinatesBody = [
|
||||
body("latitude")
|
||||
.optional()
|
||||
.isFloat({ min: -90, max: 90 })
|
||||
.withMessage("Latitude must be between -90 and 90"),
|
||||
body("longitude")
|
||||
.optional()
|
||||
.isFloat({ min: -180, max: 180 })
|
||||
.withMessage("Longitude must be between -180 and 180"),
|
||||
];
|
||||
|
||||
// Two-Factor Authentication validation
|
||||
const validateTotpCode = [
|
||||
body("code")
|
||||
.trim()
|
||||
.matches(/^\d{6}$/)
|
||||
.withMessage("TOTP code must be exactly 6 digits"),
|
||||
handleValidationErrors,
|
||||
];
|
||||
|
||||
const validateEmailOtp = [
|
||||
body("code")
|
||||
.trim()
|
||||
.matches(/^\d{6}$/)
|
||||
.withMessage("Email OTP must be exactly 6 digits"),
|
||||
handleValidationErrors,
|
||||
];
|
||||
|
||||
const validateRecoveryCode = [
|
||||
body("code")
|
||||
.trim()
|
||||
.matches(/^[A-Za-z0-9]{4}-[A-Za-z0-9]{4}$/i)
|
||||
.withMessage("Recovery code must be in format XXXX-XXXX"),
|
||||
handleValidationErrors,
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
sanitizeInput,
|
||||
handleValidationErrors,
|
||||
@@ -328,4 +382,10 @@ module.exports = {
|
||||
validateResetPassword,
|
||||
validateVerifyResetToken,
|
||||
validateFeedback,
|
||||
validateCoordinatesQuery,
|
||||
validateCoordinatesBody,
|
||||
// Two-Factor Authentication
|
||||
validateTotpCode,
|
||||
validateEmailOtp,
|
||||
validateRecoveryCode,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
// Change images column from VARCHAR(255)[] to TEXT[] to support longer URLs
|
||||
await queryInterface.changeColumn("Items", "images", {
|
||||
type: Sequelize.ARRAY(Sequelize.TEXT),
|
||||
defaultValue: [],
|
||||
});
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
// Revert to original VARCHAR(255)[]
|
||||
await queryInterface.changeColumn("Items", "images", {
|
||||
type: Sequelize.ARRAY(Sequelize.STRING),
|
||||
defaultValue: [],
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
// Change image/photo URL fields from VARCHAR(255) to TEXT to support longer URLs
|
||||
await Promise.all([
|
||||
queryInterface.changeColumn("Users", "profileImage", {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
}),
|
||||
queryInterface.changeColumn("Messages", "imagePath", {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
}),
|
||||
queryInterface.changeColumn("ConditionChecks", "photos", {
|
||||
type: Sequelize.ARRAY(Sequelize.TEXT),
|
||||
defaultValue: [],
|
||||
}),
|
||||
]);
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
// Revert to original VARCHAR(255)
|
||||
await Promise.all([
|
||||
queryInterface.changeColumn("Users", "profileImage", {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true,
|
||||
}),
|
||||
queryInterface.changeColumn("Messages", "imagePath", {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true,
|
||||
}),
|
||||
queryInterface.changeColumn("ConditionChecks", "photos", {
|
||||
type: Sequelize.ARRAY(Sequelize.STRING),
|
||||
defaultValue: [],
|
||||
}),
|
||||
]);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
// Rename image fields to consistent naming convention
|
||||
// Using TEXT type for all to support long URLs/paths
|
||||
await queryInterface.renameColumn("Items", "images", "imageFilenames");
|
||||
await queryInterface.renameColumn("Users", "profileImage", "imageFilename");
|
||||
await queryInterface.renameColumn("Messages", "imagePath", "imageFilename");
|
||||
await queryInterface.renameColumn("ConditionChecks", "photos", "imageFilenames");
|
||||
await queryInterface.renameColumn("ForumPosts", "images", "imageFilenames");
|
||||
await queryInterface.renameColumn("ForumComments", "images", "imageFilenames");
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
// Revert to original column names
|
||||
await queryInterface.renameColumn("Items", "imageFilenames", "images");
|
||||
await queryInterface.renameColumn("Users", "imageFilename", "profileImage");
|
||||
await queryInterface.renameColumn("Messages", "imageFilename", "imagePath");
|
||||
await queryInterface.renameColumn("ConditionChecks", "imageFilenames", "photos");
|
||||
await queryInterface.renameColumn("ForumPosts", "imageFilenames", "images");
|
||||
await queryInterface.renameColumn("ForumComments", "imageFilenames", "images");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.addColumn("Users", "verificationAttempts", {
|
||||
type: Sequelize.INTEGER,
|
||||
defaultValue: 0,
|
||||
allowNull: true,
|
||||
});
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeColumn("Users", "verificationAttempts");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.changeColumn("Messages", "content", {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
});
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
// First update any null content to empty string before reverting
|
||||
await queryInterface.sequelize.query(
|
||||
`UPDATE "Messages" SET content = '' WHERE content IS NULL`
|
||||
);
|
||||
await queryInterface.changeColumn("Messages", "content", {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: false,
|
||||
});
|
||||
},
|
||||
};
|
||||
20
backend/migrations/20251230000001-add-geospatial-index.js
Normal file
20
backend/migrations/20251230000001-add-geospatial-index.js
Normal file
@@ -0,0 +1,20 @@
|
||||
'use strict';
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Add index on latitude and longitude columns for faster geospatial queries
|
||||
// This improves performance of the bounding box pre-filter used in radius searches
|
||||
await queryInterface.addIndex('Items', ['latitude', 'longitude'], {
|
||||
name: 'idx_items_lat_lng',
|
||||
where: {
|
||||
latitude: { [Sequelize.Op.ne]: null },
|
||||
longitude: { [Sequelize.Op.ne]: null }
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.removeIndex('Items', 'idx_items_lat_lng');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.addColumn("Users", "stripePayoutsEnabled", {
|
||||
type: Sequelize.BOOLEAN,
|
||||
defaultValue: false,
|
||||
allowNull: true,
|
||||
});
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeColumn("Users", "stripePayoutsEnabled");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
// Add bankDepositStatus enum column
|
||||
await queryInterface.addColumn("Rentals", "bankDepositStatus", {
|
||||
type: Sequelize.ENUM("pending", "in_transit", "paid", "failed", "canceled"),
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
});
|
||||
|
||||
// Add bankDepositAt timestamp
|
||||
await queryInterface.addColumn("Rentals", "bankDepositAt", {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true,
|
||||
});
|
||||
|
||||
// Add stripePayoutId to track which Stripe payout included this transfer
|
||||
await queryInterface.addColumn("Rentals", "stripePayoutId", {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true,
|
||||
});
|
||||
|
||||
// Add bankDepositFailureCode for failed deposits
|
||||
await queryInterface.addColumn("Rentals", "bankDepositFailureCode", {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true,
|
||||
});
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeColumn("Rentals", "bankDepositFailureCode");
|
||||
await queryInterface.removeColumn("Rentals", "stripePayoutId");
|
||||
await queryInterface.removeColumn("Rentals", "bankDepositAt");
|
||||
await queryInterface.removeColumn("Rentals", "bankDepositStatus");
|
||||
|
||||
// Drop the enum type (PostgreSQL specific)
|
||||
await queryInterface.sequelize.query(
|
||||
'DROP TYPE IF EXISTS "enum_Rentals_bankDepositStatus";'
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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" },
|
||||
});
|
||||
```
|
||||
@@ -24,8 +24,8 @@ const ConditionCheck = sequelize.define("ConditionCheck", {
|
||||
),
|
||||
allowNull: false,
|
||||
},
|
||||
photos: {
|
||||
type: DataTypes.ARRAY(DataTypes.STRING),
|
||||
imageFilenames: {
|
||||
type: DataTypes.ARRAY(DataTypes.TEXT),
|
||||
defaultValue: [],
|
||||
},
|
||||
notes: {
|
||||
|
||||
@@ -39,7 +39,7 @@ const ForumComment = sequelize.define('ForumComment', {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false
|
||||
},
|
||||
images: {
|
||||
imageFilenames: {
|
||||
type: DataTypes.ARRAY(DataTypes.TEXT),
|
||||
allowNull: true,
|
||||
defaultValue: []
|
||||
|
||||
@@ -52,7 +52,7 @@ const ForumPost = sequelize.define('ForumPost', {
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
images: {
|
||||
imageFilenames: {
|
||||
type: DataTypes.ARRAY(DataTypes.TEXT),
|
||||
allowNull: true,
|
||||
defaultValue: []
|
||||
|
||||
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;
|
||||
@@ -82,8 +82,8 @@ const Item = sequelize.define("Item", {
|
||||
longitude: {
|
||||
type: DataTypes.DECIMAL(11, 8),
|
||||
},
|
||||
images: {
|
||||
type: DataTypes.ARRAY(DataTypes.STRING),
|
||||
imageFilenames: {
|
||||
type: DataTypes.ARRAY(DataTypes.TEXT),
|
||||
defaultValue: [],
|
||||
},
|
||||
isAvailable: {
|
||||
@@ -95,11 +95,11 @@ const Item = sequelize.define("Item", {
|
||||
},
|
||||
availableAfter: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: "09:00",
|
||||
defaultValue: "00:00",
|
||||
},
|
||||
availableBefore: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: "17:00",
|
||||
defaultValue: "23:00",
|
||||
},
|
||||
specifyTimesPerDay: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
@@ -108,13 +108,13 @@ const Item = sequelize.define("Item", {
|
||||
weeklyTimes: {
|
||||
type: DataTypes.JSONB,
|
||||
defaultValue: {
|
||||
sunday: { availableAfter: "09:00", availableBefore: "17:00" },
|
||||
monday: { availableAfter: "09:00", availableBefore: "17:00" },
|
||||
tuesday: { availableAfter: "09:00", availableBefore: "17:00" },
|
||||
wednesday: { availableAfter: "09:00", availableBefore: "17:00" },
|
||||
thursday: { availableAfter: "09:00", availableBefore: "17:00" },
|
||||
friday: { availableAfter: "09:00", availableBefore: "17:00" },
|
||||
saturday: { availableAfter: "09:00", availableBefore: "17:00" },
|
||||
sunday: { availableAfter: "00:00", availableBefore: "23:00" },
|
||||
monday: { availableAfter: "00:00", availableBefore: "23:00" },
|
||||
tuesday: { availableAfter: "00:00", availableBefore: "23:00" },
|
||||
wednesday: { availableAfter: "00:00", availableBefore: "23:00" },
|
||||
thursday: { availableAfter: "00:00", availableBefore: "23:00" },
|
||||
friday: { availableAfter: "00:00", availableBefore: "23:00" },
|
||||
saturday: { availableAfter: "00:00", availableBefore: "23:00" },
|
||||
},
|
||||
},
|
||||
ownerId: {
|
||||
|
||||
@@ -25,18 +25,26 @@ const Message = sequelize.define('Message', {
|
||||
},
|
||||
content: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
allowNull: true
|
||||
},
|
||||
isRead: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false
|
||||
},
|
||||
imagePath: {
|
||||
type: DataTypes.STRING,
|
||||
imageFilename: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
timestamps: true,
|
||||
validate: {
|
||||
contentOrImage() {
|
||||
const hasContent = this.content && this.content.trim().length > 0;
|
||||
if (!hasContent && !this.imageFilename) {
|
||||
throw new Error('Message must have content or an image');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = Message;
|
||||
@@ -67,11 +67,11 @@ const Rental = sequelize.define("Rental", {
|
||||
allowNull: false,
|
||||
},
|
||||
paymentStatus: {
|
||||
type: DataTypes.ENUM("pending", "paid", "refunded", "not_required"),
|
||||
type: DataTypes.ENUM("pending", "paid", "refunded", "not_required", "requires_action"),
|
||||
allowNull: false,
|
||||
},
|
||||
payoutStatus: {
|
||||
type: DataTypes.ENUM("pending", "completed", "failed"),
|
||||
type: DataTypes.ENUM("pending", "completed", "failed", "on_hold"),
|
||||
allowNull: true,
|
||||
},
|
||||
payoutProcessedAt: {
|
||||
@@ -80,6 +80,66 @@ const Rental = sequelize.define("Rental", {
|
||||
stripeTransferId: {
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
// Bank deposit tracking fields (for tracking when Stripe deposits to owner's bank)
|
||||
bankDepositStatus: {
|
||||
type: DataTypes.ENUM("pending", "in_transit", "paid", "failed", "canceled"),
|
||||
allowNull: true,
|
||||
},
|
||||
bankDepositAt: {
|
||||
type: DataTypes.DATE,
|
||||
},
|
||||
stripePayoutId: {
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
bankDepositFailureCode: {
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
// Dispute tracking fields (for tracking Stripe payment disputes/chargebacks)
|
||||
// Stripe dispute statuses: https://docs.stripe.com/api/disputes/object#dispute_object-status
|
||||
stripeDisputeStatus: {
|
||||
type: DataTypes.ENUM(
|
||||
"needs_response",
|
||||
"under_review",
|
||||
"won",
|
||||
"lost",
|
||||
"warning_needs_response",
|
||||
"warning_under_review",
|
||||
"warning_closed"
|
||||
),
|
||||
allowNull: true,
|
||||
},
|
||||
stripeDisputeId: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
stripeDisputeReason: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
stripeDisputeAmount: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
},
|
||||
stripeDisputeCreatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
stripeDisputeEvidenceDueBy: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
stripeDisputeClosedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
stripeDisputeLost: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
},
|
||||
stripeDisputeLostAmount: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
},
|
||||
// Refund tracking fields
|
||||
refundAmount: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
@@ -117,6 +177,21 @@ const Rental = sequelize.define("Rental", {
|
||||
chargedAt: {
|
||||
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: {
|
||||
type: DataTypes.ENUM("pickup", "delivery"),
|
||||
defaultValue: "pickup",
|
||||
|
||||
@@ -60,8 +60,8 @@ const User = sequelize.define(
|
||||
country: {
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
profileImage: {
|
||||
type: DataTypes.STRING,
|
||||
imageFilename: {
|
||||
type: DataTypes.TEXT,
|
||||
},
|
||||
isVerified: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
@@ -89,11 +89,11 @@ const User = sequelize.define(
|
||||
},
|
||||
defaultAvailableAfter: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: "09:00",
|
||||
defaultValue: "00:00",
|
||||
},
|
||||
defaultAvailableBefore: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: "17:00",
|
||||
defaultValue: "23:00",
|
||||
},
|
||||
defaultSpecifyTimesPerDay: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
@@ -102,23 +102,46 @@ const User = sequelize.define(
|
||||
defaultWeeklyTimes: {
|
||||
type: DataTypes.JSONB,
|
||||
defaultValue: {
|
||||
sunday: { availableAfter: "09:00", availableBefore: "17:00" },
|
||||
monday: { availableAfter: "09:00", availableBefore: "17:00" },
|
||||
tuesday: { availableAfter: "09:00", availableBefore: "17:00" },
|
||||
wednesday: { availableAfter: "09:00", availableBefore: "17:00" },
|
||||
thursday: { availableAfter: "09:00", availableBefore: "17:00" },
|
||||
friday: { availableAfter: "09:00", availableBefore: "17:00" },
|
||||
saturday: { availableAfter: "09:00", availableBefore: "17:00" },
|
||||
sunday: { availableAfter: "00:00", availableBefore: "23:00" },
|
||||
monday: { availableAfter: "00:00", availableBefore: "23:00" },
|
||||
tuesday: { availableAfter: "00:00", availableBefore: "23:00" },
|
||||
wednesday: { availableAfter: "00:00", availableBefore: "23:00" },
|
||||
thursday: { availableAfter: "00:00", availableBefore: "23:00" },
|
||||
friday: { availableAfter: "00:00", availableBefore: "23:00" },
|
||||
saturday: { availableAfter: "00:00", availableBefore: "23:00" },
|
||||
},
|
||||
},
|
||||
stripeConnectedAccountId: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
stripePayoutsEnabled: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
allowNull: true,
|
||||
},
|
||||
stripeCustomerId: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
stripeRequirementsCurrentlyDue: {
|
||||
type: DataTypes.JSON,
|
||||
defaultValue: [],
|
||||
allowNull: true,
|
||||
},
|
||||
stripeRequirementsPastDue: {
|
||||
type: DataTypes.JSON,
|
||||
defaultValue: [],
|
||||
allowNull: true,
|
||||
},
|
||||
stripeDisabledReason: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
stripeRequirementsLastUpdated: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
loginAttempts: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0,
|
||||
@@ -137,6 +160,23 @@ const User = sequelize.define(
|
||||
defaultValue: "user",
|
||||
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: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 10,
|
||||
@@ -146,6 +186,71 @@ const User = sequelize.define(
|
||||
max: 100,
|
||||
},
|
||||
},
|
||||
verificationAttempts: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0,
|
||||
allowNull: true,
|
||||
},
|
||||
// Two-Factor Authentication fields
|
||||
twoFactorEnabled: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
allowNull: false,
|
||||
},
|
||||
twoFactorMethod: {
|
||||
type: DataTypes.ENUM("totp", "email"),
|
||||
allowNull: true,
|
||||
},
|
||||
totpSecret: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
totpSecretIv: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
// Email OTP fields (backup method)
|
||||
emailOtpCode: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
emailOtpExpiry: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
emailOtpAttempts: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0,
|
||||
allowNull: false,
|
||||
},
|
||||
// Recovery codes
|
||||
recoveryCodesHash: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
recoveryCodesGeneratedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
// Step-up session tracking
|
||||
twoFactorVerifiedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
// Temporary secret during setup
|
||||
twoFactorSetupPendingSecret: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
twoFactorSetupPendingSecretIv: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
// TOTP replay protection
|
||||
recentTotpCodes: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
hooks: {
|
||||
@@ -160,7 +265,7 @@ const User = sequelize.define(
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
User.prototype.comparePassword = async function (password) {
|
||||
@@ -171,7 +276,7 @@ User.prototype.comparePassword = async function (password) {
|
||||
};
|
||||
|
||||
// Account lockout constants
|
||||
const MAX_LOGIN_ATTEMPTS = 5;
|
||||
const MAX_LOGIN_ATTEMPTS = 10;
|
||||
const LOCK_TIME = 2 * 60 * 60 * 1000; // 2 hours
|
||||
|
||||
// Check if account is locked
|
||||
@@ -208,31 +313,64 @@ User.prototype.resetLoginAttempts = async function () {
|
||||
};
|
||||
|
||||
// Email verification methods
|
||||
// Maximum verification attempts before requiring a new code
|
||||
const MAX_VERIFICATION_ATTEMPTS = 5;
|
||||
|
||||
User.prototype.generateVerificationToken = async function () {
|
||||
const crypto = require("crypto");
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
// Generate 6-digit numeric code (100000-999999)
|
||||
const code = crypto.randomInt(100000, 999999).toString();
|
||||
const expiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
||||
|
||||
return this.update({
|
||||
verificationToken: token,
|
||||
verificationToken: code,
|
||||
verificationTokenExpiry: expiry,
|
||||
verificationAttempts: 0, // Reset attempts on new code
|
||||
});
|
||||
};
|
||||
|
||||
User.prototype.isVerificationTokenValid = function (token) {
|
||||
const crypto = require("crypto");
|
||||
|
||||
if (!this.verificationToken || !this.verificationTokenExpiry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.verificationToken !== token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if (new Date() > new Date(this.verificationTokenExpiry)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
// Validate 6-digit format
|
||||
if (!/^\d{6}$/.test(token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use timing-safe comparison to prevent timing attacks
|
||||
try {
|
||||
const inputBuffer = Buffer.from(token);
|
||||
const storedBuffer = Buffer.from(this.verificationToken);
|
||||
|
||||
if (inputBuffer.length !== storedBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return crypto.timingSafeEqual(inputBuffer, storedBuffer);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if too many verification attempts
|
||||
User.prototype.isVerificationLocked = function () {
|
||||
return (this.verificationAttempts || 0) >= MAX_VERIFICATION_ATTEMPTS;
|
||||
};
|
||||
|
||||
// Increment verification attempts
|
||||
User.prototype.incrementVerificationAttempts = async function () {
|
||||
const newAttempts = (this.verificationAttempts || 0) + 1;
|
||||
await this.update({ verificationAttempts: newAttempts });
|
||||
return newAttempts;
|
||||
};
|
||||
|
||||
User.prototype.verifyEmail = async function () {
|
||||
@@ -241,6 +379,7 @@ User.prototype.verifyEmail = async function () {
|
||||
verifiedAt: new Date(),
|
||||
verificationToken: null,
|
||||
verificationTokenExpiry: null,
|
||||
verificationAttempts: 0,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -299,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;
|
||||
|
||||
@@ -10,6 +10,7 @@ const UserAddress = require("./UserAddress");
|
||||
const ConditionCheck = require("./ConditionCheck");
|
||||
const AlphaInvitation = require("./AlphaInvitation");
|
||||
const Feedback = require("./Feedback");
|
||||
const ImageMetadata = require("./ImageMetadata");
|
||||
|
||||
User.hasMany(Item, { as: "ownedItems", foreignKey: "ownerId" });
|
||||
Item.belongsTo(User, { as: "owner", foreignKey: "ownerId" });
|
||||
@@ -91,4 +92,5 @@ module.exports = {
|
||||
ConditionCheck,
|
||||
AlphaInvitation,
|
||||
Feedback,
|
||||
ImageMetadata,
|
||||
};
|
||||
|
||||
4783
backend/package-lock.json
generated
4783
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,10 +12,10 @@
|
||||
"dev:qa": "NODE_ENV=qa nodemon -r dotenv/config server.js dotenv_config_path=.env.qa",
|
||||
"test": "NODE_ENV=test jest",
|
||||
"test:watch": "NODE_ENV=test jest --watch",
|
||||
"test:coverage": "jest --coverage --forceExit --maxWorkers=4",
|
||||
"test:coverage": "jest --coverage --maxWorkers=1",
|
||||
"test:unit": "NODE_ENV=test jest tests/unit",
|
||||
"test:integration": "NODE_ENV=test jest tests/integration",
|
||||
"test:ci": "NODE_ENV=test jest --ci --coverage --maxWorkers=2",
|
||||
"test:ci": "NODE_ENV=test jest --ci --coverage --maxWorkers=1",
|
||||
"db:migrate": "sequelize-cli db:migrate",
|
||||
"db:migrate:undo": "sequelize-cli db:migrate:undo",
|
||||
"db:migrate:undo:all": "sequelize-cli db:migrate:undo:all",
|
||||
@@ -34,8 +34,11 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.940.0",
|
||||
"@aws-sdk/client-scheduler": "^3.896.0",
|
||||
"@aws-sdk/client-ses": "^3.896.0",
|
||||
"@aws-sdk/credential-providers": "^3.901.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.940.0",
|
||||
"@googlemaps/google-maps-services-js": "^3.4.2",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"body-parser": "^2.2.0",
|
||||
@@ -52,9 +55,9 @@
|
||||
"jsdom": "^27.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"morgan": "^1.10.1",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^3.0.3",
|
||||
"otplib": "^13.1.1",
|
||||
"pg": "^8.16.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"sequelize": "^6.37.7",
|
||||
"sequelize-cli": "^6.6.3",
|
||||
"socket.io": "^4.8.1",
|
||||
@@ -64,7 +67,10 @@
|
||||
"winston-daily-rotate-file": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.28.6",
|
||||
"@babel/preset-env": "^7.28.6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"babel-jest": "^30.2.0",
|
||||
"jest": "^30.1.3",
|
||||
"nodemon": "^3.1.10",
|
||||
"sequelize-mock": "^0.10.2",
|
||||
|
||||
@@ -91,7 +91,7 @@ router.post("/validate-code", alphaCodeValidationLimiter, async (req, res) => {
|
||||
|
||||
res.cookie("alphaAccessCode", cookieData, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
|
||||
sameSite: "strict",
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||
});
|
||||
|
||||
@@ -20,14 +20,15 @@ const {
|
||||
loginLimiter,
|
||||
registerLimiter,
|
||||
passwordResetLimiter,
|
||||
emailVerificationLimiter,
|
||||
} = require("../middleware/rateLimiter");
|
||||
const { authenticateToken } = require("../middleware/auth");
|
||||
const router = express.Router();
|
||||
|
||||
const googleClient = new OAuth2Client(
|
||||
process.env.GOOGLE_CLIENT_ID,
|
||||
process.env.GOOGLE_CLIENT_SECRET,
|
||||
process.env.GOOGLE_REDIRECT_URI ||
|
||||
"http://localhost:3000/auth/google/callback"
|
||||
process.env.GOOGLE_REDIRECT_URI,
|
||||
);
|
||||
|
||||
// Get CSRF token endpoint
|
||||
@@ -43,8 +44,7 @@ router.post(
|
||||
validateRegistration,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { email, password, firstName, lastName, phone } =
|
||||
req.body;
|
||||
const { email, password, firstName, lastName, phone } = req.body;
|
||||
|
||||
const existingUser = await User.findOne({
|
||||
where: { email },
|
||||
@@ -64,7 +64,7 @@ router.post(
|
||||
|
||||
// Alpha access validation
|
||||
let alphaInvitation = null;
|
||||
if (process.env.ALPHA_TESTING_ENABLED === 'true') {
|
||||
if (process.env.ALPHA_TESTING_ENABLED === "true") {
|
||||
if (req.cookies && req.cookies.alphaAccessCode) {
|
||||
const { code } = req.cookies.alphaAccessCode;
|
||||
if (code) {
|
||||
@@ -88,7 +88,8 @@ router.post(
|
||||
|
||||
if (!alphaInvitation) {
|
||||
return res.status(403).json({
|
||||
error: "Alpha access required. Please enter your invitation code first.",
|
||||
error:
|
||||
"Alpha access required. Please enter your invitation code first.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -101,12 +102,14 @@ router.post(
|
||||
phone,
|
||||
});
|
||||
|
||||
// Link alpha invitation to user
|
||||
await alphaInvitation.update({
|
||||
usedBy: user.id,
|
||||
usedAt: new Date(),
|
||||
status: "active",
|
||||
});
|
||||
// Link alpha invitation to user (only if alpha testing is enabled)
|
||||
if (alphaInvitation) {
|
||||
await alphaInvitation.update({
|
||||
usedBy: user.id,
|
||||
usedAt: new Date(),
|
||||
status: "active",
|
||||
});
|
||||
}
|
||||
|
||||
// Generate verification token and send email
|
||||
await user.generateVerificationToken();
|
||||
@@ -114,12 +117,16 @@ router.post(
|
||||
// Send verification email (don't block registration if email fails)
|
||||
let verificationEmailSent = false;
|
||||
try {
|
||||
await emailServices.auth.sendVerificationEmail(user, user.verificationToken);
|
||||
await emailServices.auth.sendVerificationEmail(
|
||||
user,
|
||||
user.verificationToken,
|
||||
);
|
||||
verificationEmailSent = true;
|
||||
} catch (emailError) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Failed to send verification email", {
|
||||
error: emailError.message,
|
||||
stack: emailError.stack,
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
});
|
||||
@@ -128,29 +135,27 @@ router.post(
|
||||
|
||||
const token = jwt.sign(
|
||||
{ id: user.id, jwtVersion: user.jwtVersion },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: "15m" } // Short-lived access token
|
||||
process.env.JWT_ACCESS_SECRET,
|
||||
{ expiresIn: "15m" }, // Short-lived access token
|
||||
);
|
||||
|
||||
const refreshToken = jwt.sign(
|
||||
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: "7d" }
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
{ expiresIn: "7d" },
|
||||
);
|
||||
|
||||
// Set tokens as httpOnly cookies
|
||||
res.cookie("accessToken", token, {
|
||||
httpOnly: true,
|
||||
secure:
|
||||
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
|
||||
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
|
||||
sameSite: "strict",
|
||||
maxAge: 15 * 60 * 1000, // 15 minutes
|
||||
});
|
||||
|
||||
res.cookie("refreshToken", refreshToken, {
|
||||
httpOnly: true,
|
||||
secure:
|
||||
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
|
||||
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
|
||||
sameSite: "strict",
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
});
|
||||
@@ -182,7 +187,7 @@ router.post(
|
||||
});
|
||||
res.status(500).json({ error: "Registration failed. Please try again." });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
@@ -198,14 +203,25 @@ router.post(
|
||||
const user = await User.findOne({ where: { email } });
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: "Invalid credentials" });
|
||||
return res.status(401).json({
|
||||
error: "Please check your email and password, or create an account.",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if account is locked
|
||||
if (user.isLocked()) {
|
||||
return res.status(423).json({
|
||||
error:
|
||||
"Account is temporarily locked due to too many failed login attempts. Please try again later.",
|
||||
"Account is temporarily locked due to too many failed login attempts. Please try again in 2 hours.",
|
||||
});
|
||||
}
|
||||
|
||||
// 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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -215,7 +231,9 @@ router.post(
|
||||
if (!isPasswordValid) {
|
||||
// Increment login attempts
|
||||
await user.incLoginAttempts();
|
||||
return res.status(401).json({ error: "Invalid credentials" });
|
||||
return res.status(401).json({
|
||||
error: "Please check your email and password, or create an account.",
|
||||
});
|
||||
}
|
||||
|
||||
// Reset login attempts on successful login
|
||||
@@ -223,29 +241,27 @@ router.post(
|
||||
|
||||
const token = jwt.sign(
|
||||
{ id: user.id, jwtVersion: user.jwtVersion },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: "15m" } // Short-lived access token
|
||||
process.env.JWT_ACCESS_SECRET,
|
||||
{ expiresIn: "15m" }, // Short-lived access token
|
||||
);
|
||||
|
||||
const refreshToken = jwt.sign(
|
||||
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: "7d" }
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
{ expiresIn: "7d" },
|
||||
);
|
||||
|
||||
// Set tokens as httpOnly cookies
|
||||
res.cookie("accessToken", token, {
|
||||
httpOnly: true,
|
||||
secure:
|
||||
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
|
||||
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
|
||||
sameSite: "strict",
|
||||
maxAge: 15 * 60 * 1000, // 15 minutes
|
||||
});
|
||||
|
||||
res.cookie("refreshToken", refreshToken, {
|
||||
httpOnly: true,
|
||||
secure:
|
||||
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
|
||||
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
|
||||
sameSite: "strict",
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
});
|
||||
@@ -276,7 +292,7 @@ router.post(
|
||||
});
|
||||
res.status(500).json({ error: "Login failed. Please try again." });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
@@ -298,9 +314,7 @@ router.post(
|
||||
// Exchange authorization code for tokens
|
||||
const { tokens } = await googleClient.getToken({
|
||||
code,
|
||||
redirect_uri:
|
||||
process.env.GOOGLE_REDIRECT_URI ||
|
||||
"http://localhost:3000/auth/google/callback",
|
||||
redirect_uri: process.env.GOOGLE_REDIRECT_URI,
|
||||
});
|
||||
|
||||
// Verify the ID token from the token response
|
||||
@@ -320,7 +334,8 @@ router.post(
|
||||
|
||||
if (!email) {
|
||||
return res.status(400).json({
|
||||
error: "Email permission is required to continue. Please grant email access when signing in with Google and try again."
|
||||
error:
|
||||
"Email permission is required to continue. Please grant email access when signing in with Google and try again.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -330,18 +345,22 @@ router.post(
|
||||
let lastName = familyName;
|
||||
|
||||
if (!firstName || !lastName) {
|
||||
const emailUsername = email.split('@')[0];
|
||||
const emailUsername = email.split("@")[0];
|
||||
// Try to split email username by common separators
|
||||
const nameParts = emailUsername.split(/[._-]/);
|
||||
|
||||
if (!firstName) {
|
||||
firstName = nameParts[0] ? nameParts[0].charAt(0).toUpperCase() + nameParts[0].slice(1) : 'Google';
|
||||
firstName = nameParts[0]
|
||||
? nameParts[0].charAt(0).toUpperCase() + nameParts[0].slice(1)
|
||||
: "Google";
|
||||
}
|
||||
|
||||
if (!lastName) {
|
||||
lastName = nameParts.length > 1
|
||||
? nameParts[nameParts.length - 1].charAt(0).toUpperCase() + nameParts[nameParts.length - 1].slice(1)
|
||||
: 'User';
|
||||
lastName =
|
||||
nameParts.length > 1
|
||||
? nameParts[nameParts.length - 1].charAt(0).toUpperCase() +
|
||||
nameParts[nameParts.length - 1].slice(1)
|
||||
: "User";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,13 +386,13 @@ router.post(
|
||||
lastName,
|
||||
authProvider: "google",
|
||||
providerId: googleId,
|
||||
profileImage: picture,
|
||||
imageFilename: picture,
|
||||
isVerified: true,
|
||||
verifiedAt: new Date(),
|
||||
});
|
||||
|
||||
// Check if there's an alpha invitation for this email
|
||||
if (process.env.ALPHA_TESTING_ENABLED === 'true') {
|
||||
if (process.env.ALPHA_TESTING_ENABLED === "true") {
|
||||
const alphaInvitation = await AlphaInvitation.findOne({
|
||||
where: { email: email.toLowerCase().trim() },
|
||||
});
|
||||
@@ -389,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
|
||||
const token = jwt.sign(
|
||||
{ id: user.id, jwtVersion: user.jwtVersion },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: "15m" }
|
||||
process.env.JWT_ACCESS_SECRET,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
|
||||
const refreshToken = jwt.sign(
|
||||
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: "7d" }
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
{ expiresIn: "7d" },
|
||||
);
|
||||
|
||||
// Set tokens as httpOnly cookies
|
||||
res.cookie("accessToken", token, {
|
||||
httpOnly: true,
|
||||
secure:
|
||||
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
|
||||
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
|
||||
sameSite: "strict",
|
||||
maxAge: 15 * 60 * 1000,
|
||||
});
|
||||
|
||||
res.cookie("refreshToken", refreshToken, {
|
||||
httpOnly: true,
|
||||
secure:
|
||||
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
|
||||
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
|
||||
sameSite: "strict",
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
@@ -434,7 +460,7 @@ router.post(
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
profileImage: user.profileImage,
|
||||
imageFilename: user.imageFilename,
|
||||
isVerified: user.isVerified,
|
||||
role: user.role,
|
||||
},
|
||||
@@ -461,77 +487,125 @@ router.post(
|
||||
.status(500)
|
||||
.json({ error: "Google authentication failed. Please try again." });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Email verification endpoint
|
||||
router.post("/verify-email", sanitizeInput, async (req, res) => {
|
||||
try {
|
||||
const { token } = req.body;
|
||||
router.post(
|
||||
"/verify-email",
|
||||
emailVerificationLimiter,
|
||||
authenticateToken,
|
||||
sanitizeInput,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { code } = req.body;
|
||||
|
||||
if (!token) {
|
||||
return res.status(400).json({
|
||||
error: "Verification token required",
|
||||
code: "TOKEN_REQUIRED",
|
||||
});
|
||||
}
|
||||
if (!code) {
|
||||
return res.status(400).json({
|
||||
error: "Verification code required",
|
||||
code: "CODE_REQUIRED",
|
||||
});
|
||||
}
|
||||
|
||||
// Find user with this verification token
|
||||
const user = await User.findOne({
|
||||
where: { verificationToken: token },
|
||||
});
|
||||
// Validate 6-digit format
|
||||
if (!/^\d{6}$/.test(code)) {
|
||||
return res.status(400).json({
|
||||
error: "Verification code must be 6 digits",
|
||||
code: "INVALID_CODE_FORMAT",
|
||||
});
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid verification token",
|
||||
code: "VERIFICATION_TOKEN_INVALID",
|
||||
});
|
||||
}
|
||||
// Get the authenticated user
|
||||
const user = await User.findByPk(req.user.id);
|
||||
|
||||
// Check if already verified
|
||||
if (user.isVerified) {
|
||||
return res.status(400).json({
|
||||
error: "Email already verified",
|
||||
code: "ALREADY_VERIFIED",
|
||||
});
|
||||
}
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
error: "User not found",
|
||||
code: "USER_NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if token is valid (not expired)
|
||||
if (!user.isVerificationTokenValid(token)) {
|
||||
return res.status(400).json({
|
||||
error: "Verification token has expired. Please request a new one.",
|
||||
code: "VERIFICATION_TOKEN_EXPIRED",
|
||||
});
|
||||
}
|
||||
// Check if already verified
|
||||
if (user.isVerified) {
|
||||
return res.status(400).json({
|
||||
error: "Email already verified",
|
||||
code: "ALREADY_VERIFIED",
|
||||
});
|
||||
}
|
||||
|
||||
// Verify the email
|
||||
await user.verifyEmail();
|
||||
// Check if too many failed attempts
|
||||
if (user.isVerificationLocked()) {
|
||||
return res.status(429).json({
|
||||
error: "Too many verification attempts. Please request a new code.",
|
||||
code: "TOO_MANY_ATTEMPTS",
|
||||
});
|
||||
}
|
||||
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Email verified successfully", {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
});
|
||||
// Check if user has a verification token
|
||||
if (!user.verificationToken) {
|
||||
return res.status(400).json({
|
||||
error: "No verification code found. Please request a new one.",
|
||||
code: "NO_CODE",
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: "Email verified successfully",
|
||||
user: {
|
||||
id: user.id,
|
||||
// Check if code is expired
|
||||
if (
|
||||
user.verificationTokenExpiry &&
|
||||
new Date() > new Date(user.verificationTokenExpiry)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: "Verification code has expired. Please request a new one.",
|
||||
code: "VERIFICATION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
// Validate the code
|
||||
if (!user.isVerificationTokenValid(code)) {
|
||||
// Increment failed attempts
|
||||
await user.incrementVerificationAttempts();
|
||||
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.warn("Invalid verification code attempt", {
|
||||
userId: user.id,
|
||||
attempts: user.verificationAttempts + 1,
|
||||
});
|
||||
|
||||
return res.status(400).json({
|
||||
error: "Invalid verification code",
|
||||
code: "VERIFICATION_INVALID",
|
||||
});
|
||||
}
|
||||
|
||||
// Verify the email
|
||||
await user.verifyEmail();
|
||||
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Email verified successfully", {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
isVerified: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Email verification error", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Email verification failed. Please try again.",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: "Email verified successfully",
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
isVerified: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Email verification error", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Email verification failed. Please try again.",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Resend verification email endpoint
|
||||
router.post(
|
||||
@@ -550,7 +624,7 @@ router.post(
|
||||
});
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(accessToken, process.env.JWT_SECRET);
|
||||
const decoded = jwt.verify(accessToken, process.env.JWT_ACCESS_SECRET);
|
||||
const user = await User.findByPk(decoded.id);
|
||||
|
||||
if (!user) {
|
||||
@@ -573,11 +647,15 @@ router.post(
|
||||
|
||||
// Send verification email
|
||||
try {
|
||||
await emailServices.auth.sendVerificationEmail(user, user.verificationToken);
|
||||
await emailServices.auth.sendVerificationEmail(
|
||||
user,
|
||||
user.verificationToken,
|
||||
);
|
||||
} catch (emailError) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Failed to resend verification email", {
|
||||
error: emailError.message,
|
||||
stack: emailError.stack,
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
});
|
||||
@@ -612,7 +690,7 @@ router.post(
|
||||
error: "Failed to resend verification email. Please try again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Refresh token endpoint
|
||||
@@ -625,7 +703,7 @@ router.post("/refresh", async (req, res) => {
|
||||
}
|
||||
|
||||
// Verify refresh token
|
||||
const decoded = jwt.verify(refreshToken, process.env.JWT_SECRET);
|
||||
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
|
||||
|
||||
if (!decoded.id || decoded.type !== "refresh") {
|
||||
return res.status(401).json({ error: "Invalid refresh token" });
|
||||
@@ -645,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
|
||||
const newAccessToken = jwt.sign(
|
||||
{ id: user.id, jwtVersion: user.jwtVersion },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: "15m" }
|
||||
process.env.JWT_ACCESS_SECRET,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
|
||||
// Set new access token cookie
|
||||
res.cookie("accessToken", newAccessToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
|
||||
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
|
||||
sameSite: "strict",
|
||||
maxAge: 15 * 60 * 1000,
|
||||
});
|
||||
@@ -752,6 +839,7 @@ router.post(
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Failed to send password reset email", {
|
||||
error: emailError.message,
|
||||
stack: emailError.stack,
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
});
|
||||
@@ -763,7 +851,7 @@ router.post(
|
||||
"Password reset requested for non-existent or OAuth user",
|
||||
{
|
||||
email: email,
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -783,7 +871,7 @@ router.post(
|
||||
error: "Failed to process password reset request. Please try again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Verify reset token endpoint (optional - for frontend UX)
|
||||
@@ -837,7 +925,7 @@ router.post(
|
||||
error: "Failed to verify reset token. Please try again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Reset password endpoint
|
||||
@@ -893,6 +981,7 @@ router.post(
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Failed to send password changed notification", {
|
||||
error: emailError.message,
|
||||
stack: emailError.stack,
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
});
|
||||
@@ -919,7 +1008,7 @@ router.post(
|
||||
error: "Failed to reset password. Please try again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,86 +1,35 @@
|
||||
const express = require("express");
|
||||
const multer = require("multer");
|
||||
const { authenticateToken } = require("../middleware/auth");
|
||||
const ConditionCheckService = require("../services/conditionCheckService");
|
||||
const logger = require("../utils/logger");
|
||||
const { validateS3Keys } = require("../utils/s3KeyValidator");
|
||||
const { IMAGE_LIMITS } = require("../config/imageLimits");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Configure multer for photo uploads
|
||||
const upload = multer({
|
||||
dest: "uploads/condition-checks/",
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10MB limit
|
||||
files: 20, // Maximum 20 files
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
// Accept only image files
|
||||
if (file.mimetype.startsWith("image/")) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error("Only image files are allowed"), false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Submit a condition check
|
||||
router.post(
|
||||
"/:rentalId",
|
||||
authenticateToken,
|
||||
upload.array("photos"),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { rentalId } = req.params;
|
||||
const { checkType, notes } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// Get uploaded file paths
|
||||
const photos = req.files ? req.files.map((file) => file.path) : [];
|
||||
|
||||
const conditionCheck = await ConditionCheckService.submitConditionCheck(
|
||||
rentalId,
|
||||
checkType,
|
||||
userId,
|
||||
photos,
|
||||
notes
|
||||
);
|
||||
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Condition check submitted", {
|
||||
rentalId,
|
||||
checkType,
|
||||
userId,
|
||||
photoCount: photos.length,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
conditionCheck,
|
||||
});
|
||||
} catch (error) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Error submitting condition check", {
|
||||
error: error.message,
|
||||
rentalId: req.params.rentalId,
|
||||
userId: req.user?.id,
|
||||
});
|
||||
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get condition checks for a rental
|
||||
router.get("/:rentalId", authenticateToken, async (req, res) => {
|
||||
// Get condition checks for multiple rentals in a single request (batch)
|
||||
router.get("/batch", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { rentalId } = req.params;
|
||||
const { rentalIds } = req.query;
|
||||
|
||||
const conditionChecks = await ConditionCheckService.getConditionChecks(
|
||||
rentalId
|
||||
);
|
||||
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,
|
||||
@@ -88,9 +37,10 @@ router.get("/:rentalId", authenticateToken, async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Error fetching condition checks", {
|
||||
reqLogger.error("Error fetching batch condition checks", {
|
||||
error: error.message,
|
||||
rentalId: req.params.rentalId,
|
||||
stack: error.stack,
|
||||
rentalIds: req.query.rentalIds,
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
@@ -100,27 +50,66 @@ router.get("/:rentalId", authenticateToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get condition check timeline for a rental
|
||||
router.get("/:rentalId/timeline", authenticateToken, async (req, res) => {
|
||||
// Submit a condition check
|
||||
router.post("/:rentalId", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { rentalId } = req.params;
|
||||
const { checkType, notes, imageFilenames: rawImageFilenames } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
const timeline = await ConditionCheckService.getConditionCheckTimeline(
|
||||
rentalId
|
||||
// Ensure imageFilenames is an array (S3 keys)
|
||||
const imageFilenamesArray = Array.isArray(rawImageFilenames)
|
||||
? rawImageFilenames
|
||||
: [];
|
||||
|
||||
// Validate S3 keys format and folder
|
||||
const keyValidation = validateS3Keys(
|
||||
imageFilenamesArray,
|
||||
"condition-checks",
|
||||
{
|
||||
maxKeys: IMAGE_LIMITS.conditionChecks,
|
||||
}
|
||||
);
|
||||
if (!keyValidation.valid) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: keyValidation.error,
|
||||
details: keyValidation.invalidKeys,
|
||||
});
|
||||
}
|
||||
|
||||
const imageFilenames = imageFilenamesArray;
|
||||
|
||||
const conditionCheck = await ConditionCheckService.submitConditionCheck(
|
||||
rentalId,
|
||||
checkType,
|
||||
userId,
|
||||
imageFilenames,
|
||||
notes
|
||||
);
|
||||
|
||||
res.json({
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Condition check submitted", {
|
||||
rentalId,
|
||||
checkType,
|
||||
userId,
|
||||
photoCount: imageFilenames.length,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
timeline,
|
||||
conditionCheck,
|
||||
});
|
||||
} catch (error) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Error fetching condition check timeline", {
|
||||
reqLogger.error("Error submitting condition check", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
rentalId: req.params.rentalId,
|
||||
userId: req.user?.id,
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
@@ -131,9 +120,12 @@ router.get("/:rentalId/timeline", authenticateToken, async (req, res) => {
|
||||
router.get("/", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { rentalIds } = req.query;
|
||||
const ids = rentalIds ? rentalIds.split(",").filter((id) => id.trim()) : [];
|
||||
|
||||
const availableChecks = await ConditionCheckService.getAvailableChecks(
|
||||
userId
|
||||
userId,
|
||||
ids
|
||||
);
|
||||
|
||||
res.json({
|
||||
@@ -144,6 +136,7 @@ router.get("/", authenticateToken, async (req, res) => {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Error fetching available checks", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
userId: req.user?.id,
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ const emailServices = require('../services/email');
|
||||
const router = express.Router();
|
||||
|
||||
// Submit new feedback
|
||||
router.post('/', authenticateToken, sanitizeInput, validateFeedback, async (req, res) => {
|
||||
router.post('/', authenticateToken, sanitizeInput, validateFeedback, async (req, res, next) => {
|
||||
try {
|
||||
const { feedbackText, url } = req.body;
|
||||
|
||||
@@ -33,6 +33,7 @@ router.post('/', authenticateToken, sanitizeInput, validateFeedback, async (req,
|
||||
} catch (emailError) {
|
||||
reqLogger.error("Failed to send feedback confirmation email", {
|
||||
error: emailError.message,
|
||||
stack: emailError.stack,
|
||||
userId: req.user.id,
|
||||
feedbackId: feedback.id
|
||||
});
|
||||
@@ -45,6 +46,7 @@ router.post('/', authenticateToken, sanitizeInput, validateFeedback, async (req,
|
||||
} catch (emailError) {
|
||||
reqLogger.error("Failed to send feedback notification to admin", {
|
||||
error: emailError.message,
|
||||
stack: emailError.stack,
|
||||
userId: req.user.id,
|
||||
feedbackId: feedback.id
|
||||
});
|
||||
@@ -59,7 +61,7 @@ router.post('/', authenticateToken, sanitizeInput, validateFeedback, async (req,
|
||||
stack: error.stack,
|
||||
userId: req.user.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -2,11 +2,13 @@ const express = require('express');
|
||||
const { Op } = require('sequelize');
|
||||
const { ForumPost, ForumComment, PostTag, User } = require('../models');
|
||||
const { authenticateToken, requireAdmin, optionalAuth } = require('../middleware/auth');
|
||||
const { uploadForumPostImages, uploadForumCommentImages } = require('../middleware/upload');
|
||||
const { validateCoordinatesBody, handleValidationErrors } = require('../middleware/validation');
|
||||
const logger = require('../utils/logger');
|
||||
const emailServices = require('../services/email');
|
||||
const googleMapsService = require('../services/googleMapsService');
|
||||
const locationService = require('../services/locationService');
|
||||
const { validateS3Keys } = require('../utils/s3KeyValidator');
|
||||
const { IMAGE_LIMITS } = require('../config/imageLimits');
|
||||
const router = express.Router();
|
||||
|
||||
// Helper function to build nested comment tree
|
||||
@@ -21,7 +23,7 @@ const buildCommentTree = (comments, isAdmin = false) => {
|
||||
// Sanitize deleted comments for non-admin users
|
||||
if (commentJson.isDeleted && !isAdmin) {
|
||||
commentJson.content = '';
|
||||
commentJson.images = [];
|
||||
commentJson.imageFilenames = [];
|
||||
}
|
||||
|
||||
commentMap[comment.id] = { ...commentJson, replies: [] };
|
||||
@@ -40,7 +42,7 @@ const buildCommentTree = (comments, isAdmin = false) => {
|
||||
};
|
||||
|
||||
// GET /api/forum/posts - Browse all posts
|
||||
router.get('/posts', optionalAuth, async (req, res) => {
|
||||
router.get('/posts', optionalAuth, async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
search,
|
||||
@@ -158,12 +160,12 @@ router.get('/posts', optionalAuth, async (req, res) => {
|
||||
stack: error.stack,
|
||||
query: req.query
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/forum/posts/:id - Get single post with all comments
|
||||
router.get('/posts/:id', optionalAuth, async (req, res) => {
|
||||
router.get('/posts/:id', optionalAuth, async (req, res, next) => {
|
||||
try {
|
||||
const post = await ForumPost.findByPk(req.params.id, {
|
||||
include: [
|
||||
@@ -233,26 +235,35 @@ router.get('/posts/:id', optionalAuth, async (req, res) => {
|
||||
stack: error.stack,
|
||||
postId: req.params.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/forum/posts - Create new post
|
||||
router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res) => {
|
||||
router.post('/posts', authenticateToken, ...validateCoordinatesBody, handleValidationErrors, async (req, res, next) => {
|
||||
try {
|
||||
let { title, content, category, tags, zipCode, latitude: providedLat, longitude: providedLng } = req.body;
|
||||
|
||||
// Parse tags if they come as JSON string (from FormData)
|
||||
if (typeof tags === 'string') {
|
||||
try {
|
||||
tags = JSON.parse(tags);
|
||||
} catch (e) {
|
||||
tags = [];
|
||||
}
|
||||
// Require email verification
|
||||
if (!req.user.isVerified) {
|
||||
return res.status(403).json({
|
||||
error: "Please verify your email address before creating forum posts.",
|
||||
code: "EMAIL_NOT_VERIFIED"
|
||||
});
|
||||
}
|
||||
|
||||
// Extract image filenames if uploaded
|
||||
const images = req.files ? req.files.map(file => file.filename) : [];
|
||||
let { title, content, category, tags, zipCode, latitude: providedLat, longitude: providedLng, imageFilenames: rawImageFilenames } = req.body;
|
||||
|
||||
// Ensure imageFilenames is an array and validate S3 keys
|
||||
const imageFilenamesArray = Array.isArray(rawImageFilenames) ? rawImageFilenames : [];
|
||||
|
||||
const keyValidation = validateS3Keys(imageFilenamesArray, 'forum', { maxKeys: IMAGE_LIMITS.forum });
|
||||
if (!keyValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: keyValidation.error,
|
||||
details: keyValidation.invalidKeys
|
||||
});
|
||||
}
|
||||
|
||||
const imageFilenames = imageFilenamesArray;
|
||||
|
||||
// Initialize location fields
|
||||
let latitude = null;
|
||||
@@ -301,6 +312,7 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res)
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Geocoding failed for item request", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
zipCode
|
||||
});
|
||||
// Continue without coordinates - post will still be created
|
||||
@@ -313,7 +325,7 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res)
|
||||
content,
|
||||
category,
|
||||
authorId: req.user.id,
|
||||
images,
|
||||
imageFilenames,
|
||||
zipCode: zipCode || null,
|
||||
latitude,
|
||||
longitude
|
||||
@@ -440,6 +452,7 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res)
|
||||
} catch (emailError) {
|
||||
logger.error("Failed to send item request notification", {
|
||||
error: emailError.message,
|
||||
stack: emailError.stack,
|
||||
recipientId: user.id,
|
||||
postId: post.id
|
||||
});
|
||||
@@ -481,13 +494,21 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res)
|
||||
authorId: req.user.id,
|
||||
postData: logger.sanitize(req.body)
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/forum/posts/:id - Update post
|
||||
router.put('/posts/:id', authenticateToken, async (req, res) => {
|
||||
router.put('/posts/:id', authenticateToken, async (req, res, next) => {
|
||||
try {
|
||||
// Require email verification
|
||||
if (!req.user.isVerified) {
|
||||
return res.status(403).json({
|
||||
error: "Please verify your email address before editing forum posts.",
|
||||
code: "EMAIL_NOT_VERIFIED"
|
||||
});
|
||||
}
|
||||
|
||||
const post = await ForumPost.findByPk(req.params.id);
|
||||
|
||||
if (!post) {
|
||||
@@ -498,9 +519,26 @@ router.put('/posts/:id', authenticateToken, async (req, res) => {
|
||||
return res.status(403).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const { title, content, category, tags } = req.body;
|
||||
const { title, content, category, tags, imageFilenames: rawImageFilenames } = req.body;
|
||||
|
||||
await post.update({ title, content, category });
|
||||
// Build update object
|
||||
const updateData = { title, content, category };
|
||||
|
||||
// Handle imageFilenames if provided
|
||||
if (rawImageFilenames !== undefined) {
|
||||
const imageFilenamesArray = Array.isArray(rawImageFilenames) ? rawImageFilenames : [];
|
||||
|
||||
const keyValidation = validateS3Keys(imageFilenamesArray, 'forum', { maxKeys: IMAGE_LIMITS.forum });
|
||||
if (!keyValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: keyValidation.error,
|
||||
details: keyValidation.invalidKeys
|
||||
});
|
||||
}
|
||||
updateData.imageFilenames = imageFilenamesArray;
|
||||
}
|
||||
|
||||
await post.update(updateData);
|
||||
|
||||
// Update tags if provided
|
||||
if (tags !== undefined) {
|
||||
@@ -549,12 +587,12 @@ router.put('/posts/:id', authenticateToken, async (req, res) => {
|
||||
postId: req.params.id,
|
||||
authorId: req.user.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/forum/posts/:id - Delete post
|
||||
router.delete('/posts/:id', authenticateToken, async (req, res) => {
|
||||
router.delete('/posts/:id', authenticateToken, async (req, res, next) => {
|
||||
try {
|
||||
const post = await ForumPost.findByPk(req.params.id);
|
||||
|
||||
@@ -586,12 +624,12 @@ router.delete('/posts/:id', authenticateToken, async (req, res) => {
|
||||
postId: req.params.id,
|
||||
authorId: req.user.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/forum/posts/:id/status - Update post status
|
||||
router.patch('/posts/:id/status', authenticateToken, async (req, res) => {
|
||||
router.patch('/posts/:id/status', authenticateToken, async (req, res, next) => {
|
||||
try {
|
||||
const { status } = req.body;
|
||||
const post = await ForumPost.findByPk(req.params.id);
|
||||
@@ -692,7 +730,7 @@ router.patch('/posts/:id/status', authenticateToken, async (req, res) => {
|
||||
stack: emailError.stack,
|
||||
postId: req.params.id
|
||||
});
|
||||
console.error("Email notification error:", emailError);
|
||||
logger.error("Email notification error", { error: emailError });
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -734,12 +772,12 @@ router.patch('/posts/:id/status', authenticateToken, async (req, res) => {
|
||||
postId: req.params.id,
|
||||
authorId: req.user.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/forum/posts/:id/accept-answer - Mark/unmark comment as accepted answer
|
||||
router.patch('/posts/:id/accept-answer', authenticateToken, async (req, res) => {
|
||||
router.patch('/posts/:id/accept-answer', authenticateToken, async (req, res, next) => {
|
||||
try {
|
||||
const { commentId } = req.body;
|
||||
const post = await ForumPost.findByPk(req.params.id);
|
||||
@@ -872,7 +910,7 @@ router.patch('/posts/:id/accept-answer', authenticateToken, async (req, res) =>
|
||||
commentId: commentId,
|
||||
postId: req.params.id
|
||||
});
|
||||
console.error("Email notification error:", emailError);
|
||||
logger.error("Email notification error", { error: emailError });
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -908,14 +946,24 @@ router.patch('/posts/:id/accept-answer', authenticateToken, async (req, res) =>
|
||||
postId: req.params.id,
|
||||
authorId: req.user.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/forum/posts/:id/comments - Add comment/reply
|
||||
router.post('/posts/:id/comments', authenticateToken, uploadForumCommentImages, async (req, res) => {
|
||||
router.post('/posts/:id/comments', authenticateToken, async (req, res, next) => {
|
||||
try {
|
||||
const { content, parentCommentId } = req.body;
|
||||
// Require email verification
|
||||
if (!req.user.isVerified) {
|
||||
return res.status(403).json({
|
||||
error: "Please verify your email address before commenting.",
|
||||
code: "EMAIL_NOT_VERIFIED"
|
||||
});
|
||||
}
|
||||
|
||||
// Support both parentId (new) and parentCommentId (legacy) for backwards compatibility
|
||||
const { content, parentId, parentCommentId, imageFilenames: rawImageFilenames } = req.body;
|
||||
const parentIdResolved = parentId || parentCommentId;
|
||||
const post = await ForumPost.findByPk(req.params.id);
|
||||
|
||||
if (!post) {
|
||||
@@ -928,22 +976,32 @@ router.post('/posts/:id/comments', authenticateToken, uploadForumCommentImages,
|
||||
}
|
||||
|
||||
// Validate parent comment if provided
|
||||
if (parentCommentId) {
|
||||
const parentComment = await ForumComment.findByPk(parentCommentId);
|
||||
if (parentIdResolved) {
|
||||
const parentComment = await ForumComment.findByPk(parentIdResolved);
|
||||
if (!parentComment || parentComment.postId !== post.id) {
|
||||
return res.status(400).json({ error: 'Invalid parent comment' });
|
||||
}
|
||||
}
|
||||
|
||||
// Extract image filenames if uploaded
|
||||
const images = req.files ? req.files.map(file => file.filename) : [];
|
||||
// Ensure imageFilenames is an array and validate S3 keys
|
||||
const imageFilenamesArray = Array.isArray(rawImageFilenames) ? rawImageFilenames : [];
|
||||
|
||||
const keyValidation = validateS3Keys(imageFilenamesArray, 'forum', { maxKeys: IMAGE_LIMITS.forum });
|
||||
if (!keyValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: keyValidation.error,
|
||||
details: keyValidation.invalidKeys
|
||||
});
|
||||
}
|
||||
|
||||
const imageFilenames = imageFilenamesArray;
|
||||
|
||||
const comment = await ForumComment.create({
|
||||
postId: req.params.id,
|
||||
authorId: req.user.id,
|
||||
content,
|
||||
parentCommentId: parentCommentId || null,
|
||||
images
|
||||
parentCommentId: parentIdResolved || null,
|
||||
imageFilenames
|
||||
});
|
||||
|
||||
// Increment comment count and update post's updatedAt to reflect activity
|
||||
@@ -955,7 +1013,7 @@ router.post('/posts/:id/comments', authenticateToken, uploadForumCommentImages,
|
||||
{
|
||||
model: User,
|
||||
as: 'author',
|
||||
attributes: ['id', 'username', 'firstName', 'lastName', 'email']
|
||||
attributes: ['id', 'firstName', 'lastName', 'email']
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -1052,7 +1110,7 @@ router.post('/posts/:id/comments', authenticateToken, uploadForumCommentImages,
|
||||
commentId: comment.id,
|
||||
postId: req.params.id
|
||||
});
|
||||
console.error("Email notification error:", emailError);
|
||||
logger.error("Email notification error", { error: emailError });
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -1073,14 +1131,22 @@ router.post('/posts/:id/comments', authenticateToken, uploadForumCommentImages,
|
||||
postId: req.params.id,
|
||||
authorId: req.user.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/forum/comments/:id - Edit comment
|
||||
router.put('/comments/:id', authenticateToken, async (req, res) => {
|
||||
router.put('/comments/:id', authenticateToken, async (req, res, next) => {
|
||||
try {
|
||||
const { content } = req.body;
|
||||
// Require email verification
|
||||
if (!req.user.isVerified) {
|
||||
return res.status(403).json({
|
||||
error: "Please verify your email address before editing comments.",
|
||||
code: "EMAIL_NOT_VERIFIED"
|
||||
});
|
||||
}
|
||||
|
||||
const { content, imageFilenames: rawImageFilenames } = req.body;
|
||||
const comment = await ForumComment.findByPk(req.params.id);
|
||||
|
||||
if (!comment) {
|
||||
@@ -1095,7 +1161,19 @@ router.put('/comments/:id', authenticateToken, async (req, res) => {
|
||||
return res.status(400).json({ error: 'Cannot edit deleted comment' });
|
||||
}
|
||||
|
||||
await comment.update({ content });
|
||||
const updateData = { content };
|
||||
|
||||
// Handle image filenames if provided
|
||||
if (rawImageFilenames !== undefined) {
|
||||
const imageFilenamesArray = Array.isArray(rawImageFilenames) ? rawImageFilenames : [];
|
||||
const keyValidation = validateS3Keys(imageFilenamesArray, 'forum', { maxKeys: IMAGE_LIMITS.forum });
|
||||
if (!keyValidation.valid) {
|
||||
return res.status(400).json({ error: keyValidation.error });
|
||||
}
|
||||
updateData.imageFilenames = imageFilenamesArray;
|
||||
}
|
||||
|
||||
await comment.update(updateData);
|
||||
|
||||
const updatedComment = await ForumComment.findByPk(comment.id, {
|
||||
include: [
|
||||
@@ -1122,12 +1200,12 @@ router.put('/comments/:id', authenticateToken, async (req, res) => {
|
||||
commentId: req.params.id,
|
||||
authorId: req.user.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/forum/comments/:id - Soft delete comment
|
||||
router.delete('/comments/:id', authenticateToken, async (req, res) => {
|
||||
router.delete('/comments/:id', authenticateToken, async (req, res, next) => {
|
||||
try {
|
||||
const comment = await ForumComment.findByPk(req.params.id);
|
||||
|
||||
@@ -1164,12 +1242,12 @@ router.delete('/comments/:id', authenticateToken, async (req, res) => {
|
||||
commentId: req.params.id,
|
||||
authorId: req.user.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/forum/my-posts - Get user's posts
|
||||
router.get('/my-posts', authenticateToken, async (req, res) => {
|
||||
router.get('/my-posts', authenticateToken, async (req, res, next) => {
|
||||
try {
|
||||
const posts = await ForumPost.findAll({
|
||||
where: { authorId: req.user.id },
|
||||
@@ -1202,12 +1280,12 @@ router.get('/my-posts', authenticateToken, async (req, res) => {
|
||||
stack: error.stack,
|
||||
userId: req.user.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/forum/tags - Get all unique tags for autocomplete
|
||||
router.get('/tags', async (req, res) => {
|
||||
router.get('/tags', async (req, res, next) => {
|
||||
try {
|
||||
const { search } = req.query;
|
||||
|
||||
@@ -1241,14 +1319,14 @@ router.get('/tags', async (req, res) => {
|
||||
stack: error.stack,
|
||||
query: req.query
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// ============ ADMIN ROUTES ============
|
||||
|
||||
// DELETE /api/forum/admin/posts/:id - Admin soft-delete post
|
||||
router.delete('/admin/posts/:id', authenticateToken, requireAdmin, async (req, res) => {
|
||||
router.delete('/admin/posts/:id', authenticateToken, requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const { reason } = req.body;
|
||||
|
||||
@@ -1261,7 +1339,7 @@ router.delete('/admin/posts/:id', authenticateToken, requireAdmin, async (req, r
|
||||
{
|
||||
model: User,
|
||||
as: 'author',
|
||||
attributes: ['id', 'username', 'firstName', 'lastName', 'email']
|
||||
attributes: ['id', 'firstName', 'lastName', 'email']
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -1308,7 +1386,12 @@ router.delete('/admin/posts/:id', authenticateToken, requireAdmin, async (req, r
|
||||
}
|
||||
} catch (emailError) {
|
||||
// Log but don't fail the deletion
|
||||
console.error('Failed to send forum post deletion notification email:', emailError.message);
|
||||
logger.error('Failed to send forum post deletion notification email', {
|
||||
error: emailError.message,
|
||||
stack: emailError.stack,
|
||||
postId: post.id,
|
||||
authorId: post.authorId
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -1321,12 +1404,12 @@ router.delete('/admin/posts/:id', authenticateToken, requireAdmin, async (req, r
|
||||
postId: req.params.id,
|
||||
adminId: req.user.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/forum/admin/posts/:id/restore - Admin restore deleted post
|
||||
router.patch('/admin/posts/:id/restore', authenticateToken, requireAdmin, async (req, res) => {
|
||||
router.patch('/admin/posts/:id/restore', authenticateToken, requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const post = await ForumPost.findByPk(req.params.id);
|
||||
|
||||
@@ -1362,12 +1445,12 @@ router.patch('/admin/posts/:id/restore', authenticateToken, requireAdmin, async
|
||||
postId: req.params.id,
|
||||
adminId: req.user.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/forum/admin/comments/:id - Admin soft-delete comment
|
||||
router.delete('/admin/comments/:id', authenticateToken, requireAdmin, async (req, res) => {
|
||||
router.delete('/admin/comments/:id', authenticateToken, requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const { reason } = req.body;
|
||||
|
||||
@@ -1380,7 +1463,7 @@ router.delete('/admin/comments/:id', authenticateToken, requireAdmin, async (req
|
||||
{
|
||||
model: User,
|
||||
as: 'author',
|
||||
attributes: ['id', 'username', 'firstName', 'lastName', 'email']
|
||||
attributes: ['id', 'firstName', 'lastName', 'email']
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -1436,7 +1519,12 @@ router.delete('/admin/comments/:id', authenticateToken, requireAdmin, async (req
|
||||
}
|
||||
} catch (emailError) {
|
||||
// Log but don't fail the deletion
|
||||
console.error('Failed to send forum comment deletion notification email:', emailError.message);
|
||||
logger.error('Failed to send forum comment deletion notification email', {
|
||||
error: emailError.message,
|
||||
stack: emailError.stack,
|
||||
commentId: comment.id,
|
||||
authorId: comment.authorId
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -1449,12 +1537,12 @@ router.delete('/admin/comments/:id', authenticateToken, requireAdmin, async (req
|
||||
commentId: req.params.id,
|
||||
adminId: req.user.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/forum/admin/comments/:id/restore - Admin restore deleted comment
|
||||
router.patch('/admin/comments/:id/restore', authenticateToken, requireAdmin, async (req, res) => {
|
||||
router.patch('/admin/comments/:id/restore', authenticateToken, requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const comment = await ForumComment.findByPk(req.params.id);
|
||||
|
||||
@@ -1500,19 +1588,19 @@ router.patch('/admin/comments/:id/restore', authenticateToken, requireAdmin, asy
|
||||
commentId: req.params.id,
|
||||
adminId: req.user.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/forum/admin/posts/:id/close - Admin close discussion
|
||||
router.patch('/admin/posts/:id/close', authenticateToken, requireAdmin, async (req, res) => {
|
||||
router.patch('/admin/posts/:id/close', authenticateToken, requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const post = await ForumPost.findByPk(req.params.id, {
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'author',
|
||||
attributes: ['id', 'username', 'firstName', 'lastName', 'email']
|
||||
attributes: ['id', 'firstName', 'lastName', 'email']
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -1545,7 +1633,7 @@ router.patch('/admin/posts/:id/close', authenticateToken, requireAdmin, async (r
|
||||
(async () => {
|
||||
try {
|
||||
const admin = await User.findByPk(req.user.id, {
|
||||
attributes: ['id', 'username', 'firstName', 'lastName', 'email']
|
||||
attributes: ['id', 'firstName', 'lastName', 'email']
|
||||
});
|
||||
|
||||
// Get all unique participants (author + commenters)
|
||||
@@ -1602,7 +1690,7 @@ router.patch('/admin/posts/:id/close', authenticateToken, requireAdmin, async (r
|
||||
stack: emailError.stack,
|
||||
postId: req.params.id
|
||||
});
|
||||
console.error("Email notification error:", emailError);
|
||||
logger.error("Email notification error", { error: emailError });
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -1615,12 +1703,12 @@ router.patch('/admin/posts/:id/close', authenticateToken, requireAdmin, async (r
|
||||
postId: req.params.id,
|
||||
adminId: req.user.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/forum/admin/posts/:id/reopen - Admin reopen discussion
|
||||
router.patch('/admin/posts/:id/reopen', authenticateToken, requireAdmin, async (req, res) => {
|
||||
router.patch('/admin/posts/:id/reopen', authenticateToken, requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const post = await ForumPost.findByPk(req.params.id);
|
||||
|
||||
@@ -1655,7 +1743,7 @@ router.patch('/admin/posts/:id/reopen', authenticateToken, requireAdmin, async (
|
||||
postId: req.params.id,
|
||||
adminId: req.user.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
121
backend/routes/health.js
Normal file
121
backend/routes/health.js
Normal file
@@ -0,0 +1,121 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const { sequelize } = require("../models");
|
||||
const s3Service = require("../services/s3Service");
|
||||
const logger = require("../utils/logger");
|
||||
|
||||
/**
|
||||
* Health check endpoint for load balancers and monitoring
|
||||
* GET /health
|
||||
*
|
||||
* Returns:
|
||||
* - 200: All services healthy
|
||||
* - 503: One or more services unhealthy
|
||||
*/
|
||||
router.get("/", async (req, res) => {
|
||||
const startTime = Date.now();
|
||||
const checks = {
|
||||
database: { status: "unknown", latency: null },
|
||||
s3: { status: "unknown", latency: null },
|
||||
};
|
||||
|
||||
let allHealthy = true;
|
||||
|
||||
// Database health check
|
||||
try {
|
||||
const dbStart = Date.now();
|
||||
await sequelize.authenticate();
|
||||
checks.database = {
|
||||
status: "healthy",
|
||||
latency: Date.now() - dbStart,
|
||||
};
|
||||
} catch (error) {
|
||||
allHealthy = false;
|
||||
checks.database = {
|
||||
status: "unhealthy",
|
||||
error: error.message,
|
||||
latency: Date.now() - startTime,
|
||||
};
|
||||
logger.error("Health check: Database connection failed", {
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
// S3 health check (if enabled)
|
||||
if (s3Service.isEnabled()) {
|
||||
try {
|
||||
const s3Start = Date.now();
|
||||
// S3 is considered healthy if it's properly initialized
|
||||
// A more thorough check could list bucket contents, but that adds latency
|
||||
checks.s3 = {
|
||||
status: "healthy",
|
||||
latency: Date.now() - s3Start,
|
||||
bucket: process.env.S3_BUCKET,
|
||||
};
|
||||
} catch (error) {
|
||||
allHealthy = false;
|
||||
checks.s3 = {
|
||||
status: "unhealthy",
|
||||
error: error.message,
|
||||
latency: Date.now() - startTime,
|
||||
};
|
||||
logger.error("Health check: S3 check failed", {
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
checks.s3 = {
|
||||
status: "disabled",
|
||||
latency: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Log unhealthy states
|
||||
if (!allHealthy) {
|
||||
logger.warn("Health check failed", { checks });
|
||||
}
|
||||
|
||||
res.status(allHealthy ? 200 : 503).json({
|
||||
status: allHealthy ? "healthy" : "unhealthy",
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Liveness probe - simple check that the process is running
|
||||
* GET /health/live
|
||||
*
|
||||
* Used by Kubernetes/ECS for liveness probes
|
||||
* Returns 200 if the process is alive
|
||||
*/
|
||||
router.get("/live", (req, res) => {
|
||||
res.status(200).json({
|
||||
status: "alive",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Readiness probe - check if the service is ready to accept traffic
|
||||
* GET /health/ready
|
||||
*
|
||||
* Used by load balancers to determine if instance should receive traffic
|
||||
* Checks critical dependencies (database)
|
||||
*/
|
||||
router.get("/ready", async (req, res) => {
|
||||
try {
|
||||
await sequelize.authenticate();
|
||||
res.status(200).json({
|
||||
status: "ready",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Readiness check failed", { error: error.message, stack: error.stack });
|
||||
res.status(503).json({
|
||||
status: "not_ready",
|
||||
timestamp: new Date().toISOString(),
|
||||
error: "Database connection failed",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,11 +1,60 @@
|
||||
const express = require("express");
|
||||
const { Op } = require("sequelize");
|
||||
const { Item, User, Rental } = require("../models"); // Import from models/index.js to get models with associations
|
||||
const { Op, Sequelize } = require("sequelize");
|
||||
const { Item, User, Rental, sequelize } = require("../models"); // Import from models/index.js to get models with associations
|
||||
const { authenticateToken, requireVerifiedEmail, requireAdmin, optionalAuth } = require("../middleware/auth");
|
||||
const { validateCoordinatesQuery, validateCoordinatesBody, handleValidationErrors } = require("../middleware/validation");
|
||||
const logger = require("../utils/logger");
|
||||
const { validateS3Keys } = require("../utils/s3KeyValidator");
|
||||
const { IMAGE_LIMITS } = require("../config/imageLimits");
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/", async (req, res) => {
|
||||
// Allowed fields for item create/update (prevents mass assignment)
|
||||
const ALLOWED_ITEM_FIELDS = [
|
||||
'name',
|
||||
'description',
|
||||
'pickUpAvailable',
|
||||
'localDeliveryAvailable',
|
||||
'localDeliveryRadius',
|
||||
'shippingAvailable',
|
||||
'inPlaceUseAvailable',
|
||||
'pricePerHour',
|
||||
'pricePerDay',
|
||||
'pricePerWeek',
|
||||
'pricePerMonth',
|
||||
'replacementCost',
|
||||
'address1',
|
||||
'address2',
|
||||
'city',
|
||||
'state',
|
||||
'zipCode',
|
||||
'country',
|
||||
'latitude',
|
||||
'longitude',
|
||||
'imageFilenames',
|
||||
'isAvailable',
|
||||
'rules',
|
||||
'availableAfter',
|
||||
'availableBefore',
|
||||
'specifyTimesPerDay',
|
||||
'weeklyTimes',
|
||||
];
|
||||
|
||||
/**
|
||||
* Extract only allowed fields from request body
|
||||
* @param {Object} body - Request body
|
||||
* @returns {Object} - Object with only allowed fields
|
||||
*/
|
||||
function extractAllowedFields(body) {
|
||||
const result = {};
|
||||
for (const field of ALLOWED_ITEM_FIELDS) {
|
||||
if (body[field] !== undefined) {
|
||||
result[field] = body[field];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
router.get("/", validateCoordinatesQuery, async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
minPrice,
|
||||
@@ -13,6 +62,9 @@ router.get("/", async (req, res) => {
|
||||
city,
|
||||
zipCode,
|
||||
search,
|
||||
lat,
|
||||
lng,
|
||||
radius = 25,
|
||||
page = 1,
|
||||
limit = 20,
|
||||
} = req.query;
|
||||
@@ -26,8 +78,50 @@ router.get("/", async (req, res) => {
|
||||
if (minPrice) where.pricePerDay[Op.gte] = minPrice;
|
||||
if (maxPrice) where.pricePerDay[Op.lte] = maxPrice;
|
||||
}
|
||||
if (city) where.city = { [Op.iLike]: `%${city}%` };
|
||||
if (zipCode) where.zipCode = { [Op.iLike]: `%${zipCode}%` };
|
||||
|
||||
// Location filtering: Radius search OR city/ZIP fallback
|
||||
if (lat && lng) {
|
||||
// Parse and validate coordinates
|
||||
const latNum = parseFloat(lat);
|
||||
const lngNum = parseFloat(lng);
|
||||
const radiusNum = parseFloat(radius);
|
||||
|
||||
if (!isNaN(latNum) && !isNaN(lngNum) && !isNaN(radiusNum)) {
|
||||
// Bounding box pre-filter (fast, uses indexes)
|
||||
// ~69 miles per degree latitude, longitude varies by latitude
|
||||
const latDelta = radiusNum / 69;
|
||||
const lngDelta = radiusNum / (69 * Math.cos(latNum * Math.PI / 180));
|
||||
|
||||
where.latitude = {
|
||||
[Op.and]: [
|
||||
{ [Op.gte]: latNum - latDelta },
|
||||
{ [Op.lte]: latNum + latDelta },
|
||||
{ [Op.ne]: null }
|
||||
]
|
||||
};
|
||||
where.longitude = {
|
||||
[Op.and]: [
|
||||
{ [Op.gte]: lngNum - lngDelta },
|
||||
{ [Op.lte]: lngNum + lngDelta },
|
||||
{ [Op.ne]: null }
|
||||
]
|
||||
};
|
||||
|
||||
// Haversine formula for exact distance (applied after bounding box)
|
||||
// 3959 = Earth's radius in miles
|
||||
where[Op.and] = sequelize.literal(`
|
||||
(3959 * acos(
|
||||
cos(radians(${latNum})) * cos(radians("Item"."latitude")) *
|
||||
cos(radians("Item"."longitude") - radians(${lngNum})) +
|
||||
sin(radians(${latNum})) * sin(radians("Item"."latitude"))
|
||||
)) <= ${radiusNum}
|
||||
`);
|
||||
}
|
||||
} else {
|
||||
// Fallback to city/ZIP string matching
|
||||
if (city) where.city = { [Op.iLike]: `%${city}%` };
|
||||
if (zipCode) where.zipCode = { [Op.iLike]: `%${zipCode}%` };
|
||||
}
|
||||
if (search) {
|
||||
where[Op.or] = [
|
||||
{ name: { [Op.iLike]: `%${search}%` } },
|
||||
@@ -43,7 +137,11 @@ router.get("/", async (req, res) => {
|
||||
{
|
||||
model: User,
|
||||
as: "owner",
|
||||
attributes: ["id", "firstName", "lastName"],
|
||||
attributes: ["id", "firstName", "lastName", "imageFilename"],
|
||||
where: {
|
||||
isBanned: { [Op.ne]: true }
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
limit: parseInt(limit),
|
||||
@@ -65,7 +163,7 @@ router.get("/", async (req, res) => {
|
||||
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Items search completed", {
|
||||
filters: { minPrice, maxPrice, city, zipCode, search },
|
||||
filters: { minPrice, maxPrice, city, zipCode, search, lat, lng, radius },
|
||||
resultsCount: count,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit)
|
||||
@@ -84,11 +182,11 @@ router.get("/", async (req, res) => {
|
||||
stack: error.stack,
|
||||
query: req.query
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/recommendations", authenticateToken, async (req, res) => {
|
||||
router.get("/recommendations", authenticateToken, async (req, res, next) => {
|
||||
try {
|
||||
const userRentals = await Rental.findAll({
|
||||
where: { renterId: req.user.id },
|
||||
@@ -119,12 +217,12 @@ router.get("/recommendations", authenticateToken, async (req, res) => {
|
||||
stack: error.stack,
|
||||
userId: req.user.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Public endpoint to get reviews for a specific item (must come before /:id route)
|
||||
router.get('/:id/reviews', async (req, res) => {
|
||||
router.get('/:id/reviews', async (req, res, next) => {
|
||||
try {
|
||||
const { Rental, User } = require('../models');
|
||||
|
||||
@@ -140,7 +238,7 @@ router.get('/:id/reviews', async (req, res) => {
|
||||
{
|
||||
model: User,
|
||||
as: 'renter',
|
||||
attributes: ['id', 'firstName', 'lastName']
|
||||
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
|
||||
}
|
||||
],
|
||||
order: [['createdAt', 'DESC']]
|
||||
@@ -169,18 +267,18 @@ router.get('/:id/reviews', async (req, res) => {
|
||||
stack: error.stack,
|
||||
itemId: req.params.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/:id", optionalAuth, async (req, res) => {
|
||||
router.get("/:id", optionalAuth, async (req, res, next) => {
|
||||
try {
|
||||
const item = await Item.findByPk(req.params.id, {
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: "owner",
|
||||
attributes: ["id", "firstName", "lastName"],
|
||||
attributes: ["id", "firstName", "lastName", "imageFilename"],
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
@@ -226,14 +324,57 @@ router.get("/:id", optionalAuth, async (req, res) => {
|
||||
stack: error.stack,
|
||||
itemId: req.params.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
|
||||
router.post("/", authenticateToken, requireVerifiedEmail, ...validateCoordinatesBody, handleValidationErrors, async (req, res, next) => {
|
||||
try {
|
||||
// Extract only allowed fields (prevents mass assignment)
|
||||
const allowedData = extractAllowedFields(req.body);
|
||||
|
||||
// Validate imageFilenames - at least one image is required
|
||||
const imageFilenames = Array.isArray(allowedData.imageFilenames)
|
||||
? allowedData.imageFilenames
|
||||
: [];
|
||||
|
||||
if (imageFilenames.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: "At least one image is required to create a listing"
|
||||
});
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!allowedData.name || !allowedData.name.trim()) {
|
||||
return res.status(400).json({ error: "Item name is required" });
|
||||
}
|
||||
if (!allowedData.address1 || !allowedData.address1.trim()) {
|
||||
return res.status(400).json({ error: "Address is required" });
|
||||
}
|
||||
if (!allowedData.city || !allowedData.city.trim()) {
|
||||
return res.status(400).json({ error: "City is required" });
|
||||
}
|
||||
if (!allowedData.state || !allowedData.state.trim()) {
|
||||
return res.status(400).json({ error: "State is required" });
|
||||
}
|
||||
if (!allowedData.zipCode || !allowedData.zipCode.trim()) {
|
||||
return res.status(400).json({ error: "ZIP code is required" });
|
||||
}
|
||||
if (!allowedData.replacementCost || Number(allowedData.replacementCost) <= 0) {
|
||||
return res.status(400).json({ error: "Replacement cost is required" });
|
||||
}
|
||||
|
||||
const keyValidation = validateS3Keys(imageFilenames, 'items', { maxKeys: IMAGE_LIMITS.items });
|
||||
if (!keyValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: keyValidation.error,
|
||||
details: keyValidation.invalidKeys
|
||||
});
|
||||
}
|
||||
allowedData.imageFilenames = imageFilenames;
|
||||
|
||||
const item = await Item.create({
|
||||
...req.body,
|
||||
...allowedData,
|
||||
ownerId: req.user.id,
|
||||
});
|
||||
|
||||
@@ -260,10 +401,17 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
|
||||
itemWithOwner.owner,
|
||||
itemWithOwner
|
||||
);
|
||||
console.log(`First listing celebration email sent to owner ${req.user.id}`);
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("First listing celebration email sent", { ownerId: req.user.id });
|
||||
} catch (emailError) {
|
||||
// Log but don't fail the item creation
|
||||
console.error('Failed to send first listing celebration email:', emailError.message);
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error('Failed to send first listing celebration email', {
|
||||
error: emailError.message,
|
||||
stack: emailError.stack,
|
||||
ownerId: req.user.id,
|
||||
itemId: item.id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,11 +432,11 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
|
||||
ownerId: req.user.id,
|
||||
itemData: logger.sanitize(req.body)
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put("/:id", authenticateToken, async (req, res) => {
|
||||
router.put("/:id", authenticateToken, ...validateCoordinatesBody, handleValidationErrors, async (req, res, next) => {
|
||||
try {
|
||||
const item = await Item.findByPk(req.params.id);
|
||||
|
||||
@@ -300,7 +448,53 @@ router.put("/:id", authenticateToken, async (req, res) => {
|
||||
return res.status(403).json({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
await item.update(req.body);
|
||||
// Extract only allowed fields (prevents mass assignment)
|
||||
const allowedData = extractAllowedFields(req.body);
|
||||
|
||||
// Validate imageFilenames if provided
|
||||
if (allowedData.imageFilenames !== undefined) {
|
||||
const imageFilenames = Array.isArray(allowedData.imageFilenames)
|
||||
? allowedData.imageFilenames
|
||||
: [];
|
||||
|
||||
// Require at least one image
|
||||
if (imageFilenames.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: "At least one image is required for a listing"
|
||||
});
|
||||
}
|
||||
|
||||
const keyValidation = validateS3Keys(imageFilenames, 'items', { maxKeys: IMAGE_LIMITS.items });
|
||||
if (!keyValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: keyValidation.error,
|
||||
details: keyValidation.invalidKeys
|
||||
});
|
||||
}
|
||||
allowedData.imageFilenames = imageFilenames;
|
||||
}
|
||||
|
||||
// Validate required fields if they are being updated
|
||||
if (allowedData.name !== undefined && (!allowedData.name || !allowedData.name.trim())) {
|
||||
return res.status(400).json({ error: "Item name is required" });
|
||||
}
|
||||
if (allowedData.address1 !== undefined && (!allowedData.address1 || !allowedData.address1.trim())) {
|
||||
return res.status(400).json({ error: "Address is required" });
|
||||
}
|
||||
if (allowedData.city !== undefined && (!allowedData.city || !allowedData.city.trim())) {
|
||||
return res.status(400).json({ error: "City is required" });
|
||||
}
|
||||
if (allowedData.state !== undefined && (!allowedData.state || !allowedData.state.trim())) {
|
||||
return res.status(400).json({ error: "State is required" });
|
||||
}
|
||||
if (allowedData.zipCode !== undefined && (!allowedData.zipCode || !allowedData.zipCode.trim())) {
|
||||
return res.status(400).json({ error: "ZIP code is required" });
|
||||
}
|
||||
if (allowedData.replacementCost !== undefined && (!allowedData.replacementCost || Number(allowedData.replacementCost) <= 0)) {
|
||||
return res.status(400).json({ error: "Replacement cost is required" });
|
||||
}
|
||||
|
||||
await item.update(allowedData);
|
||||
|
||||
const updatedItem = await Item.findByPk(item.id, {
|
||||
include: [
|
||||
@@ -327,11 +521,11 @@ router.put("/:id", authenticateToken, async (req, res) => {
|
||||
itemId: req.params.id,
|
||||
ownerId: req.user.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/:id", authenticateToken, async (req, res) => {
|
||||
router.delete("/:id", authenticateToken, async (req, res, next) => {
|
||||
try {
|
||||
const item = await Item.findByPk(req.params.id);
|
||||
|
||||
@@ -360,12 +554,12 @@ router.delete("/:id", authenticateToken, async (req, res) => {
|
||||
itemId: req.params.id,
|
||||
ownerId: req.user.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Admin endpoints
|
||||
router.delete("/admin/:id", authenticateToken, requireAdmin, async (req, res) => {
|
||||
router.delete("/admin/:id", authenticateToken, requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const { reason } = req.body;
|
||||
|
||||
@@ -440,10 +634,15 @@ router.delete("/admin/:id", authenticateToken, requireAdmin, async (req, res) =>
|
||||
item,
|
||||
reason.trim()
|
||||
);
|
||||
console.log(`Item deletion notification email sent to owner ${item.ownerId}`);
|
||||
logger.info("Item deletion notification email sent", { ownerId: item.ownerId, itemId: item.id });
|
||||
} catch (emailError) {
|
||||
// Log but don't fail the deletion
|
||||
console.error('Failed to send item deletion notification email:', emailError.message);
|
||||
logger.error('Failed to send item deletion notification email', {
|
||||
error: emailError.message,
|
||||
stack: emailError.stack,
|
||||
ownerId: item.ownerId,
|
||||
itemId: item.id
|
||||
});
|
||||
}
|
||||
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
@@ -463,11 +662,11 @@ router.delete("/admin/:id", authenticateToken, requireAdmin, async (req, res) =>
|
||||
itemId: req.params.id,
|
||||
adminId: req.user.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch("/admin/:id/restore", authenticateToken, requireAdmin, async (req, res) => {
|
||||
router.patch("/admin/:id/restore", authenticateToken, requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const item = await Item.findByPk(req.params.id);
|
||||
|
||||
@@ -513,7 +712,7 @@ router.patch("/admin/:id/restore", authenticateToken, requireAdmin, async (req,
|
||||
itemId: req.params.id,
|
||||
adminId: req.user.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
const express = require('express');
|
||||
const helmet = require('helmet');
|
||||
const { Message, User } = require('../models');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const { uploadMessageImage } = require('../middleware/upload');
|
||||
const logger = require('../utils/logger');
|
||||
const { emitNewMessage, emitMessageRead } = require('../sockets/messageSocket');
|
||||
const { Op } = require('sequelize');
|
||||
const emailServices = require('../services/email');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { validateS3Keys } = require('../utils/s3KeyValidator');
|
||||
const { IMAGE_LIMITS } = require('../config/imageLimits');
|
||||
const router = express.Router();
|
||||
|
||||
// Get all messages for the current user (inbox)
|
||||
router.get('/', authenticateToken, async (req, res) => {
|
||||
router.get('/', authenticateToken, async (req, res, next) => {
|
||||
try {
|
||||
const messages = await Message.findAll({
|
||||
where: { receiverId: req.user.id },
|
||||
@@ -20,7 +18,7 @@ router.get('/', authenticateToken, async (req, res) => {
|
||||
{
|
||||
model: User,
|
||||
as: 'sender',
|
||||
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
||||
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
|
||||
}
|
||||
],
|
||||
order: [['createdAt', 'DESC']]
|
||||
@@ -40,12 +38,12 @@ router.get('/', authenticateToken, async (req, res) => {
|
||||
stack: error.stack,
|
||||
userId: req.user.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get conversations grouped by user pairs
|
||||
router.get('/conversations', authenticateToken, async (req, res) => {
|
||||
router.get('/conversations', authenticateToken, async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
|
||||
@@ -61,12 +59,12 @@ router.get('/conversations', authenticateToken, async (req, res) => {
|
||||
{
|
||||
model: User,
|
||||
as: 'sender',
|
||||
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
||||
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: 'receiver',
|
||||
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
||||
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
|
||||
}
|
||||
],
|
||||
order: [['createdAt', 'DESC']]
|
||||
@@ -134,12 +132,12 @@ router.get('/conversations', authenticateToken, async (req, res) => {
|
||||
stack: error.stack,
|
||||
userId: req.user.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get sent messages
|
||||
router.get('/sent', authenticateToken, async (req, res) => {
|
||||
router.get('/sent', authenticateToken, async (req, res, next) => {
|
||||
try {
|
||||
const messages = await Message.findAll({
|
||||
where: { senderId: req.user.id },
|
||||
@@ -147,7 +145,7 @@ router.get('/sent', authenticateToken, async (req, res) => {
|
||||
{
|
||||
model: User,
|
||||
as: 'receiver',
|
||||
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
||||
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
|
||||
}
|
||||
],
|
||||
order: [['createdAt', 'DESC']]
|
||||
@@ -167,12 +165,12 @@ router.get('/sent', authenticateToken, async (req, res) => {
|
||||
stack: error.stack,
|
||||
userId: req.user.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get a single message
|
||||
router.get('/:id', authenticateToken, async (req, res) => {
|
||||
router.get('/:id', authenticateToken, async (req, res, next) => {
|
||||
try {
|
||||
const message = await Message.findOne({
|
||||
where: {
|
||||
@@ -186,12 +184,12 @@ router.get('/:id', authenticateToken, async (req, res) => {
|
||||
{
|
||||
model: User,
|
||||
as: 'sender',
|
||||
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
||||
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: 'receiver',
|
||||
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
||||
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -232,14 +230,25 @@ router.get('/:id', authenticateToken, async (req, res) => {
|
||||
userId: req.user.id,
|
||||
messageId: req.params.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Send a new message
|
||||
router.post('/', authenticateToken, uploadMessageImage, async (req, res) => {
|
||||
router.post('/', authenticateToken, async (req, res, next) => {
|
||||
try {
|
||||
const { receiverId, content } = req.body;
|
||||
const { receiverId, content, imageFilename } = req.body;
|
||||
|
||||
// Validate imageFilename if provided
|
||||
if (imageFilename) {
|
||||
const keyValidation = validateS3Keys([imageFilename], 'messages', { maxKeys: IMAGE_LIMITS.messages });
|
||||
if (!keyValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: keyValidation.error,
|
||||
details: keyValidation.invalidKeys
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check if receiver exists
|
||||
const receiver = await User.findByPk(receiverId);
|
||||
@@ -252,21 +261,18 @@ router.post('/', authenticateToken, uploadMessageImage, async (req, res) => {
|
||||
return res.status(400).json({ error: 'Cannot send messages to yourself' });
|
||||
}
|
||||
|
||||
// Extract image filename if uploaded
|
||||
const imagePath = req.file ? req.file.filename : null;
|
||||
|
||||
const message = await Message.create({
|
||||
senderId: req.user.id,
|
||||
receiverId,
|
||||
content,
|
||||
imagePath
|
||||
imageFilename: imageFilename || null
|
||||
});
|
||||
|
||||
const messageWithSender = await Message.findByPk(message.id, {
|
||||
include: [{
|
||||
model: User,
|
||||
as: 'sender',
|
||||
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
||||
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
|
||||
}]
|
||||
});
|
||||
|
||||
@@ -288,6 +294,7 @@ router.post('/', authenticateToken, uploadMessageImage, async (req, res) => {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Failed to send message notification email", {
|
||||
error: emailError.message,
|
||||
stack: emailError.stack,
|
||||
messageId: message.id,
|
||||
receiverId: receiverId
|
||||
});
|
||||
@@ -307,14 +314,14 @@ router.post('/', authenticateToken, uploadMessageImage, async (req, res) => {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
senderId: req.user.id,
|
||||
receiverId: req.body.receiverId
|
||||
receiverId: req.body?.receiverId
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Mark message as read
|
||||
router.put('/:id/read', authenticateToken, async (req, res) => {
|
||||
router.put('/:id/read', authenticateToken, async (req, res, next) => {
|
||||
try {
|
||||
const message = await Message.findOne({
|
||||
where: {
|
||||
@@ -354,12 +361,12 @@ router.put('/:id/read', authenticateToken, async (req, res) => {
|
||||
userId: req.user.id,
|
||||
messageId: req.params.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get unread message count
|
||||
router.get('/unread/count', authenticateToken, async (req, res) => {
|
||||
router.get('/unread/count', authenticateToken, async (req, res, next) => {
|
||||
try {
|
||||
const count = await Message.count({
|
||||
where: {
|
||||
@@ -381,54 +388,7 @@ router.get('/unread/count', authenticateToken, async (req, res) => {
|
||||
stack: error.stack,
|
||||
userId: req.user.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 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 {
|
||||
const { filename } = req.params;
|
||||
|
||||
// Verify user is sender or receiver of a message with this image
|
||||
const message = await Message.findOne({
|
||||
where: {
|
||||
imagePath: 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,
|
||||
filename: req.params.filename
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to load image' });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,11 +2,13 @@ const express = require("express");
|
||||
const { authenticateToken, requireVerifiedEmail } = require("../middleware/auth");
|
||||
const { User, Item } = require("../models");
|
||||
const StripeService = require("../services/stripeService");
|
||||
const StripeWebhookService = require("../services/stripeWebhookService");
|
||||
const emailServices = require("../services/email");
|
||||
const logger = require("../utils/logger");
|
||||
const router = express.Router();
|
||||
|
||||
// Get checkout session status
|
||||
router.get("/checkout-session/:sessionId", async (req, res) => {
|
||||
router.get("/checkout-session/:sessionId", async (req, res, next) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
|
||||
@@ -32,14 +34,14 @@ router.get("/checkout-session/:sessionId", async (req, res) => {
|
||||
reqLogger.error("Stripe checkout session retrieval failed", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
sessionId: sessionId,
|
||||
sessionId: req.params.sessionId,
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Create connected account
|
||||
router.post("/accounts", authenticateToken, requireVerifiedEmail, async (req, res) => {
|
||||
router.post("/accounts", authenticateToken, requireVerifiedEmail, async (req, res, next) => {
|
||||
try {
|
||||
const user = await User.findByPk(req.user.id);
|
||||
|
||||
@@ -82,14 +84,15 @@ router.post("/accounts", authenticateToken, requireVerifiedEmail, async (req, re
|
||||
stack: error.stack,
|
||||
userId: req.user.id,
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Generate onboarding link
|
||||
router.post("/account-links", authenticateToken, requireVerifiedEmail, async (req, res) => {
|
||||
router.post("/account-links", authenticateToken, requireVerifiedEmail, async (req, res, next) => {
|
||||
let user = null;
|
||||
try {
|
||||
const user = await User.findByPk(req.user.id);
|
||||
user = await User.findByPk(req.user.id);
|
||||
|
||||
if (!user || !user.stripeConnectedAccountId) {
|
||||
return res.status(400).json({ error: "No connected account found" });
|
||||
@@ -128,14 +131,50 @@ router.post("/account-links", authenticateToken, requireVerifiedEmail, async (re
|
||||
userId: req.user.id,
|
||||
stripeConnectedAccountId: user?.stripeConnectedAccountId,
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get account status
|
||||
router.get("/account-status", authenticateToken, async (req, res) => {
|
||||
// Create account session for embedded onboarding
|
||||
router.post("/account-sessions", authenticateToken, requireVerifiedEmail, async (req, res, next) => {
|
||||
let user = null;
|
||||
try {
|
||||
const user = await User.findByPk(req.user.id);
|
||||
user = await User.findByPk(req.user.id);
|
||||
|
||||
if (!user || !user.stripeConnectedAccountId) {
|
||||
return res.status(400).json({ error: "No connected account found" });
|
||||
}
|
||||
|
||||
const accountSession = await StripeService.createAccountSession(
|
||||
user.stripeConnectedAccountId
|
||||
);
|
||||
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Stripe account session created", {
|
||||
userId: req.user.id,
|
||||
stripeConnectedAccountId: user.stripeConnectedAccountId,
|
||||
});
|
||||
|
||||
res.json({
|
||||
clientSecret: accountSession.client_secret,
|
||||
});
|
||||
} catch (error) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Stripe account session creation failed", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
userId: req.user.id,
|
||||
stripeConnectedAccountId: user?.stripeConnectedAccountId,
|
||||
});
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get account status with reconciliation
|
||||
router.get("/account-status", authenticateToken, async (req, res, next) => {
|
||||
let user = null;
|
||||
try {
|
||||
user = await User.findByPk(req.user.id);
|
||||
|
||||
if (!user || !user.stripeConnectedAccountId) {
|
||||
return res.status(400).json({ error: "No connected account found" });
|
||||
@@ -153,6 +192,64 @@ router.get("/account-status", authenticateToken, async (req, res) => {
|
||||
payoutsEnabled: accountStatus.payouts_enabled,
|
||||
});
|
||||
|
||||
// Reconciliation: Compare fetched status with stored User fields
|
||||
const previousPayoutsEnabled = user.stripePayoutsEnabled;
|
||||
const currentPayoutsEnabled = accountStatus.payouts_enabled;
|
||||
const requirements = accountStatus.requirements || {};
|
||||
|
||||
// Check if status has changed and needs updating
|
||||
const statusChanged =
|
||||
previousPayoutsEnabled !== currentPayoutsEnabled ||
|
||||
JSON.stringify(user.stripeRequirementsCurrentlyDue || []) !==
|
||||
JSON.stringify(requirements.currently_due || []);
|
||||
|
||||
if (statusChanged) {
|
||||
reqLogger.info("Reconciling account status from API call", {
|
||||
userId: req.user.id,
|
||||
previousPayoutsEnabled,
|
||||
currentPayoutsEnabled,
|
||||
previousCurrentlyDue: user.stripeRequirementsCurrentlyDue?.length || 0,
|
||||
newCurrentlyDue: requirements.currently_due?.length || 0,
|
||||
});
|
||||
|
||||
// Update user with current status
|
||||
await user.update({
|
||||
stripePayoutsEnabled: currentPayoutsEnabled,
|
||||
stripeRequirementsCurrentlyDue: requirements.currently_due || [],
|
||||
stripeRequirementsPastDue: requirements.past_due || [],
|
||||
stripeDisabledReason: requirements.disabled_reason || null,
|
||||
stripeRequirementsLastUpdated: new Date(),
|
||||
});
|
||||
|
||||
// If payouts just became disabled (true -> false), send notification
|
||||
if (!currentPayoutsEnabled && previousPayoutsEnabled) {
|
||||
reqLogger.warn("Payouts disabled detected during reconciliation", {
|
||||
userId: req.user.id,
|
||||
disabledReason: requirements.disabled_reason,
|
||||
});
|
||||
|
||||
try {
|
||||
const disabledReason = StripeWebhookService.formatDisabledReason(
|
||||
requirements.disabled_reason
|
||||
);
|
||||
|
||||
await emailServices.payment.sendPayoutsDisabledEmail(user.email, {
|
||||
ownerName: user.firstName || user.lastName,
|
||||
disabledReason,
|
||||
});
|
||||
|
||||
reqLogger.info("Sent payouts disabled email during reconciliation", {
|
||||
userId: req.user.id,
|
||||
});
|
||||
} catch (emailError) {
|
||||
reqLogger.error("Failed to send payouts disabled email", {
|
||||
userId: req.user.id,
|
||||
error: emailError.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
accountId: accountStatus.id,
|
||||
detailsSubmitted: accountStatus.details_submitted,
|
||||
@@ -168,7 +265,7 @@ router.get("/account-status", authenticateToken, async (req, res) => {
|
||||
userId: req.user.id,
|
||||
stripeConnectedAccountId: user?.stripeConnectedAccountId,
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -177,11 +274,12 @@ router.post(
|
||||
"/create-setup-checkout-session",
|
||||
authenticateToken,
|
||||
requireVerifiedEmail,
|
||||
async (req, res) => {
|
||||
async (req, res, next) => {
|
||||
let user = null;
|
||||
try {
|
||||
const { rentalData } = req.body;
|
||||
|
||||
const user = await User.findByPk(req.user.id);
|
||||
user = await User.findByPk(req.user.id);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
@@ -238,7 +336,7 @@ router.post(
|
||||
userId: req.user.id,
|
||||
stripeCustomerId: user?.stripeCustomerId,
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
119
backend/routes/stripeWebhooks.js
Normal file
119
backend/routes/stripeWebhooks.js
Normal file
@@ -0,0 +1,119 @@
|
||||
const express = require("express");
|
||||
const StripeWebhookService = require("../services/stripeWebhookService");
|
||||
const DisputeService = require("../services/disputeService");
|
||||
const logger = require("../utils/logger");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
|
||||
/**
|
||||
* POST /stripe/webhooks
|
||||
* Stripe webhook endpoint - receives events from Stripe.
|
||||
* Must use raw body for signature verification.
|
||||
*/
|
||||
router.post("/", async (req, res) => {
|
||||
const signature = req.headers["stripe-signature"];
|
||||
|
||||
if (!signature) {
|
||||
logger.warn("Webhook request missing stripe-signature header");
|
||||
return res.status(400).json({ error: "Missing signature" });
|
||||
}
|
||||
|
||||
if (!WEBHOOK_SECRET) {
|
||||
logger.error("STRIPE_WEBHOOK_SECRET not configured");
|
||||
return res.status(500).json({ error: "Webhook not configured" });
|
||||
}
|
||||
|
||||
let event;
|
||||
|
||||
try {
|
||||
// Use rawBody stored by bodyParser in server.js
|
||||
event = StripeWebhookService.constructEvent(
|
||||
req.rawBody,
|
||||
signature,
|
||||
WEBHOOK_SECRET
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error("Webhook signature verification failed", {
|
||||
error: err.message,
|
||||
});
|
||||
return res.status(400).json({ error: "Invalid signature" });
|
||||
}
|
||||
|
||||
// Log event receipt for debugging
|
||||
// For Connect account events, event.account contains the connected account ID
|
||||
logger.info("Stripe webhook received", {
|
||||
eventId: event.id,
|
||||
eventType: event.type,
|
||||
connectedAccount: event.account || null,
|
||||
});
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
case "account.updated":
|
||||
await StripeWebhookService.handleAccountUpdated(event.data.object);
|
||||
break;
|
||||
|
||||
case "payout.paid":
|
||||
// Payout to connected account's bank succeeded
|
||||
await StripeWebhookService.handlePayoutPaid(
|
||||
event.data.object,
|
||||
event.account
|
||||
);
|
||||
break;
|
||||
|
||||
case "payout.failed":
|
||||
// Payout to connected account's bank failed
|
||||
await StripeWebhookService.handlePayoutFailed(
|
||||
event.data.object,
|
||||
event.account
|
||||
);
|
||||
break;
|
||||
|
||||
case "payout.canceled":
|
||||
// Payout was canceled before being deposited
|
||||
await StripeWebhookService.handlePayoutCanceled(
|
||||
event.data.object,
|
||||
event.account
|
||||
);
|
||||
break;
|
||||
|
||||
case "account.application.deauthorized":
|
||||
// Owner disconnected their Stripe account from our platform
|
||||
await StripeWebhookService.handleAccountDeauthorized(event.account);
|
||||
break;
|
||||
|
||||
case "charge.dispute.created":
|
||||
// Renter disputed a charge with their bank
|
||||
await DisputeService.handleDisputeCreated(event.data.object);
|
||||
break;
|
||||
|
||||
case "charge.dispute.closed":
|
||||
case "charge.dispute.funds_reinstated":
|
||||
case "charge.dispute.funds_withdrawn":
|
||||
// Dispute was resolved (won, lost, or warning closed)
|
||||
await DisputeService.handleDisputeClosed(event.data.object);
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.info("Unhandled webhook event type", { type: event.type });
|
||||
}
|
||||
|
||||
// Always return 200 to acknowledge receipt
|
||||
res.json({ received: true, eventId: event.id });
|
||||
} catch (error) {
|
||||
logger.error("Error processing webhook", {
|
||||
eventId: event.id,
|
||||
eventType: event.type,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
|
||||
// Still return 200 to prevent Stripe retries for processing errors
|
||||
// Failed payouts will be handled by retry job
|
||||
res.json({ received: true, eventId: event.id });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
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;
|
||||
230
backend/routes/upload.js
Normal file
230
backend/routes/upload.js
Normal file
@@ -0,0 +1,230 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const { authenticateToken } = require("../middleware/auth");
|
||||
const { uploadPresignLimiter } = require("../middleware/rateLimiter");
|
||||
const s3Service = require("../services/s3Service");
|
||||
const S3OwnershipService = require("../services/s3OwnershipService");
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
const logger = require("../utils/logger");
|
||||
const MAX_BATCH_SIZE = 20;
|
||||
|
||||
/**
|
||||
* Middleware to check if S3 is enabled
|
||||
*/
|
||||
const requireS3Enabled = (req, res, next) => {
|
||||
if (!s3Service.isEnabled()) {
|
||||
return res.status(503).json({
|
||||
error: "File upload service is not available",
|
||||
});
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/upload/presign
|
||||
* Get a presigned URL for uploading a single file to S3
|
||||
*/
|
||||
router.post(
|
||||
"/presign",
|
||||
authenticateToken,
|
||||
requireS3Enabled,
|
||||
uploadPresignLimiter,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { uploadType, contentType, fileName, fileSize } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!uploadType || !contentType || !fileName || !fileSize) {
|
||||
return res.status(400).json({ error: "Missing required fields" });
|
||||
}
|
||||
|
||||
const result = await s3Service.getPresignedUploadUrl(
|
||||
uploadType,
|
||||
contentType,
|
||||
fileName,
|
||||
fileSize
|
||||
);
|
||||
|
||||
logger.info("Presigned URL generated", {
|
||||
userId: req.user.id,
|
||||
uploadType,
|
||||
key: result.key,
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
if (error.message.includes("Invalid")) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/upload/presign-batch
|
||||
* Get presigned URLs for uploading multiple files to S3
|
||||
* All files in a batch share the same UUID base for coordinated variant uploads
|
||||
*/
|
||||
router.post(
|
||||
"/presign-batch",
|
||||
authenticateToken,
|
||||
requireS3Enabled,
|
||||
uploadPresignLimiter,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { uploadType, files } = req.body;
|
||||
|
||||
if (!uploadType || !files || !Array.isArray(files)) {
|
||||
return res.status(400).json({ error: "Missing required fields" });
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return res.status(400).json({ error: "No files specified" });
|
||||
}
|
||||
|
||||
if (files.length > MAX_BATCH_SIZE) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Maximum ${MAX_BATCH_SIZE} files per batch" });
|
||||
}
|
||||
|
||||
// Validate each file has required fields
|
||||
for (const file of files) {
|
||||
if (!file.contentType || !file.fileName || !file.fileSize) {
|
||||
return res.status(400).json({
|
||||
error: "Each file must have contentType, fileName, and fileSize",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Generate one shared UUID for all files in this batch
|
||||
const sharedBaseKey = uuidv4();
|
||||
|
||||
const results = await Promise.all(
|
||||
files.map((f) =>
|
||||
s3Service.getPresignedUploadUrl(
|
||||
uploadType,
|
||||
f.contentType,
|
||||
f.fileName,
|
||||
f.fileSize,
|
||||
sharedBaseKey
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
logger.info("Batch presigned URLs generated", {
|
||||
userId: req.user.id,
|
||||
uploadType,
|
||||
count: results.length,
|
||||
baseKey: sharedBaseKey,
|
||||
});
|
||||
|
||||
res.json({ uploads: results, baseKey: sharedBaseKey });
|
||||
} catch (error) {
|
||||
if (error.message.includes("Invalid")) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/upload/confirm
|
||||
* Confirm that files have been uploaded to S3
|
||||
*/
|
||||
router.post(
|
||||
"/confirm",
|
||||
authenticateToken,
|
||||
requireS3Enabled,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { keys } = req.body;
|
||||
|
||||
if (!keys || !Array.isArray(keys)) {
|
||||
return res.status(400).json({ error: "Missing keys array" });
|
||||
}
|
||||
|
||||
if (keys.length === 0) {
|
||||
return res.status(400).json({ error: "No keys specified" });
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
keys.map(async (key) => ({
|
||||
key,
|
||||
exists: await s3Service.verifyUpload(key),
|
||||
}))
|
||||
);
|
||||
|
||||
const confirmed = results.filter((r) => r.exists).map((r) => r.key);
|
||||
|
||||
logger.info("Upload confirmation", {
|
||||
userId: req.user.id,
|
||||
confirmed: confirmed.length,
|
||||
total: keys.length,
|
||||
});
|
||||
|
||||
// Only return confirmed keys, not which ones failed (prevents file existence probing)
|
||||
res.json({ confirmed, total: keys.length });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/upload/signed-url/*key
|
||||
* Get a signed URL for accessing private content (messages, condition-checks)
|
||||
* The key is the full path after /signed-url/ (e.g., "messages/uuid.jpg")
|
||||
*/
|
||||
router.get(
|
||||
"/signed-url/*key",
|
||||
authenticateToken,
|
||||
requireS3Enabled,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
// Express wildcard params may be string or array - handle both
|
||||
let key = req.params.key;
|
||||
if (Array.isArray(key)) {
|
||||
key = key.join("/");
|
||||
}
|
||||
if (!key || typeof key !== "string") {
|
||||
return res.status(400).json({ error: "Invalid key parameter" });
|
||||
}
|
||||
// Decode URL-encoded characters (e.g., %2F -> /)
|
||||
key = decodeURIComponent(key);
|
||||
|
||||
// Only allow private folders to use signed URLs
|
||||
const isPrivate =
|
||||
key.startsWith("messages/") || key.startsWith("condition-checks/");
|
||||
if (!isPrivate) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Signed URLs only for private content" });
|
||||
}
|
||||
|
||||
// Verify user is authorized to access this file
|
||||
const authResult = await S3OwnershipService.canAccessFile(
|
||||
key,
|
||||
req.user.id
|
||||
);
|
||||
if (!authResult.authorized) {
|
||||
logger.warn("Unauthorized signed URL request", {
|
||||
userId: req.user.id,
|
||||
key,
|
||||
reason: authResult.reason,
|
||||
});
|
||||
return res.status(403).json({ error: "Access denied" });
|
||||
}
|
||||
|
||||
const url = await s3Service.getPresignedDownloadUrl(key);
|
||||
|
||||
res.json({ url, expiresIn: 3600 });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,14 +1,71 @@
|
||||
const express = require('express');
|
||||
const { User, UserAddress } = require('../models'); // Import from models/index.js to get models with associations
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const { uploadProfileImage } = require('../middleware/upload');
|
||||
const { authenticateToken, optionalAuth, requireAdmin } = require('../middleware/auth');
|
||||
const { validateCoordinatesBody, validatePasswordChange, handleValidationErrors, sanitizeInput } = require('../middleware/validation');
|
||||
const { requireStepUpAuth } = require('../middleware/stepUpAuth');
|
||||
const { csrfProtection } = require('../middleware/csrf');
|
||||
const logger = require('../utils/logger');
|
||||
const userService = require('../services/UserService');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const emailServices = require('../services/email');
|
||||
const { validateS3Keys } = require('../utils/s3KeyValidator');
|
||||
const { IMAGE_LIMITS } = require('../config/imageLimits');
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/profile', authenticateToken, async (req, res) => {
|
||||
// Allowed fields for profile update (prevents mass assignment)
|
||||
const ALLOWED_PROFILE_FIELDS = [
|
||||
'firstName',
|
||||
'lastName',
|
||||
'email',
|
||||
'phone',
|
||||
'address1',
|
||||
'address2',
|
||||
'city',
|
||||
'state',
|
||||
'zipCode',
|
||||
'country',
|
||||
'imageFilename',
|
||||
'itemRequestNotificationRadius',
|
||||
];
|
||||
|
||||
// Allowed fields for user address create/update (prevents mass assignment)
|
||||
const ALLOWED_ADDRESS_FIELDS = [
|
||||
'address1',
|
||||
'address2',
|
||||
'city',
|
||||
'state',
|
||||
'zipCode',
|
||||
'country',
|
||||
'latitude',
|
||||
'longitude',
|
||||
];
|
||||
|
||||
/**
|
||||
* Extract only allowed fields from request body
|
||||
*/
|
||||
function extractAllowedProfileFields(body) {
|
||||
const result = {};
|
||||
for (const field of ALLOWED_PROFILE_FIELDS) {
|
||||
if (body[field] !== undefined) {
|
||||
result[field] = body[field];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract only allowed address fields from request body
|
||||
*/
|
||||
function extractAllowedAddressFields(body) {
|
||||
const result = {};
|
||||
for (const field of ALLOWED_ADDRESS_FIELDS) {
|
||||
if (body[field] !== undefined) {
|
||||
result[field] = body[field];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
router.get('/profile', authenticateToken, async (req, res, next) => {
|
||||
try {
|
||||
const user = await User.findByPk(req.user.id, {
|
||||
attributes: { exclude: ['password'] }
|
||||
@@ -27,12 +84,12 @@ router.get('/profile', authenticateToken, async (req, res) => {
|
||||
stack: error.stack,
|
||||
userId: req.user.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Address routes (must come before /:id route)
|
||||
router.get('/addresses', authenticateToken, async (req, res) => {
|
||||
router.get('/addresses', authenticateToken, async (req, res, next) => {
|
||||
try {
|
||||
const addresses = await UserAddress.findAll({
|
||||
where: { userId: req.user.id },
|
||||
@@ -52,13 +109,15 @@ router.get('/addresses', authenticateToken, async (req, res) => {
|
||||
stack: error.stack,
|
||||
userId: req.user.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/addresses', authenticateToken, async (req, res) => {
|
||||
router.post('/addresses', authenticateToken, ...validateCoordinatesBody, handleValidationErrors, async (req, res, next) => {
|
||||
try {
|
||||
const address = await userService.createUserAddress(req.user.id, req.body);
|
||||
// Extract only allowed fields (prevents mass assignment)
|
||||
const allowedData = extractAllowedAddressFields(req.body);
|
||||
const address = await userService.createUserAddress(req.user.id, allowedData);
|
||||
|
||||
res.status(201).json(address);
|
||||
} catch (error) {
|
||||
@@ -69,13 +128,15 @@ router.post('/addresses', authenticateToken, async (req, res) => {
|
||||
userId: req.user.id,
|
||||
addressData: logger.sanitize(req.body)
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/addresses/:id', authenticateToken, async (req, res) => {
|
||||
router.put('/addresses/:id', authenticateToken, ...validateCoordinatesBody, handleValidationErrors, async (req, res, next) => {
|
||||
try {
|
||||
const address = await userService.updateUserAddress(req.user.id, req.params.id, req.body);
|
||||
// Extract only allowed fields (prevents mass assignment)
|
||||
const allowedData = extractAllowedAddressFields(req.body);
|
||||
const address = await userService.updateUserAddress(req.user.id, req.params.id, allowedData);
|
||||
|
||||
res.json(address);
|
||||
} catch (error) {
|
||||
@@ -88,14 +149,14 @@ router.put('/addresses/:id', authenticateToken, async (req, res) => {
|
||||
});
|
||||
|
||||
if (error.message === 'Address not found') {
|
||||
return res.status(404).json({ error: error.message });
|
||||
return res.status(404).json({ error: 'Address not found' });
|
||||
}
|
||||
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/addresses/:id', authenticateToken, async (req, res) => {
|
||||
router.delete('/addresses/:id', authenticateToken, async (req, res, next) => {
|
||||
try {
|
||||
await userService.deleteUserAddress(req.user.id, req.params.id);
|
||||
|
||||
@@ -110,15 +171,15 @@ router.delete('/addresses/:id', authenticateToken, async (req, res) => {
|
||||
});
|
||||
|
||||
if (error.message === 'Address not found') {
|
||||
return res.status(404).json({ error: error.message });
|
||||
return res.status(404).json({ error: 'Address not found' });
|
||||
}
|
||||
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// User availability routes (must come before /:id route)
|
||||
router.get('/availability', authenticateToken, async (req, res) => {
|
||||
router.get('/availability', authenticateToken, async (req, res, next) => {
|
||||
try {
|
||||
const user = await User.findByPk(req.user.id, {
|
||||
attributes: ['defaultAvailableAfter', 'defaultAvailableBefore', 'defaultSpecifyTimesPerDay', 'defaultWeeklyTimes']
|
||||
@@ -130,11 +191,11 @@ router.get('/availability', authenticateToken, async (req, res) => {
|
||||
weeklyTimes: user.defaultWeeklyTimes
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/availability', authenticateToken, async (req, res) => {
|
||||
router.put('/availability', authenticateToken, async (req, res, next) => {
|
||||
try {
|
||||
const { generalAvailableAfter, generalAvailableBefore, specifyTimesPerDay, weeklyTimes } = req.body;
|
||||
|
||||
@@ -149,14 +210,24 @@ router.put('/availability', authenticateToken, async (req, res) => {
|
||||
|
||||
res.json({ message: 'Availability updated successfully' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
router.get('/:id', optionalAuth, async (req, res, next) => {
|
||||
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, {
|
||||
attributes: { exclude: ['password', 'email', 'phone', 'address'] }
|
||||
attributes: { exclude: excludedAttributes }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
@@ -165,7 +236,8 @@ router.get('/:id', async (req, res) => {
|
||||
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Public user profile fetched", {
|
||||
requestedUserId: req.params.id
|
||||
requestedUserId: req.params.id,
|
||||
viewerIsAdmin: isAdmin
|
||||
});
|
||||
|
||||
res.json(user);
|
||||
@@ -176,84 +248,219 @@ router.get('/:id', async (req, res) => {
|
||||
stack: error.stack,
|
||||
requestedUserId: req.params.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/profile', authenticateToken, async (req, res) => {
|
||||
router.put('/profile', authenticateToken, async (req, res, next) => {
|
||||
try {
|
||||
// Extract only allowed fields (prevents mass assignment)
|
||||
const allowedData = extractAllowedProfileFields(req.body);
|
||||
|
||||
// Validate imageFilename if provided
|
||||
if (allowedData.imageFilename !== undefined && allowedData.imageFilename !== null) {
|
||||
const keyValidation = validateS3Keys([allowedData.imageFilename], 'profiles', { maxKeys: IMAGE_LIMITS.profile });
|
||||
if (!keyValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: keyValidation.error,
|
||||
details: keyValidation.invalidKeys
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Use UserService to handle update and email notification
|
||||
const updatedUser = await userService.updateProfile(req.user.id, req.body);
|
||||
const updatedUser = await userService.updateProfile(req.user.id, allowedData);
|
||||
|
||||
res.json(updatedUser);
|
||||
} catch (error) {
|
||||
console.error('Profile update error:', error);
|
||||
res.status(500).json({
|
||||
error: error.message,
|
||||
details: error.errors ? error.errors.map(e => ({ field: e.path, message: e.message })) : undefined
|
||||
});
|
||||
logger.error('Profile update error', { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Upload profile image endpoint
|
||||
router.post('/profile/image', authenticateToken, (req, res) => {
|
||||
uploadProfileImage(req, res, async (err) => {
|
||||
if (err) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Profile image upload error", {
|
||||
error: err.message,
|
||||
userId: req.user.id
|
||||
});
|
||||
return res.status(400).json({ error: err.message });
|
||||
// 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" });
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
// 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 {
|
||||
// Delete old profile image if exists
|
||||
const user = await User.findByPk(req.user.id);
|
||||
if (user.profileImage) {
|
||||
const oldImagePath = path.join(__dirname, '../uploads/profiles', user.profileImage);
|
||||
try {
|
||||
await fs.unlink(oldImagePath);
|
||||
} catch (unlinkErr) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.warn("Error deleting old profile image", {
|
||||
error: unlinkErr.message,
|
||||
userId: req.user.id,
|
||||
oldImagePath
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update user with new image filename
|
||||
await user.update({
|
||||
profileImage: req.file.filename
|
||||
});
|
||||
|
||||
const emailServices = require("../services/email");
|
||||
await emailServices.userEngagement.sendUserBannedNotification(
|
||||
targetUser,
|
||||
req.user,
|
||||
reason.trim()
|
||||
);
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Profile image uploaded successfully", {
|
||||
userId: req.user.id,
|
||||
filename: req.file.filename
|
||||
reqLogger.info("User ban notification email sent", {
|
||||
bannedUserId: targetUserId,
|
||||
adminId: req.user.id
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Profile image uploaded successfully',
|
||||
filename: req.file.filename,
|
||||
imageUrl: `/uploads/profiles/${req.file.filename}`
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (emailError) {
|
||||
// Log but don't fail the ban operation
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Profile image database update failed", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
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
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to update profile image' });
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,5 +1,5 @@
|
||||
// Load environment config
|
||||
const env = process.env.NODE_ENV || "dev";
|
||||
const env = process.env.NODE_ENV;
|
||||
const envFile = `.env.${env}`;
|
||||
require("dotenv").config({ path: envFile });
|
||||
|
||||
@@ -101,11 +101,11 @@ async function resendInvitation(emailOrCode) {
|
||||
// Try to find by code first (if it looks like a code), otherwise by email
|
||||
if (input.toUpperCase().startsWith("ALPHA-")) {
|
||||
invitation = await AlphaInvitation.findOne({
|
||||
where: { code: input.toUpperCase() }
|
||||
where: { code: input.toUpperCase() },
|
||||
});
|
||||
} else {
|
||||
invitation = await AlphaInvitation.findOne({
|
||||
where: { email: normalizeEmail(input) }
|
||||
where: { email: normalizeEmail(input) },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -131,7 +131,10 @@ async function resendInvitation(emailOrCode) {
|
||||
|
||||
// Resend the email
|
||||
try {
|
||||
await emailServices.alphaInvitation.sendAlphaInvitation(invitation.email, invitation.code);
|
||||
await emailServices.alphaInvitation.sendAlphaInvitation(
|
||||
invitation.email,
|
||||
invitation.code,
|
||||
);
|
||||
|
||||
console.log(`\n✅ Alpha invitation resent successfully!`);
|
||||
console.log(` Email: ${invitation.email}`);
|
||||
@@ -178,7 +181,7 @@ async function listInvitations(filter = "all") {
|
||||
});
|
||||
|
||||
console.log(
|
||||
`\n📋 Alpha Invitations (${invitations.length} total, filter: ${filter})\n`
|
||||
`\n📋 Alpha Invitations (${invitations.length} total, filter: ${filter})\n`,
|
||||
);
|
||||
console.log("─".repeat(100));
|
||||
console.log(
|
||||
@@ -186,7 +189,7 @@ async function listInvitations(filter = "all") {
|
||||
"EMAIL".padEnd(30) +
|
||||
"STATUS".padEnd(10) +
|
||||
"USED BY".padEnd(25) +
|
||||
"CREATED"
|
||||
"CREATED",
|
||||
);
|
||||
console.log("─".repeat(100));
|
||||
|
||||
@@ -204,7 +207,7 @@ async function listInvitations(filter = "all") {
|
||||
inv.email.padEnd(30) +
|
||||
inv.status.padEnd(10) +
|
||||
usedBy.padEnd(25) +
|
||||
created
|
||||
created,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -221,7 +224,7 @@ async function listInvitations(filter = "all") {
|
||||
};
|
||||
|
||||
console.log(
|
||||
`\nSummary: ${stats.pending} pending | ${stats.active} active | ${stats.revoked} revoked\n`
|
||||
`\nSummary: ${stats.pending} pending | ${stats.active} active | ${stats.revoked} revoked\n`,
|
||||
);
|
||||
|
||||
return invitations;
|
||||
@@ -274,7 +277,9 @@ async function restoreInvitation(code) {
|
||||
}
|
||||
|
||||
if (invitation.status !== "revoked") {
|
||||
console.log(`\n⚠️ Invitation is not revoked (current status: ${invitation.status})`);
|
||||
console.log(
|
||||
`\n⚠️ Invitation is not revoked (current status: ${invitation.status})`,
|
||||
);
|
||||
console.log(` Code: ${code}`);
|
||||
console.log(` Email: ${invitation.email}`);
|
||||
return invitation;
|
||||
@@ -288,7 +293,9 @@ async function restoreInvitation(code) {
|
||||
console.log(`\n✅ Invitation restored successfully!`);
|
||||
console.log(` Code: ${code}`);
|
||||
console.log(` Email: ${invitation.email}`);
|
||||
console.log(` Status: ${newStatus} (${invitation.usedBy ? 'was previously used' : 'never used'})`);
|
||||
console.log(
|
||||
` Status: ${newStatus} (${invitation.usedBy ? "was previously used" : "never used"})`,
|
||||
);
|
||||
|
||||
return invitation;
|
||||
} catch (error) {
|
||||
@@ -313,7 +320,7 @@ async function bulkImport(csvPath) {
|
||||
const dataLines = hasHeader ? lines.slice(1) : lines;
|
||||
|
||||
console.log(
|
||||
`\n📥 Importing ${dataLines.length} invitations from ${csvPath}...\n`
|
||||
`\n📥 Importing ${dataLines.length} invitations from ${csvPath}...\n`,
|
||||
);
|
||||
|
||||
let successCount = 0;
|
||||
@@ -391,7 +398,7 @@ CSV Format:
|
||||
if (!email) {
|
||||
console.log("\n❌ Error: Email is required");
|
||||
console.log(
|
||||
"Usage: node scripts/manageAlphaInvitations.js add <email> [notes]\n"
|
||||
"Usage: node scripts/manageAlphaInvitations.js add <email> [notes]\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -406,7 +413,7 @@ CSV Format:
|
||||
if (!code) {
|
||||
console.log("\n❌ Error: Code is required");
|
||||
console.log(
|
||||
"Usage: node scripts/manageAlphaInvitations.js revoke <code>\n"
|
||||
"Usage: node scripts/manageAlphaInvitations.js revoke <code>\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -418,7 +425,7 @@ CSV Format:
|
||||
if (!emailOrCode) {
|
||||
console.log("\n❌ Error: Email or code is required");
|
||||
console.log(
|
||||
"Usage: node scripts/manageAlphaInvitations.js resend <email|code>\n"
|
||||
"Usage: node scripts/manageAlphaInvitations.js resend <email|code>\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -430,7 +437,7 @@ CSV Format:
|
||||
if (!code) {
|
||||
console.log("\n❌ Error: Code is required");
|
||||
console.log(
|
||||
"Usage: node scripts/manageAlphaInvitations.js restore <code>\n"
|
||||
"Usage: node scripts/manageAlphaInvitations.js restore <code>\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -442,7 +449,7 @@ CSV Format:
|
||||
if (!csvPath) {
|
||||
console.log("\n❌ Error: CSV path is required");
|
||||
console.log(
|
||||
"Usage: node scripts/manageAlphaInvitations.js bulk <csvPath>\n"
|
||||
"Usage: node scripts/manageAlphaInvitations.js bulk <csvPath>\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -451,7 +458,7 @@ CSV Format:
|
||||
} else {
|
||||
console.log(`\n❌ Unknown command: ${command}`);
|
||||
console.log(
|
||||
"Run 'node scripts/manageAlphaInvitations.js help' for usage information\n"
|
||||
"Run 'node scripts/manageAlphaInvitations.js help' for usage information\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Load environment-specific config
|
||||
const env = process.env.NODE_ENV || "dev";
|
||||
const env = process.env.NODE_ENV;
|
||||
const envFile = `.env.${env}`;
|
||||
|
||||
require("dotenv").config({
|
||||
@@ -25,14 +25,16 @@ const rentalRoutes = require("./routes/rentals");
|
||||
const messageRoutes = require("./routes/messages");
|
||||
const forumRoutes = require("./routes/forum");
|
||||
const stripeRoutes = require("./routes/stripe");
|
||||
const stripeWebhookRoutes = require("./routes/stripeWebhooks");
|
||||
const mapsRoutes = require("./routes/maps");
|
||||
const conditionCheckRoutes = require("./routes/conditionChecks");
|
||||
const feedbackRoutes = require("./routes/feedback");
|
||||
const uploadRoutes = require("./routes/upload");
|
||||
const healthRoutes = require("./routes/health");
|
||||
const twoFactorRoutes = require("./routes/twoFactor");
|
||||
|
||||
const PayoutProcessor = require("./jobs/payoutProcessor");
|
||||
const RentalStatusJob = require("./jobs/rentalStatusJob");
|
||||
const ConditionCheckReminderJob = require("./jobs/conditionCheckReminder");
|
||||
const emailServices = require("./services/email");
|
||||
const s3Service = require("./services/s3Service");
|
||||
|
||||
// Socket.io setup
|
||||
const { authenticateSocket } = require("./sockets/socketAuth");
|
||||
@@ -44,7 +46,7 @@ const server = http.createServer(app);
|
||||
// Initialize Socket.io with CORS
|
||||
const io = new Server(server, {
|
||||
cors: {
|
||||
origin: process.env.FRONTEND_URL || "http://localhost:3000",
|
||||
origin: process.env.FRONTEND_URL,
|
||||
credentials: true,
|
||||
methods: ["GET", "POST"],
|
||||
},
|
||||
@@ -66,6 +68,7 @@ const {
|
||||
addRequestId,
|
||||
sanitizeError,
|
||||
} = require("./middleware/security");
|
||||
const { sanitizeInput } = require("./middleware/validation");
|
||||
const { generalLimiter } = require("./middleware/rateLimiter");
|
||||
const errorLogger = require("./middleware/errorLogger");
|
||||
const apiLogger = require("./middleware/apiLogger");
|
||||
@@ -90,7 +93,7 @@ app.use(
|
||||
frameSrc: ["'self'", "https://accounts.google.com"],
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// Cookie parser for CSRF
|
||||
@@ -105,10 +108,11 @@ app.use("/api/", apiLogger);
|
||||
// CORS with security settings (must come BEFORE rate limiter to ensure headers on all responses)
|
||||
app.use(
|
||||
cors({
|
||||
origin: process.env.FRONTEND_URL || "http://localhost:3000",
|
||||
origin: process.env.FRONTEND_URL,
|
||||
credentials: true,
|
||||
optionsSuccessStatus: 200,
|
||||
})
|
||||
exposedHeaders: ["X-CSRF-Token"],
|
||||
}),
|
||||
);
|
||||
|
||||
// General rate limiting for all routes
|
||||
@@ -122,31 +126,34 @@ app.use(
|
||||
// Store raw body for webhook verification
|
||||
req.rawBody = buf;
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
app.use(
|
||||
bodyParser.urlencoded({
|
||||
extended: true,
|
||||
limit: "1mb",
|
||||
parameterLimit: 100, // Limit number of parameters
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// Serve static files from uploads directory with CORS headers
|
||||
app.use(
|
||||
"/uploads",
|
||||
helmet.crossOriginResourcePolicy({ policy: "cross-origin" }),
|
||||
express.static(path.join(__dirname, "uploads"))
|
||||
);
|
||||
// Apply input sanitization to all API routes (XSS prevention)
|
||||
app.use("/api/", sanitizeInput);
|
||||
|
||||
// Health check endpoints (no auth, no rate limiting)
|
||||
app.use("/health", healthRoutes);
|
||||
|
||||
// Stripe webhooks (no auth, uses signature verification instead)
|
||||
app.use("/api/stripe/webhooks", stripeWebhookRoutes);
|
||||
|
||||
// Root endpoint
|
||||
app.get("/", (req, res) => {
|
||||
res.json({ message: "Village Share API is running!" });
|
||||
});
|
||||
|
||||
// Public routes (no alpha access required)
|
||||
app.use("/api/alpha", alphaRoutes);
|
||||
app.use("/api/auth", authRoutes); // Auth has its own alpha checks in registration
|
||||
|
||||
// Health check endpoint
|
||||
app.get("/", (req, res) => {
|
||||
res.json({ message: "CommunityRentals.App API is running!" });
|
||||
});
|
||||
app.use("/api/2fa", twoFactorRoutes); // 2FA routes require authentication (handled in router)
|
||||
|
||||
// Protected routes (require alpha access)
|
||||
app.use("/api/users", requireAlphaAccess, userRoutes);
|
||||
@@ -158,12 +165,13 @@ app.use("/api/stripe", requireAlphaAccess, stripeRoutes);
|
||||
app.use("/api/maps", requireAlphaAccess, mapsRoutes);
|
||||
app.use("/api/condition-checks", requireAlphaAccess, conditionCheckRoutes);
|
||||
app.use("/api/feedback", requireAlphaAccess, feedbackRoutes);
|
||||
app.use("/api/upload", requireAlphaAccess, uploadRoutes);
|
||||
|
||||
// Error handling middleware (must be last)
|
||||
app.use(errorLogger);
|
||||
app.use(sanitizeError);
|
||||
|
||||
const PORT = process.env.PORT || 5000;
|
||||
const PORT = process.env.PORT;
|
||||
|
||||
const { checkPendingMigrations } = require("./utils/checkMigrations");
|
||||
|
||||
@@ -177,7 +185,7 @@ sequelize
|
||||
if (pendingMigrations.length > 0) {
|
||||
logger.error(
|
||||
`Found ${pendingMigrations.length} pending migration(s). Please run 'npm run db:migrate'`,
|
||||
{ pendingMigrations }
|
||||
{ pendingMigrations },
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -194,24 +202,29 @@ sequelize
|
||||
});
|
||||
// Fail fast - don't start server if email templates can't load
|
||||
if (env === "prod" || env === "production") {
|
||||
logger.error("Cannot start server without email services in production");
|
||||
logger.error(
|
||||
"Cannot start server without email services in production",
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
logger.warn("Email services failed to initialize - continuing in dev mode");
|
||||
logger.warn(
|
||||
"Email services failed to initialize - continuing in dev mode",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Start the payout processor
|
||||
const payoutJobs = PayoutProcessor.startScheduledPayouts();
|
||||
logger.info("Payout processor started");
|
||||
|
||||
// Start the rental status job
|
||||
const rentalStatusJobs = RentalStatusJob.startScheduledStatusUpdates();
|
||||
logger.info("Rental status job started");
|
||||
|
||||
// Start the condition check reminder job
|
||||
const conditionCheckJobs = ConditionCheckReminderJob.startScheduledReminders();
|
||||
logger.info("Condition check reminder job started");
|
||||
// Initialize S3 service for image uploads
|
||||
try {
|
||||
s3Service.initialize();
|
||||
logger.info("S3 service initialized successfully");
|
||||
} catch (err) {
|
||||
logger.error("Failed to initialize S3 service", {
|
||||
error: err.message,
|
||||
stack: err.stack,
|
||||
});
|
||||
logger.error("Cannot start server without S3 service in production");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
server.listen(PORT, () => {
|
||||
logger.info(`Server is running on 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;
|
||||
@@ -89,6 +89,7 @@ class UserService {
|
||||
"Failed to send personal information changed notification",
|
||||
{
|
||||
error: emailError.message,
|
||||
stack: emailError.stack,
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
changedFields,
|
||||
@@ -138,6 +139,7 @@ class UserService {
|
||||
} catch (emailError) {
|
||||
logger.error("Failed to send notification for address creation", {
|
||||
error: emailError.message,
|
||||
stack: emailError.stack,
|
||||
userId: user.id,
|
||||
addressId: address.id,
|
||||
});
|
||||
@@ -181,6 +183,7 @@ class UserService {
|
||||
} catch (emailError) {
|
||||
logger.error("Failed to send notification for address update", {
|
||||
error: emailError.message,
|
||||
stack: emailError.stack,
|
||||
userId,
|
||||
addressId: address.id,
|
||||
});
|
||||
@@ -223,6 +226,7 @@ class UserService {
|
||||
} catch (emailError) {
|
||||
logger.error("Failed to send notification for address deletion", {
|
||||
error: emailError.message,
|
||||
stack: emailError.stack,
|
||||
userId,
|
||||
addressId,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const { ConditionCheck, Rental, User } = require("../models");
|
||||
const { Op } = require("sequelize");
|
||||
const { isActive } = require("../utils/rentalStatus");
|
||||
|
||||
class ConditionCheckService {
|
||||
/**
|
||||
@@ -70,7 +71,7 @@ class ConditionCheckService {
|
||||
canSubmit =
|
||||
now >= timeWindow.start &&
|
||||
now <= timeWindow.end &&
|
||||
rental.status === "active";
|
||||
isActive(rental);
|
||||
break;
|
||||
|
||||
case "rental_end_renter":
|
||||
@@ -80,7 +81,7 @@ class ConditionCheckService {
|
||||
canSubmit =
|
||||
now >= timeWindow.start &&
|
||||
now <= timeWindow.end &&
|
||||
rental.status === "active";
|
||||
isActive(rental);
|
||||
break;
|
||||
|
||||
case "post_rental_owner":
|
||||
@@ -116,7 +117,7 @@ class ConditionCheckService {
|
||||
* @param {string} rentalId - Rental ID
|
||||
* @param {string} checkType - Type of check
|
||||
* @param {string} userId - User submitting the check
|
||||
* @param {Array} photos - Array of photo URLs
|
||||
* @param {Array} imageFilenames - Array of image filenames
|
||||
* @param {string} notes - Optional notes
|
||||
* @returns {Object} - Created condition check
|
||||
*/
|
||||
@@ -124,7 +125,7 @@ class ConditionCheckService {
|
||||
rentalId,
|
||||
checkType,
|
||||
userId,
|
||||
photos = [],
|
||||
imageFilenames = [],
|
||||
notes = null
|
||||
) {
|
||||
// Validate the check
|
||||
@@ -139,7 +140,7 @@ class ConditionCheckService {
|
||||
}
|
||||
|
||||
// Validate photos (basic validation)
|
||||
if (photos.length > 20) {
|
||||
if (imageFilenames.length > 20) {
|
||||
throw new Error("Maximum 20 photos allowed per condition check");
|
||||
}
|
||||
|
||||
@@ -147,7 +148,7 @@ class ConditionCheckService {
|
||||
rentalId,
|
||||
checkType,
|
||||
submittedBy: userId,
|
||||
photos,
|
||||
imageFilenames,
|
||||
notes,
|
||||
});
|
||||
|
||||
@@ -155,18 +156,26 @@ class ConditionCheckService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all condition checks for a rental
|
||||
* @param {string} rentalId - Rental ID
|
||||
* Get all condition checks for multiple rentals (batch)
|
||||
* @param {Array<string>} rentalIds - Array of Rental IDs
|
||||
* @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({
|
||||
where: { rentalId },
|
||||
where: {
|
||||
rentalId: {
|
||||
[Op.in]: rentalIds,
|
||||
},
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: "submittedByUser",
|
||||
attributes: ["id", "username", "firstName", "lastName"],
|
||||
attributes: ["id", "firstName", "lastName"],
|
||||
},
|
||||
],
|
||||
order: [["submittedAt", "ASC"]],
|
||||
@@ -175,119 +184,24 @@ class ConditionCheckService {
|
||||
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", "username", "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.photos.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
|
||||
* @param {string} userId - User ID
|
||||
* @param {Array<string>} rentalIds - Array of rental IDs to check
|
||||
* @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 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({
|
||||
where: {
|
||||
id: { [Op.in]: rentalIds },
|
||||
[Op.or]: [{ ownerId: userId }, { renterId: userId }],
|
||||
status: {
|
||||
[Op.in]: ["confirmed", "active", "completed"],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const { Rental, Item, ConditionCheck, User } = require("../models");
|
||||
const LateReturnService = require("./lateReturnService");
|
||||
const emailServices = require("./email");
|
||||
const { isActive } = require("../utils/rentalStatus");
|
||||
|
||||
class DamageAssessmentService {
|
||||
/**
|
||||
@@ -19,7 +20,7 @@ class DamageAssessmentService {
|
||||
replacementCost,
|
||||
proofOfOwnership,
|
||||
actualReturnDateTime,
|
||||
photos = [],
|
||||
imageFilenames = [],
|
||||
} = damageInfo;
|
||||
|
||||
const rental = await Rental.findByPk(rentalId, {
|
||||
@@ -34,7 +35,7 @@ class DamageAssessmentService {
|
||||
throw new Error("Only the item owner can report damage");
|
||||
}
|
||||
|
||||
if (rental.status !== "active") {
|
||||
if (!isActive(rental)) {
|
||||
throw new Error("Can only assess damage for active rentals");
|
||||
}
|
||||
|
||||
@@ -98,7 +99,7 @@ class DamageAssessmentService {
|
||||
needsReplacement,
|
||||
replacementCost: needsReplacement ? parseFloat(replacementCost) : null,
|
||||
proofOfOwnership: proofOfOwnership || [],
|
||||
photos,
|
||||
imageFilenames,
|
||||
assessedAt: new Date(),
|
||||
assessedBy: userId,
|
||||
feeCalculation,
|
||||
|
||||
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 { getAWSConfig } = require("../../../config/aws");
|
||||
const { htmlToPlainText } = require("./emailUtils");
|
||||
const logger = require("../../../utils/logger");
|
||||
|
||||
/**
|
||||
* EmailClient handles AWS SES configuration and core email sending functionality
|
||||
@@ -44,9 +45,9 @@ class EmailClient {
|
||||
this.sesClient = new SESClient(awsConfig);
|
||||
|
||||
this.initialized = true;
|
||||
console.log("AWS SES Email Client initialized successfully");
|
||||
logger.info("AWS SES Email Client initialized successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize AWS SES Email Client:", error);
|
||||
logger.error("Failed to initialize AWS SES Email Client", { error });
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
@@ -69,7 +70,7 @@ class EmailClient {
|
||||
|
||||
// Check if email sending is enabled in the environment
|
||||
if (!process.env.EMAIL_ENABLED || process.env.EMAIL_ENABLED !== "true") {
|
||||
console.log("Email sending disabled in environment");
|
||||
logger.debug("Email sending disabled in environment");
|
||||
return { success: true, messageId: "disabled" };
|
||||
}
|
||||
|
||||
@@ -79,7 +80,7 @@ class EmailClient {
|
||||
}
|
||||
|
||||
// Use friendly sender name format for better recognition
|
||||
const fromName = process.env.SES_FROM_NAME || "RentAll";
|
||||
const fromName = process.env.SES_FROM_NAME || "Village Share";
|
||||
const fromEmail = process.env.SES_FROM_EMAIL;
|
||||
const source = `${fromName} <${fromEmail}>`;
|
||||
|
||||
@@ -115,12 +116,10 @@ class EmailClient {
|
||||
const command = new SendEmailCommand(params);
|
||||
const result = await this.sesClient.send(command);
|
||||
|
||||
console.log(
|
||||
`Email sent successfully to ${to}, MessageId: ${result.MessageId}`
|
||||
);
|
||||
logger.info("Email sent successfully", { to, messageId: result.MessageId });
|
||||
return { success: true, messageId: result.MessageId };
|
||||
} catch (error) {
|
||||
console.error("Failed to send email:", error);
|
||||
logger.error("Failed to send email", { error, to });
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const fs = require("fs").promises;
|
||||
const path = require("path");
|
||||
const logger = require("../../../utils/logger");
|
||||
const { escapeHtml } = require("./emailUtils");
|
||||
|
||||
/**
|
||||
* TemplateManager handles loading, caching, and rendering email templates
|
||||
@@ -9,6 +11,14 @@ const path = require("path");
|
||||
* - Rendering templates with variable substitution
|
||||
* - Providing fallback templates when files can't be loaded
|
||||
*/
|
||||
// Critical templates that must be preloaded at startup for auth flows
|
||||
const CRITICAL_TEMPLATES = [
|
||||
"emailVerificationToUser",
|
||||
"passwordResetToUser",
|
||||
"passwordChangedToUser",
|
||||
"personalInfoChangedToUser",
|
||||
];
|
||||
|
||||
class TemplateManager {
|
||||
constructor() {
|
||||
// Singleton pattern - return existing instance if already created
|
||||
@@ -16,15 +26,76 @@ class TemplateManager {
|
||||
return TemplateManager.instance;
|
||||
}
|
||||
|
||||
this.templates = new Map();
|
||||
this.templates = new Map(); // Cached template content
|
||||
this.templateNames = new Set(); // Discovered template names
|
||||
this.initialized = false;
|
||||
this.initializationPromise = null;
|
||||
this.templatesDir = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"templates",
|
||||
"emails"
|
||||
);
|
||||
|
||||
TemplateManager.instance = this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the template manager by loading all email templates
|
||||
* Discover all available templates by scanning the templates directory
|
||||
* Only reads filenames, not content (for fast startup)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async discoverTemplates() {
|
||||
try {
|
||||
const files = await fs.readdir(this.templatesDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith(".html")) {
|
||||
this.templateNames.add(file.replace(".html", ""));
|
||||
}
|
||||
}
|
||||
logger.info("Discovered email templates", {
|
||||
count: this.templateNames.size,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to discover email templates", {
|
||||
templatesDir: this.templatesDir,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a single template from disk (lazy loading)
|
||||
* @param {string} templateName - Name of the template (without .html extension)
|
||||
* @returns {Promise<string>} Template content
|
||||
*/
|
||||
async loadTemplate(templateName) {
|
||||
// Return cached template if already loaded
|
||||
if (this.templates.has(templateName)) {
|
||||
return this.templates.get(templateName);
|
||||
}
|
||||
|
||||
const templatePath = path.join(this.templatesDir, `${templateName}.html`);
|
||||
try {
|
||||
const content = await fs.readFile(templatePath, "utf-8");
|
||||
this.templates.set(templateName, content);
|
||||
logger.debug("Loaded template", { templateName });
|
||||
return content;
|
||||
} catch (error) {
|
||||
logger.error("Failed to load template", {
|
||||
templateName,
|
||||
templatePath,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the template manager by discovering templates and preloading critical ones
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async initialize() {
|
||||
@@ -38,116 +109,35 @@ class TemplateManager {
|
||||
|
||||
// Start initialization and store the promise
|
||||
this.initializationPromise = (async () => {
|
||||
await this.loadEmailTemplates();
|
||||
this.initialized = true;
|
||||
console.log("Email Template Manager initialized successfully");
|
||||
})();
|
||||
// Discover all available templates (fast - only reads filenames)
|
||||
await this.discoverTemplates();
|
||||
|
||||
return this.initializationPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all email templates from disk into memory
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async loadEmailTemplates() {
|
||||
const templatesDir = path.join(__dirname, "..", "..", "..", "templates", "emails");
|
||||
|
||||
// Critical templates that must load for the app to function
|
||||
const criticalTemplates = [
|
||||
"emailVerificationToUser.html",
|
||||
"passwordResetToUser.html",
|
||||
"passwordChangedToUser.html",
|
||||
"personalInfoChangedToUser.html",
|
||||
];
|
||||
|
||||
try {
|
||||
const templateFiles = [
|
||||
"conditionCheckReminderToUser.html",
|
||||
"rentalConfirmationToUser.html",
|
||||
"emailVerificationToUser.html",
|
||||
"passwordResetToUser.html",
|
||||
"passwordChangedToUser.html",
|
||||
"personalInfoChangedToUser.html",
|
||||
"lateReturnToCS.html",
|
||||
"damageReportToCS.html",
|
||||
"lostItemToCS.html",
|
||||
"rentalRequestToOwner.html",
|
||||
"rentalRequestConfirmationToRenter.html",
|
||||
"rentalCancellationConfirmationToUser.html",
|
||||
"rentalCancellationNotificationToUser.html",
|
||||
"rentalDeclinedToRenter.html",
|
||||
"rentalApprovalConfirmationToOwner.html",
|
||||
"rentalCompletionThankYouToRenter.html",
|
||||
"rentalCompletionCongratsToOwner.html",
|
||||
"payoutReceivedToOwner.html",
|
||||
"firstListingCelebrationToOwner.html",
|
||||
"itemDeletionToOwner.html",
|
||||
"alphaInvitationToUser.html",
|
||||
"feedbackConfirmationToUser.html",
|
||||
"feedbackNotificationToAdmin.html",
|
||||
"newMessageToUser.html",
|
||||
"forumCommentToPostAuthor.html",
|
||||
"forumReplyToCommentAuthor.html",
|
||||
"forumAnswerAcceptedToCommentAuthor.html",
|
||||
"forumThreadActivityToParticipant.html",
|
||||
"forumPostClosed.html",
|
||||
"forumItemRequestNotification.html",
|
||||
"forumPostDeletionToAuthor.html",
|
||||
"forumCommentDeletionToAuthor.html",
|
||||
];
|
||||
|
||||
const failedTemplates = [];
|
||||
|
||||
for (const templateFile of templateFiles) {
|
||||
try {
|
||||
const templatePath = path.join(templatesDir, templateFile);
|
||||
const templateContent = await fs.readFile(templatePath, "utf-8");
|
||||
const templateName = path.basename(templateFile, ".html");
|
||||
this.templates.set(templateName, templateContent);
|
||||
console.log(`✓ Loaded template: ${templateName}`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`✗ Failed to load template ${templateFile}:`,
|
||||
error.message
|
||||
);
|
||||
console.error(
|
||||
` Template path: ${path.join(templatesDir, templateFile)}`
|
||||
);
|
||||
failedTemplates.push(templateFile);
|
||||
// Preload critical templates for auth flows
|
||||
const missingCritical = [];
|
||||
for (const templateName of CRITICAL_TEMPLATES) {
|
||||
if (!this.templateNames.has(templateName)) {
|
||||
missingCritical.push(templateName);
|
||||
} else {
|
||||
await this.loadTemplate(templateName);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Loaded ${this.templates.size} of ${templateFiles.length} email templates`
|
||||
);
|
||||
|
||||
// Check if critical templates are missing
|
||||
const missingCriticalTemplates = criticalTemplates.filter(
|
||||
(template) => !this.templates.has(path.basename(template, ".html"))
|
||||
);
|
||||
|
||||
if (missingCriticalTemplates.length > 0) {
|
||||
if (missingCritical.length > 0) {
|
||||
const error = new Error(
|
||||
`Critical email templates failed to load: ${missingCriticalTemplates.join(", ")}`
|
||||
`Critical email templates not found: ${missingCritical.join(", ")}`
|
||||
);
|
||||
error.missingTemplates = missingCriticalTemplates;
|
||||
error.missingTemplates = missingCritical;
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Warn if non-critical templates failed
|
||||
if (failedTemplates.length > 0) {
|
||||
console.warn(
|
||||
`⚠️ Non-critical templates failed to load: ${failedTemplates.join(", ")}`
|
||||
);
|
||||
console.warn("These templates will use fallback versions");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load email templates:", error);
|
||||
console.error("Templates directory:", templatesDir);
|
||||
console.error("Error stack:", error.stack);
|
||||
throw error; // Re-throw to fail server startup
|
||||
}
|
||||
this.initialized = true;
|
||||
logger.info("Email Template Manager initialized successfully", {
|
||||
discovered: this.templateNames.size,
|
||||
preloaded: CRITICAL_TEMPLATES.length,
|
||||
});
|
||||
})();
|
||||
|
||||
return this.initializationPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,342 +149,94 @@ class TemplateManager {
|
||||
async renderTemplate(templateName, variables = {}) {
|
||||
// Ensure service is initialized before rendering
|
||||
if (!this.initialized) {
|
||||
console.log(`Template manager not initialized yet, initializing now...`);
|
||||
logger.debug("Template manager not initialized yet, initializing now...");
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
let template = this.templates.get(templateName);
|
||||
let template;
|
||||
|
||||
if (!template) {
|
||||
console.error(`Template not found: ${templateName}`);
|
||||
console.error(
|
||||
`Available templates: ${Array.from(this.templates.keys()).join(", ")}`
|
||||
);
|
||||
console.error(`Stack trace:`, new Error().stack);
|
||||
console.log(`Using fallback template for: ${templateName}`);
|
||||
template = this.getFallbackTemplate(templateName);
|
||||
// Check if template exists in our discovered templates
|
||||
if (this.templateNames.has(templateName)) {
|
||||
// Lazy load the template if not already cached
|
||||
template = await this.loadTemplate(templateName);
|
||||
} else {
|
||||
console.log(`✓ Template found: ${templateName}`);
|
||||
logger.error("Template not found, using fallback", {
|
||||
templateName,
|
||||
discoveredTemplates: Array.from(this.templateNames),
|
||||
});
|
||||
template = this.getFallbackTemplate(templateName);
|
||||
}
|
||||
|
||||
let rendered = template;
|
||||
|
||||
try {
|
||||
Object.keys(variables).forEach((key) => {
|
||||
// Variables ending in 'Html' or 'Section' contain trusted HTML content
|
||||
// (e.g., refundSection, stripeSection, earningsSection) - don't escape these
|
||||
const isTrustedHtml = key.endsWith("Html") || key.endsWith("Section");
|
||||
let value = variables[key] || "";
|
||||
|
||||
// Escape HTML in user-provided values to prevent XSS
|
||||
if (!isTrustedHtml && typeof value === "string") {
|
||||
value = escapeHtml(value);
|
||||
}
|
||||
|
||||
const regex = new RegExp(`{{${key}}}`, "g");
|
||||
rendered = rendered.replace(regex, variables[key] || "");
|
||||
rendered = rendered.replace(regex, value);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error rendering template ${templateName}:`, error);
|
||||
console.error(`Stack trace:`, error.stack);
|
||||
console.error(`Variables provided:`, Object.keys(variables));
|
||||
logger.error("Error rendering template", {
|
||||
templateName,
|
||||
variableKeys: Object.keys(variables),
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
return rendered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a fallback template when the HTML file is not available
|
||||
* @param {string} templateName - Name of the template
|
||||
* @returns {string} Fallback HTML template
|
||||
* Get a generic fallback template when the HTML file is not available
|
||||
* This is used as a last resort when a template cannot be loaded
|
||||
* @param {string} templateName - Name of the template (for logging)
|
||||
* @returns {string} Generic fallback HTML template
|
||||
*/
|
||||
getFallbackTemplate(templateName) {
|
||||
const baseTemplate = `
|
||||
logger.warn("Using generic fallback template", { templateName });
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{title}}</title>
|
||||
<title>Village Share</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5; }
|
||||
.container { max-width: 600px; margin: 0 auto; background-color: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||
.header { text-align: center; border-bottom: 2px solid #e9ecef; padding-bottom: 20px; margin-bottom: 30px; }
|
||||
.logo { font-size: 24px; font-weight: bold; color: #333; }
|
||||
.content { line-height: 1.6; color: #555; }
|
||||
.button { display: inline-block; background-color: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; margin: 20px 0; }
|
||||
.footer { margin-top: 30px; padding-top: 20px; border-top: 1px solid #e9ecef; text-align: center; font-size: 12px; color: #6c757d; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">RentAll</div>
|
||||
<div class="logo">Village Share</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
{{content}}
|
||||
<p>Hi {{recipientName}},</p>
|
||||
<h2>{{title}}</h2>
|
||||
<p>{{message}}</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>This email was sent from RentAll. 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>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const templates = {
|
||||
conditionCheckReminderToUser: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<h2>{{title}}</h2>
|
||||
<p>{{message}}</p>
|
||||
<p><strong>Rental Item:</strong> {{itemName}}</p>
|
||||
<p><strong>Deadline:</strong> {{deadline}}</p>
|
||||
<p>Please complete this condition check as soon as possible to ensure proper documentation.</p>
|
||||
`
|
||||
),
|
||||
|
||||
rentalConfirmationToUser: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{recipientName}},</p>
|
||||
<h2>{{title}}</h2>
|
||||
<p>{{message}}</p>
|
||||
<p><strong>Item:</strong> {{itemName}}</p>
|
||||
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
|
||||
<p>Thank you for using RentAll!</p>
|
||||
`
|
||||
),
|
||||
|
||||
emailVerificationToUser: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{recipientName}},</p>
|
||||
<h2>Verify Your Email Address</h2>
|
||||
<p>Thank you for registering with RentAll! 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 RentAll 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 RentAll 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 RentAll 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 RentAll. 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 RentAll 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 RentAll 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
htmlToPlainText,
|
||||
formatEmailDate,
|
||||
formatShortDate,
|
||||
formatCurrency,
|
||||
escapeHtml,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const EmailClient = require("../core/EmailClient");
|
||||
const TemplateManager = require("../core/TemplateManager");
|
||||
const logger = require("../../../utils/logger");
|
||||
|
||||
/**
|
||||
* AlphaInvitationEmailService handles alpha program invitation emails
|
||||
@@ -26,7 +27,7 @@ class AlphaInvitationEmailService {
|
||||
]);
|
||||
|
||||
this.initialized = true;
|
||||
console.log("Alpha Invitation Email Service initialized successfully");
|
||||
logger.info("Alpha Invitation Email Service initialized successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,7 +42,7 @@ class AlphaInvitationEmailService {
|
||||
}
|
||||
|
||||
try {
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const frontendUrl = process.env.FRONTEND_URL;
|
||||
|
||||
const variables = {
|
||||
code: code,
|
||||
@@ -53,16 +54,16 @@ class AlphaInvitationEmailService {
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
"alphaInvitationToUser",
|
||||
variables
|
||||
variables,
|
||||
);
|
||||
|
||||
return await this.emailClient.sendEmail(
|
||||
email,
|
||||
"Your Alpha Access Code - RentAll",
|
||||
htmlContent
|
||||
"Your Alpha Access Code - Village Share",
|
||||
htmlContent,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to send alpha invitation email:", error);
|
||||
logger.error("Failed to send alpha invitation email", { error });
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,23 +44,24 @@ class AuthEmailService {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const frontendUrl = process.env.FRONTEND_URL;
|
||||
const verificationUrl = `${frontendUrl}/verify-email?token=${verificationToken}`;
|
||||
|
||||
const variables = {
|
||||
recipientName: user.firstName || "there",
|
||||
verificationUrl: verificationUrl,
|
||||
verificationCode: verificationToken, // 6-digit code for display in email
|
||||
};
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
"emailVerificationToUser",
|
||||
variables
|
||||
variables,
|
||||
);
|
||||
|
||||
return await this.emailClient.sendEmail(
|
||||
user.email,
|
||||
"Verify Your Email - RentAll",
|
||||
htmlContent
|
||||
"Verify Your Email - Village Share",
|
||||
htmlContent,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,7 +78,7 @@ class AuthEmailService {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const frontendUrl = process.env.FRONTEND_URL;
|
||||
const resetUrl = `${frontendUrl}/reset-password?token=${resetToken}`;
|
||||
|
||||
const variables = {
|
||||
@@ -87,13 +88,13 @@ class AuthEmailService {
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
"passwordResetToUser",
|
||||
variables
|
||||
variables,
|
||||
);
|
||||
|
||||
return await this.emailClient.sendEmail(
|
||||
user.email,
|
||||
"Reset Your Password - RentAll",
|
||||
htmlContent
|
||||
"Reset Your Password - Village Share",
|
||||
htmlContent,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,13 +123,13 @@ class AuthEmailService {
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
"passwordChangedToUser",
|
||||
variables
|
||||
variables,
|
||||
);
|
||||
|
||||
return await this.emailClient.sendEmail(
|
||||
user.email,
|
||||
"Password Changed Successfully - RentAll",
|
||||
htmlContent
|
||||
"Password Changed Successfully - Village Share",
|
||||
htmlContent,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -157,13 +158,157 @@ class AuthEmailService {
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
"personalInfoChangedToUser",
|
||||
variables
|
||||
variables,
|
||||
);
|
||||
|
||||
return await this.emailClient.sendEmail(
|
||||
user.email,
|
||||
"Personal Information Updated - RentAll",
|
||||
htmlContent
|
||||
"Personal Information Updated - Village Share",
|
||||
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 TemplateManager = require("../core/TemplateManager");
|
||||
const logger = require("../../../utils/logger");
|
||||
|
||||
/**
|
||||
* CustomerServiceEmailService handles all customer service alert emails
|
||||
@@ -28,7 +29,7 @@ class CustomerServiceEmailService {
|
||||
]);
|
||||
|
||||
this.initialized = true;
|
||||
console.log("Customer Service Email Service initialized successfully");
|
||||
logger.info("Customer Service Email Service initialized successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,7 +60,7 @@ class CustomerServiceEmailService {
|
||||
try {
|
||||
const csEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
|
||||
if (!csEmail) {
|
||||
console.warn("No customer service email configured");
|
||||
logger.warn("No customer service email configured");
|
||||
return { success: false, error: "No customer service email configured" };
|
||||
}
|
||||
|
||||
@@ -92,14 +93,14 @@ class CustomerServiceEmailService {
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log(
|
||||
logger.info(
|
||||
`Late return notification sent to customer service for rental ${rental.id}`
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
logger.error(
|
||||
"Failed to send late return notification to customer service:",
|
||||
error
|
||||
);
|
||||
@@ -148,7 +149,7 @@ class CustomerServiceEmailService {
|
||||
try {
|
||||
const csEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
|
||||
if (!csEmail) {
|
||||
console.warn("No customer service email configured");
|
||||
logger.warn("No customer service email configured");
|
||||
return { success: false, error: "No customer service email configured" };
|
||||
}
|
||||
|
||||
@@ -206,14 +207,14 @@ class CustomerServiceEmailService {
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log(
|
||||
logger.info(
|
||||
`Damage report notification sent to customer service for rental ${rental.id}`
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
logger.error(
|
||||
"Failed to send damage report notification to customer service:",
|
||||
error
|
||||
);
|
||||
@@ -248,7 +249,7 @@ class CustomerServiceEmailService {
|
||||
try {
|
||||
const csEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
|
||||
if (!csEmail) {
|
||||
console.warn("No customer service email configured");
|
||||
logger.warn("No customer service email configured");
|
||||
return { success: false, error: "No customer service email configured" };
|
||||
}
|
||||
|
||||
@@ -280,14 +281,14 @@ class CustomerServiceEmailService {
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log(
|
||||
logger.info(
|
||||
`Lost item notification sent to customer service for rental ${rental.id}`
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
logger.error(
|
||||
"Failed to send lost item notification to customer service:",
|
||||
error
|
||||
);
|
||||
|
||||
@@ -60,13 +60,13 @@ class FeedbackEmailService {
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
"feedbackConfirmationToUser",
|
||||
variables
|
||||
variables,
|
||||
);
|
||||
|
||||
return await this.emailClient.sendEmail(
|
||||
user.email,
|
||||
"Thank You for Your Feedback - RentAll",
|
||||
htmlContent
|
||||
"Thank You for Your Feedback - Village Share",
|
||||
htmlContent,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,8 +90,7 @@ class FeedbackEmailService {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
const adminEmail =
|
||||
process.env.FEEDBACK_EMAIL || process.env.CUSTOMER_SUPPORT_EMAIL;
|
||||
const adminEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
|
||||
|
||||
if (!adminEmail) {
|
||||
console.warn("No admin email configured for feedback notifications");
|
||||
@@ -117,13 +116,13 @@ class FeedbackEmailService {
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
"feedbackNotificationToAdmin",
|
||||
variables
|
||||
variables,
|
||||
);
|
||||
|
||||
return await this.emailClient.sendEmail(
|
||||
adminEmail,
|
||||
`New Feedback from ${user.firstName} ${user.lastName}`,
|
||||
htmlContent
|
||||
htmlContent,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const EmailClient = require("../core/EmailClient");
|
||||
const TemplateManager = require("../core/TemplateManager");
|
||||
const logger = require("../../../utils/logger");
|
||||
|
||||
/**
|
||||
* ForumEmailService handles all forum-related email notifications
|
||||
@@ -31,7 +32,7 @@ class ForumEmailService {
|
||||
]);
|
||||
|
||||
this.initialized = true;
|
||||
console.log("Forum Email Service initialized successfully");
|
||||
logger.info("Forum Email Service initialized successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,7 +57,7 @@ class ForumEmailService {
|
||||
}
|
||||
|
||||
try {
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const frontendUrl = process.env.FRONTEND_URL;
|
||||
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
|
||||
|
||||
const timestamp = new Date(comment.createdAt).toLocaleString("en-US", {
|
||||
@@ -76,7 +77,7 @@ class ForumEmailService {
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
"forumCommentToPostAuthor",
|
||||
variables
|
||||
variables,
|
||||
);
|
||||
|
||||
const subject = `${commenter.firstName} ${commenter.lastName} commented on your post`;
|
||||
@@ -84,18 +85,18 @@ class ForumEmailService {
|
||||
const result = await this.emailClient.sendEmail(
|
||||
postAuthor.email,
|
||||
subject,
|
||||
htmlContent
|
||||
htmlContent,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log(
|
||||
`Forum comment notification email sent to ${postAuthor.email}`
|
||||
logger.info(
|
||||
`Forum comment notification email sent to ${postAuthor.email}`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Failed to send forum comment notification email:", error);
|
||||
logger.error("Failed to send forum comment notification email:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
@@ -123,14 +124,14 @@ class ForumEmailService {
|
||||
replier,
|
||||
post,
|
||||
reply,
|
||||
parentComment
|
||||
parentComment,
|
||||
) {
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
try {
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const frontendUrl = process.env.FRONTEND_URL;
|
||||
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
|
||||
|
||||
const timestamp = new Date(reply.createdAt).toLocaleString("en-US", {
|
||||
@@ -151,7 +152,7 @@ class ForumEmailService {
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
"forumReplyToCommentAuthor",
|
||||
variables
|
||||
variables,
|
||||
);
|
||||
|
||||
const subject = `${replier.firstName} ${replier.lastName} replied to your comment`;
|
||||
@@ -159,18 +160,18 @@ class ForumEmailService {
|
||||
const result = await this.emailClient.sendEmail(
|
||||
commentAuthor.email,
|
||||
subject,
|
||||
htmlContent
|
||||
htmlContent,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log(
|
||||
`Forum reply notification email sent to ${commentAuthor.email}`
|
||||
logger.info(
|
||||
`Forum reply notification email sent to ${commentAuthor.email}`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Failed to send forum reply notification email:", error);
|
||||
logger.error("Failed to send forum reply notification email:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
@@ -194,14 +195,14 @@ class ForumEmailService {
|
||||
commentAuthor,
|
||||
postAuthor,
|
||||
post,
|
||||
comment
|
||||
comment,
|
||||
) {
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
try {
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const frontendUrl = process.env.FRONTEND_URL;
|
||||
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
|
||||
|
||||
const variables = {
|
||||
@@ -215,7 +216,7 @@ class ForumEmailService {
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
"forumAnswerAcceptedToCommentAuthor",
|
||||
variables
|
||||
variables,
|
||||
);
|
||||
|
||||
const subject = `Your comment was marked as the accepted answer!`;
|
||||
@@ -223,20 +224,20 @@ class ForumEmailService {
|
||||
const result = await this.emailClient.sendEmail(
|
||||
commentAuthor.email,
|
||||
subject,
|
||||
htmlContent
|
||||
htmlContent,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log(
|
||||
`Forum answer accepted notification email sent to ${commentAuthor.email}`
|
||||
logger.info(
|
||||
`Forum answer accepted notification email sent to ${commentAuthor.email}`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
logger.error(
|
||||
"Failed to send forum answer accepted notification email:",
|
||||
error
|
||||
error,
|
||||
);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
@@ -262,14 +263,14 @@ class ForumEmailService {
|
||||
participant,
|
||||
commenter,
|
||||
post,
|
||||
comment
|
||||
comment,
|
||||
) {
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
try {
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const frontendUrl = process.env.FRONTEND_URL;
|
||||
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
|
||||
|
||||
const timestamp = new Date(comment.createdAt).toLocaleString("en-US", {
|
||||
@@ -289,7 +290,7 @@ class ForumEmailService {
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
"forumThreadActivityToParticipant",
|
||||
variables
|
||||
variables,
|
||||
);
|
||||
|
||||
const subject = `New activity on a post you're following`;
|
||||
@@ -297,20 +298,20 @@ class ForumEmailService {
|
||||
const result = await this.emailClient.sendEmail(
|
||||
participant.email,
|
||||
subject,
|
||||
htmlContent
|
||||
htmlContent,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log(
|
||||
`Forum thread activity notification email sent to ${participant.email}`
|
||||
logger.info(
|
||||
`Forum thread activity notification email sent to ${participant.email}`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
logger.error(
|
||||
"Failed to send forum thread activity notification email:",
|
||||
error
|
||||
error,
|
||||
);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
@@ -330,18 +331,13 @@ class ForumEmailService {
|
||||
* @param {Date} closedAt - Timestamp when discussion was closed
|
||||
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
|
||||
*/
|
||||
async sendForumPostClosedNotification(
|
||||
recipient,
|
||||
closer,
|
||||
post,
|
||||
closedAt
|
||||
) {
|
||||
async sendForumPostClosedNotification(recipient, closer, post, closedAt) {
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
try {
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const frontendUrl = process.env.FRONTEND_URL;
|
||||
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
|
||||
|
||||
const timestamp = new Date(closedAt).toLocaleString("en-US", {
|
||||
@@ -351,8 +347,7 @@ class ForumEmailService {
|
||||
|
||||
const variables = {
|
||||
recipientName: recipient.firstName || "there",
|
||||
adminName:
|
||||
`${closer.firstName} ${closer.lastName}`.trim() || "A user",
|
||||
adminName: `${closer.firstName} ${closer.lastName}`.trim() || "A user",
|
||||
postTitle: post.title,
|
||||
postUrl: postUrl,
|
||||
timestamp: timestamp,
|
||||
@@ -360,7 +355,7 @@ class ForumEmailService {
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
"forumPostClosed",
|
||||
variables
|
||||
variables,
|
||||
);
|
||||
|
||||
const subject = `Discussion closed: ${post.title}`;
|
||||
@@ -368,20 +363,20 @@ class ForumEmailService {
|
||||
const result = await this.emailClient.sendEmail(
|
||||
recipient.email,
|
||||
subject,
|
||||
htmlContent
|
||||
htmlContent,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log(
|
||||
`Forum post closed notification email sent to ${recipient.email}`
|
||||
logger.info(
|
||||
`Forum post closed notification email sent to ${recipient.email}`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
logger.error(
|
||||
"Failed to send forum post closed notification email:",
|
||||
error
|
||||
error,
|
||||
);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
@@ -400,18 +395,24 @@ class ForumEmailService {
|
||||
* @param {string} deletionReason - Reason for deletion
|
||||
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
|
||||
*/
|
||||
async sendForumPostDeletionNotification(postAuthor, admin, post, deletionReason) {
|
||||
async sendForumPostDeletionNotification(
|
||||
postAuthor,
|
||||
admin,
|
||||
post,
|
||||
deletionReason,
|
||||
) {
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
try {
|
||||
const supportEmail = process.env.SUPPORT_EMAIL;
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const supportEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
|
||||
const frontendUrl = process.env.FRONTEND_URL;
|
||||
|
||||
const variables = {
|
||||
postAuthorName: postAuthor.firstName || "there",
|
||||
adminName: `${admin.firstName} ${admin.lastName}`.trim() || "An administrator",
|
||||
adminName:
|
||||
`${admin.firstName} ${admin.lastName}`.trim() || "An administrator",
|
||||
postTitle: post.title,
|
||||
deletionReason,
|
||||
supportEmail,
|
||||
@@ -420,7 +421,7 @@ class ForumEmailService {
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
"forumPostDeletionToAuthor",
|
||||
variables
|
||||
variables,
|
||||
);
|
||||
|
||||
const subject = `Important: Your forum post "${post.title}" has been removed`;
|
||||
@@ -428,20 +429,20 @@ class ForumEmailService {
|
||||
const result = await this.emailClient.sendEmail(
|
||||
postAuthor.email,
|
||||
subject,
|
||||
htmlContent
|
||||
htmlContent,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log(
|
||||
`Forum post deletion notification email sent to ${postAuthor.email}`
|
||||
logger.info(
|
||||
`Forum post deletion notification email sent to ${postAuthor.email}`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
logger.error(
|
||||
"Failed to send forum post deletion notification email:",
|
||||
error
|
||||
error,
|
||||
);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
@@ -461,19 +462,25 @@ class ForumEmailService {
|
||||
* @param {string} deletionReason - Reason for deletion
|
||||
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
|
||||
*/
|
||||
async sendForumCommentDeletionNotification(commentAuthor, admin, post, deletionReason) {
|
||||
async sendForumCommentDeletionNotification(
|
||||
commentAuthor,
|
||||
admin,
|
||||
post,
|
||||
deletionReason,
|
||||
) {
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
try {
|
||||
const supportEmail = process.env.SUPPORT_EMAIL;
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const supportEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
|
||||
const frontendUrl = process.env.FRONTEND_URL;
|
||||
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
|
||||
|
||||
const variables = {
|
||||
commentAuthorName: commentAuthor.firstName || "there",
|
||||
adminName: `${admin.firstName} ${admin.lastName}`.trim() || "An administrator",
|
||||
adminName:
|
||||
`${admin.firstName} ${admin.lastName}`.trim() || "An administrator",
|
||||
postTitle: post.title,
|
||||
postUrl,
|
||||
deletionReason,
|
||||
@@ -482,7 +489,7 @@ class ForumEmailService {
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
"forumCommentDeletionToAuthor",
|
||||
variables
|
||||
variables,
|
||||
);
|
||||
|
||||
const subject = `Your comment on "${post.title}" has been removed`;
|
||||
@@ -490,20 +497,20 @@ class ForumEmailService {
|
||||
const result = await this.emailClient.sendEmail(
|
||||
commentAuthor.email,
|
||||
subject,
|
||||
htmlContent
|
||||
htmlContent,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log(
|
||||
`Forum comment deletion notification email sent to ${commentAuthor.email}`
|
||||
logger.info(
|
||||
`Forum comment deletion notification email sent to ${commentAuthor.email}`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
logger.error(
|
||||
"Failed to send forum comment deletion notification email:",
|
||||
error
|
||||
error,
|
||||
);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
@@ -530,7 +537,7 @@ class ForumEmailService {
|
||||
}
|
||||
|
||||
try {
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const frontendUrl = process.env.FRONTEND_URL;
|
||||
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
|
||||
|
||||
const variables = {
|
||||
@@ -545,7 +552,7 @@ class ForumEmailService {
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
"forumItemRequestNotification",
|
||||
variables
|
||||
variables,
|
||||
);
|
||||
|
||||
const subject = `Someone nearby is looking for: ${post.title}`;
|
||||
@@ -553,18 +560,18 @@ class ForumEmailService {
|
||||
const result = await this.emailClient.sendEmail(
|
||||
recipient.email,
|
||||
subject,
|
||||
htmlContent
|
||||
htmlContent,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log(
|
||||
`Item request notification email sent to ${recipient.email}`
|
||||
logger.info(
|
||||
`Item request notification email sent to ${recipient.email}`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Failed to send item request notification email:", error);
|
||||
logger.error("Failed to send item request notification email:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const EmailClient = require("../core/EmailClient");
|
||||
const TemplateManager = require("../core/TemplateManager");
|
||||
const logger = require("../../../utils/logger");
|
||||
|
||||
/**
|
||||
* MessagingEmailService handles all messaging-related email notifications
|
||||
@@ -26,7 +27,7 @@ class MessagingEmailService {
|
||||
]);
|
||||
|
||||
this.initialized = true;
|
||||
console.log("Messaging Email Service initialized successfully");
|
||||
logger.info("Messaging Email Service initialized successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,7 +50,7 @@ class MessagingEmailService {
|
||||
}
|
||||
|
||||
try {
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const frontendUrl = process.env.FRONTEND_URL;
|
||||
const conversationUrl = `${frontendUrl}/messages/conversations/${sender.id}`;
|
||||
|
||||
const timestamp = new Date(message.createdAt).toLocaleString("en-US", {
|
||||
@@ -67,7 +68,7 @@ class MessagingEmailService {
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
"newMessageToUser",
|
||||
variables
|
||||
variables,
|
||||
);
|
||||
|
||||
const subject = `New message from ${sender.firstName} ${sender.lastName}`;
|
||||
@@ -75,18 +76,18 @@ class MessagingEmailService {
|
||||
const result = await this.emailClient.sendEmail(
|
||||
receiver.email,
|
||||
subject,
|
||||
htmlContent
|
||||
htmlContent,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log(
|
||||
`Message notification email sent to ${receiver.email} from ${sender.firstName} ${sender.lastName}`
|
||||
logger.info(
|
||||
`Message notification email sent to ${receiver.email} from ${sender.firstName} ${sender.lastName}`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Failed to send message notification email:", error);
|
||||
logger.error("Failed to send message notification email:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -1,5 +1,6 @@
|
||||
const EmailClient = require("../core/EmailClient");
|
||||
const TemplateManager = require("../core/TemplateManager");
|
||||
const logger = require("../../../utils/logger");
|
||||
|
||||
/**
|
||||
* RentalFlowEmailService handles rental lifecycle flow emails
|
||||
@@ -33,7 +34,7 @@ class RentalFlowEmailService {
|
||||
]);
|
||||
|
||||
this.initialized = true;
|
||||
console.log("Rental Flow Email Service initialized successfully");
|
||||
logger.info("Rental Flow Email Service initialized successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,12 +62,13 @@ class RentalFlowEmailService {
|
||||
}
|
||||
|
||||
try {
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const frontendUrl = process.env.FRONTEND_URL;
|
||||
const approveUrl = `${frontendUrl}/owning?rentalId=${rental.id}`;
|
||||
|
||||
const variables = {
|
||||
ownerName: owner.firstName,
|
||||
renterName: `${renter.firstName} ${renter.lastName}`.trim() || "A renter",
|
||||
renterName:
|
||||
`${renter.firstName} ${renter.lastName}`.trim() || "A renter",
|
||||
itemName: rental.item?.name || "your item",
|
||||
startDate: rental.startDateTime
|
||||
? new Date(rental.startDateTime).toLocaleString("en-US", {
|
||||
@@ -93,16 +95,16 @@ class RentalFlowEmailService {
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
"rentalRequestToOwner",
|
||||
variables
|
||||
variables,
|
||||
);
|
||||
|
||||
return await this.emailClient.sendEmail(
|
||||
owner.email,
|
||||
`Rental Request for ${rental.item?.name || "Your Item"}`,
|
||||
htmlContent
|
||||
htmlContent,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to send rental request email:", error);
|
||||
logger.error("Failed to send rental request email", { error });
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
@@ -127,7 +129,7 @@ class RentalFlowEmailService {
|
||||
}
|
||||
|
||||
try {
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const frontendUrl = process.env.FRONTEND_URL;
|
||||
const viewRentalsUrl = `${frontendUrl}/renting`;
|
||||
|
||||
// Determine payment message based on rental amount
|
||||
@@ -160,16 +162,18 @@ class RentalFlowEmailService {
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
"rentalRequestConfirmationToRenter",
|
||||
variables
|
||||
variables,
|
||||
);
|
||||
|
||||
return await this.emailClient.sendEmail(
|
||||
renter.email,
|
||||
`Rental Request Submitted - ${rental.item?.name || "Item"}`,
|
||||
htmlContent
|
||||
htmlContent,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to send rental request confirmation email:", error);
|
||||
logger.error("Failed to send rental request confirmation email", {
|
||||
error,
|
||||
});
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
@@ -201,7 +205,7 @@ class RentalFlowEmailService {
|
||||
}
|
||||
|
||||
try {
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const frontendUrl = process.env.FRONTEND_URL;
|
||||
|
||||
// Determine if Stripe setup is needed
|
||||
const hasStripeAccount = !!owner.stripeConnectedAccountId;
|
||||
@@ -226,15 +230,15 @@ class RentalFlowEmailService {
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>Total Rental Amount</th>
|
||||
<td>\\$${totalAmount.toFixed(2)}</td>
|
||||
<td>$${totalAmount.toFixed(2)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Community Upkeep Fee (10%)</th>
|
||||
<td>-\\$${platformFee.toFixed(2)}</td>
|
||||
<td>-$${platformFee.toFixed(2)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Your Payout</th>
|
||||
<td class="highlight">\\$${payoutAmount.toFixed(2)}</td>
|
||||
<td class="highlight">$${payoutAmount.toFixed(2)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
`;
|
||||
@@ -247,8 +251,8 @@ class RentalFlowEmailService {
|
||||
stripeSection = `
|
||||
<div class="warning-box">
|
||||
<p><strong>⚠️ Action Required: Set Up Your Earnings Account</strong></p>
|
||||
<p>To receive your payout of <strong>\\$${payoutAmount.toFixed(
|
||||
2
|
||||
<p>To receive your payout of <strong>$${payoutAmount.toFixed(
|
||||
2,
|
||||
)}</strong> when this rental completes, you need to set up your earnings account.</p>
|
||||
</div>
|
||||
<h2>Set Up Earnings to Get Paid</h2>
|
||||
@@ -258,7 +262,7 @@ class RentalFlowEmailService {
|
||||
<li><strong>Automatic payouts</strong> when rentals complete</li>
|
||||
<li><strong>Secure transfers</strong> directly to your bank account</li>
|
||||
<li><strong>Track all earnings</strong> in one dashboard</li>
|
||||
<li><strong>Fast deposits</strong> (typically 2-3 business days)</li>
|
||||
<li><strong>Fast deposits</strong> (typically 2-7 business days)</li>
|
||||
</ul>
|
||||
<p>Setup only takes about 5 minutes and you only need to do it once.</p>
|
||||
</div>
|
||||
@@ -273,8 +277,8 @@ class RentalFlowEmailService {
|
||||
stripeSection = `
|
||||
<div class="success-box">
|
||||
<p><strong>✓ Earnings Account Active</strong></p>
|
||||
<p>Your earnings account is set up. You'll automatically receive \\$${payoutAmount.toFixed(
|
||||
2
|
||||
<p>Your earnings account is set up. You'll automatically receive $${payoutAmount.toFixed(
|
||||
2,
|
||||
)} when this rental completes.</p>
|
||||
<p><a href="${frontendUrl}/earnings" style="color: #155724; text-decoration: underline;">View your earnings dashboard →</a></p>
|
||||
</div>
|
||||
@@ -311,7 +315,7 @@ class RentalFlowEmailService {
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
"rentalApprovalConfirmationToOwner",
|
||||
variables
|
||||
variables,
|
||||
);
|
||||
|
||||
const subject = `Rental Approved - ${rental.item?.name || "Your Item"}`;
|
||||
@@ -319,10 +323,12 @@ class RentalFlowEmailService {
|
||||
return await this.emailClient.sendEmail(
|
||||
owner.email,
|
||||
subject,
|
||||
htmlContent
|
||||
htmlContent,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to send rental approval confirmation email:", error);
|
||||
logger.error("Failed to send rental approval confirmation email", {
|
||||
error,
|
||||
});
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
@@ -349,7 +355,7 @@ class RentalFlowEmailService {
|
||||
}
|
||||
|
||||
try {
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const frontendUrl = process.env.FRONTEND_URL;
|
||||
const browseItemsUrl = `${frontendUrl}/`;
|
||||
|
||||
// Determine payment message based on rental amount
|
||||
@@ -396,16 +402,16 @@ class RentalFlowEmailService {
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
"rentalDeclinedToRenter",
|
||||
variables
|
||||
variables,
|
||||
);
|
||||
|
||||
return await this.emailClient.sendEmail(
|
||||
renter.email,
|
||||
`Rental Request Declined - ${rental.item?.name || "Item"}`,
|
||||
htmlContent
|
||||
htmlContent,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to send rental declined email:", error);
|
||||
logger.error("Failed to send rental declined email", { error });
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
@@ -436,7 +442,7 @@ class RentalFlowEmailService {
|
||||
notification,
|
||||
rental,
|
||||
recipientName = null,
|
||||
isRenter = false
|
||||
isRenter = false,
|
||||
) {
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
@@ -531,7 +537,7 @@ class RentalFlowEmailService {
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
"rentalConfirmationToUser",
|
||||
variables
|
||||
variables,
|
||||
);
|
||||
|
||||
// Use clear, transactional subject line with item name
|
||||
@@ -539,7 +545,7 @@ class RentalFlowEmailService {
|
||||
|
||||
return await this.emailClient.sendEmail(userEmail, subject, htmlContent);
|
||||
} catch (error) {
|
||||
console.error("Failed to send rental confirmation:", error);
|
||||
logger.error("Failed to send rental confirmation", { error });
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
@@ -600,24 +606,24 @@ class RentalFlowEmailService {
|
||||
ownerNotification,
|
||||
rental,
|
||||
owner.firstName,
|
||||
false // isRenter = false for owner
|
||||
false, // isRenter = false for owner
|
||||
);
|
||||
if (ownerResult.success) {
|
||||
console.log(
|
||||
`Rental confirmation email sent to owner: ${owner.email}`
|
||||
);
|
||||
logger.info("Rental confirmation email sent to owner", {
|
||||
email: owner.email,
|
||||
});
|
||||
results.ownerEmailSent = true;
|
||||
} else {
|
||||
console.error(
|
||||
`Failed to send rental confirmation email to owner (${owner.email}):`,
|
||||
ownerResult.error
|
||||
);
|
||||
logger.error("Failed to send rental confirmation email to owner", {
|
||||
email: owner.email,
|
||||
error: ownerResult.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to send rental confirmation email to owner (${owner.email}):`,
|
||||
error.message
|
||||
);
|
||||
logger.error("Failed to send rental confirmation email to owner", {
|
||||
email: owner.email,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,31 +635,30 @@ class RentalFlowEmailService {
|
||||
renterNotification,
|
||||
rental,
|
||||
renter.firstName,
|
||||
true // isRenter = true for renter (enables payment receipt)
|
||||
true, // isRenter = true for renter (enables payment receipt)
|
||||
);
|
||||
if (renterResult.success) {
|
||||
console.log(
|
||||
`Rental confirmation email sent to renter: ${renter.email}`
|
||||
);
|
||||
logger.info("Rental confirmation email sent to renter", {
|
||||
email: renter.email,
|
||||
});
|
||||
results.renterEmailSent = true;
|
||||
} else {
|
||||
console.error(
|
||||
`Failed to send rental confirmation email to renter (${renter.email}):`,
|
||||
renterResult.error
|
||||
);
|
||||
logger.error("Failed to send rental confirmation email to renter", {
|
||||
email: renter.email,
|
||||
error: renterResult.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to send rental confirmation email to renter (${renter.email}):`,
|
||||
error.message
|
||||
);
|
||||
logger.error("Failed to send rental confirmation email to renter", {
|
||||
email: renter.email,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Error fetching user data for rental confirmation emails:",
|
||||
error
|
||||
);
|
||||
logger.error("Error fetching user data for rental confirmation emails", {
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
@@ -692,7 +697,7 @@ class RentalFlowEmailService {
|
||||
};
|
||||
|
||||
try {
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const frontendUrl = process.env.FRONTEND_URL;
|
||||
const browseUrl = `${frontendUrl}/`;
|
||||
|
||||
const cancelledBy = rental.cancelledBy;
|
||||
@@ -736,7 +741,7 @@ class RentalFlowEmailService {
|
||||
<div class="info-box">
|
||||
<p><strong>Full Refund Processed</strong></p>
|
||||
<p>You will receive a full refund of $${refundInfo.amount.toFixed(
|
||||
2
|
||||
2,
|
||||
)}. The refund will appear in your account within 5-10 business days.</p>
|
||||
</div>
|
||||
<div style="text-align: center">
|
||||
@@ -779,7 +784,7 @@ class RentalFlowEmailService {
|
||||
<div class="refund-amount">$${refundInfo.amount.toFixed(2)}</div>
|
||||
<div class="info-box">
|
||||
<p><strong>Refund Amount:</strong> $${refundInfo.amount.toFixed(
|
||||
2
|
||||
2,
|
||||
)} (${refundPercentage}% of total)</p>
|
||||
<p><strong>Reason:</strong> ${refundInfo.reason}</p>
|
||||
<p><strong>Processing Time:</strong> Refunds typically appear within 5-10 business days.</p>
|
||||
@@ -809,26 +814,27 @@ class RentalFlowEmailService {
|
||||
|
||||
const confirmationHtml = await this.templateManager.renderTemplate(
|
||||
"rentalCancellationConfirmationToUser",
|
||||
confirmationVariables
|
||||
confirmationVariables,
|
||||
);
|
||||
|
||||
const confirmationResult = await this.emailClient.sendEmail(
|
||||
confirmationRecipient,
|
||||
`Cancellation Confirmed - ${itemName}`,
|
||||
confirmationHtml
|
||||
confirmationHtml,
|
||||
);
|
||||
|
||||
if (confirmationResult.success) {
|
||||
console.log(
|
||||
`Cancellation confirmation email sent to ${cancelledBy}: ${confirmationRecipient}`
|
||||
);
|
||||
logger.info("Cancellation confirmation email sent", {
|
||||
cancelledBy,
|
||||
email: confirmationRecipient,
|
||||
});
|
||||
results.confirmationEmailSent = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to send cancellation confirmation email to ${cancelledBy}:`,
|
||||
error.message
|
||||
);
|
||||
logger.error("Failed to send cancellation confirmation email", {
|
||||
cancelledBy,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
// Send notification email to other party
|
||||
@@ -845,31 +851,29 @@ class RentalFlowEmailService {
|
||||
|
||||
const notificationHtml = await this.templateManager.renderTemplate(
|
||||
"rentalCancellationNotificationToUser",
|
||||
notificationVariables
|
||||
notificationVariables,
|
||||
);
|
||||
|
||||
const notificationResult = await this.emailClient.sendEmail(
|
||||
notificationRecipient,
|
||||
`Rental Cancelled - ${itemName}`,
|
||||
notificationHtml
|
||||
notificationHtml,
|
||||
);
|
||||
|
||||
if (notificationResult.success) {
|
||||
console.log(
|
||||
`Cancellation notification email sent to ${
|
||||
cancelledBy === "owner" ? "renter" : "owner"
|
||||
}: ${notificationRecipient}`
|
||||
);
|
||||
logger.info("Cancellation notification email sent", {
|
||||
recipientType: cancelledBy === "owner" ? "renter" : "owner",
|
||||
email: notificationRecipient,
|
||||
});
|
||||
results.notificationEmailSent = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to send cancellation notification email:`,
|
||||
error.message
|
||||
);
|
||||
logger.error("Failed to send cancellation notification email", {
|
||||
error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending cancellation emails:", error);
|
||||
logger.error("Error sending cancellation emails", { error });
|
||||
}
|
||||
|
||||
return results;
|
||||
@@ -904,7 +908,7 @@ class RentalFlowEmailService {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const frontendUrl = process.env.FRONTEND_URL;
|
||||
const results = {
|
||||
renterEmailSent: false,
|
||||
ownerEmailSent: false,
|
||||
@@ -941,7 +945,7 @@ class RentalFlowEmailService {
|
||||
<h2>Share Your Experience</h2>
|
||||
<div class="info-box">
|
||||
<p><strong>Help the community by leaving a review!</strong></p>
|
||||
<p>Your feedback helps other renters make informed decisions and supports quality listings on RentAll.</p>
|
||||
<p>Your feedback helps other renters make informed decisions and supports quality listings on Village Share.</p>
|
||||
<ul>
|
||||
<li>How was the item's condition?</li>
|
||||
<li>Was the owner responsive and helpful?</li>
|
||||
@@ -956,7 +960,7 @@ class RentalFlowEmailService {
|
||||
reviewSection = `
|
||||
<div class="success-box">
|
||||
<p><strong>✓ Thank You for Your Review!</strong></p>
|
||||
<p>Your feedback has been submitted and helps strengthen the RentAll community.</p>
|
||||
<p>Your feedback has been submitted and helps strengthen the Village Share community.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -976,31 +980,33 @@ class RentalFlowEmailService {
|
||||
|
||||
const renterHtmlContent = await this.templateManager.renderTemplate(
|
||||
"rentalCompletionThankYouToRenter",
|
||||
renterVariables
|
||||
renterVariables,
|
||||
);
|
||||
|
||||
const renterResult = await this.emailClient.sendEmail(
|
||||
renter.email,
|
||||
`Thank You for Returning "${rental.item?.name || "Item"}" On Time!`,
|
||||
renterHtmlContent
|
||||
renterHtmlContent,
|
||||
);
|
||||
|
||||
if (renterResult.success) {
|
||||
console.log(
|
||||
`Rental completion thank you email sent to renter: ${renter.email}`
|
||||
);
|
||||
logger.info("Rental completion thank you email sent to renter", {
|
||||
email: renter.email,
|
||||
});
|
||||
results.renterEmailSent = true;
|
||||
} else {
|
||||
console.error(
|
||||
`Failed to send rental completion email to renter (${renter.email}):`,
|
||||
renterResult.error
|
||||
);
|
||||
logger.error("Failed to send rental completion email to renter", {
|
||||
email: renter.email,
|
||||
error: renterResult.error,
|
||||
});
|
||||
}
|
||||
} catch (emailError) {
|
||||
console.error(
|
||||
`Failed to send rental completion email to renter (${renter.email}):`,
|
||||
emailError.message
|
||||
);
|
||||
logger.error("Failed to send rental completion email to renter", {
|
||||
error: emailError.message,
|
||||
stack: emailError.stack,
|
||||
renterEmail: renter.email,
|
||||
rentalId: rental.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare owner email
|
||||
@@ -1018,19 +1024,19 @@ class RentalFlowEmailService {
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>Total Rental Amount</th>
|
||||
<td>\\$${totalAmount.toFixed(2)}</td>
|
||||
<td>$${totalAmount.toFixed(2)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Community Upkeep Fee (10%)</th>
|
||||
<td>-\\$${platformFee.toFixed(2)}</td>
|
||||
<td>-$${platformFee.toFixed(2)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Your Payout</th>
|
||||
<td class="highlight">\\$${payoutAmount.toFixed(2)}</td>
|
||||
<td class="highlight">$${payoutAmount.toFixed(2)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="font-size: 14px; color: #6c757d;">
|
||||
Your earnings will be automatically transferred to your account when the rental period ends and any dispute windows close.
|
||||
Your earnings are transferred immediately when the rental is marked complete. Funds typically reach your bank within 2-7 business days.
|
||||
</p>
|
||||
`;
|
||||
}
|
||||
@@ -1042,8 +1048,8 @@ class RentalFlowEmailService {
|
||||
stripeSection = `
|
||||
<div class="warning-box">
|
||||
<p><strong>⚠️ Action Required: Set Up Your Earnings Account</strong></p>
|
||||
<p>To receive your payout of <strong>\\$${payoutAmount.toFixed(
|
||||
2
|
||||
<p>To receive your payout of <strong>$${payoutAmount.toFixed(
|
||||
2,
|
||||
)}</strong>, you need to set up your earnings account.</p>
|
||||
</div>
|
||||
<h2>Set Up Earnings to Get Paid</h2>
|
||||
@@ -1053,7 +1059,7 @@ class RentalFlowEmailService {
|
||||
<li><strong>Automatic payouts</strong> when the rental period ends</li>
|
||||
<li><strong>Secure transfers</strong> directly to your bank account</li>
|
||||
<li><strong>Track all earnings</strong> in one dashboard</li>
|
||||
<li><strong>Fast deposits</strong> (typically 2-3 business days)</li>
|
||||
<li><strong>Fast deposits</strong> (typically 2-7 business days)</li>
|
||||
</ul>
|
||||
<p>Setup only takes about 5 minutes and you only need to do it once.</p>
|
||||
</div>
|
||||
@@ -1067,10 +1073,11 @@ class RentalFlowEmailService {
|
||||
} else if (hasStripeAccount && isPaidRental) {
|
||||
stripeSection = `
|
||||
<div class="success-box">
|
||||
<p><strong>✓ Earnings Account Active</strong></p>
|
||||
<p>Your earnings account is set up. You'll automatically receive \\$${payoutAmount.toFixed(
|
||||
2
|
||||
)} when the rental period ends.</p>
|
||||
<p><strong>✓ Payout Initiated</strong></p>
|
||||
<p>Your earnings of <strong>$${payoutAmount.toFixed(
|
||||
2,
|
||||
)}</strong> have been transferred to your Stripe account.</p>
|
||||
<p style="font-size: 14px; margin-top: 10px;">Funds typically reach your bank within 2-7 business days.</p>
|
||||
<p><a href="${frontendUrl}/earnings" style="color: #155724; text-decoration: underline;">View your earnings dashboard →</a></p>
|
||||
</div>
|
||||
`;
|
||||
@@ -1093,34 +1100,40 @@ class RentalFlowEmailService {
|
||||
|
||||
const ownerHtmlContent = await this.templateManager.renderTemplate(
|
||||
"rentalCompletionCongratsToOwner",
|
||||
ownerVariables
|
||||
ownerVariables,
|
||||
);
|
||||
|
||||
const ownerResult = await this.emailClient.sendEmail(
|
||||
owner.email,
|
||||
`Rental Complete - ${rental.item?.name || "Your Item"}`,
|
||||
ownerHtmlContent
|
||||
ownerHtmlContent,
|
||||
);
|
||||
|
||||
if (ownerResult.success) {
|
||||
console.log(
|
||||
`Rental completion congratulations email sent to owner: ${owner.email}`
|
||||
);
|
||||
logger.info("Rental completion congratulations email sent to owner", {
|
||||
email: owner.email,
|
||||
});
|
||||
results.ownerEmailSent = true;
|
||||
} else {
|
||||
console.error(
|
||||
`Failed to send rental completion email to owner (${owner.email}):`,
|
||||
ownerResult.error
|
||||
);
|
||||
logger.error("Failed to send rental completion email to owner", {
|
||||
email: owner.email,
|
||||
error: ownerResult.error,
|
||||
});
|
||||
}
|
||||
} catch (emailError) {
|
||||
console.error(
|
||||
`Failed to send rental completion email to owner (${owner.email}):`,
|
||||
emailError.message
|
||||
);
|
||||
logger.error("Failed to send rental completion email to owner", {
|
||||
error: emailError.message,
|
||||
stack: emailError.stack,
|
||||
ownerEmail: owner.email,
|
||||
rentalId: rental.id,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending rental completion emails:", error);
|
||||
logger.error("Error sending rental completion emails", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
rentalId: rental?.id,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
@@ -1148,7 +1161,7 @@ class RentalFlowEmailService {
|
||||
}
|
||||
|
||||
try {
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const frontendUrl = process.env.FRONTEND_URL;
|
||||
const earningsDashboardUrl = `${frontendUrl}/earnings`;
|
||||
|
||||
// Format currency values
|
||||
@@ -1180,7 +1193,7 @@ class RentalFlowEmailService {
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
"payoutReceivedToOwner",
|
||||
variables
|
||||
variables,
|
||||
);
|
||||
|
||||
return await this.emailClient.sendEmail(
|
||||
@@ -1188,10 +1201,54 @@ class RentalFlowEmailService {
|
||||
`Earnings Received - $${payoutAmount.toFixed(2)} for ${
|
||||
rental.item?.name || "Your Item"
|
||||
}`,
|
||||
htmlContent
|
||||
htmlContent,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to send payout received email:", error);
|
||||
logger.error("Failed to send payout received email", { error });
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send authentication required email to renter when 3DS verification is needed
|
||||
* This is sent when the owner approves a rental but the renter's bank requires
|
||||
* additional verification (3D Secure) to complete the payment.
|
||||
*
|
||||
* @param {string} email - Renter's email address
|
||||
* @param {Object} data - Email data
|
||||
* @param {string} data.renterName - Renter's first name
|
||||
* @param {string} data.itemName - Name of the item being rented
|
||||
* @param {string} data.ownerName - Owner's first name
|
||||
* @param {number} data.amount - Total rental amount
|
||||
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
|
||||
*/
|
||||
async sendAuthenticationRequiredEmail(email, data) {
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
try {
|
||||
const { renterName, itemName, ownerName, amount } = data;
|
||||
|
||||
const variables = {
|
||||
renterName: renterName || "there",
|
||||
itemName: itemName || "the item",
|
||||
ownerName: ownerName || "The owner",
|
||||
amount: typeof amount === "number" ? amount.toFixed(2) : "0.00",
|
||||
};
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
"authenticationRequiredToRenter",
|
||||
variables,
|
||||
);
|
||||
|
||||
return await this.emailClient.sendEmail(
|
||||
email,
|
||||
`Action Required: Complete payment for ${itemName}`,
|
||||
htmlContent,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Failed to send authentication required email", { error });
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const EmailClient = require("../core/EmailClient");
|
||||
const TemplateManager = require("../core/TemplateManager");
|
||||
const logger = require("../../../utils/logger");
|
||||
|
||||
/**
|
||||
* RentalReminderEmailService handles rental reminder emails
|
||||
@@ -26,7 +27,7 @@ class RentalReminderEmailService {
|
||||
]);
|
||||
|
||||
this.initialized = true;
|
||||
console.log("Rental Reminder Email Service initialized successfully");
|
||||
logger.info("Rental Reminder Email Service initialized successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,11 +65,11 @@ class RentalReminderEmailService {
|
||||
|
||||
return await this.emailClient.sendEmail(
|
||||
userEmail,
|
||||
`RentAll: ${notification.title}`,
|
||||
`Village Share: ${notification.title}`,
|
||||
htmlContent
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to send condition check reminder:", error);
|
||||
logger.error("Failed to send condition check reminder:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const EmailClient = require("../core/EmailClient");
|
||||
const TemplateManager = require("../core/TemplateManager");
|
||||
const logger = require("../../../utils/logger");
|
||||
|
||||
/**
|
||||
* UserEngagementEmailService handles user engagement emails
|
||||
@@ -27,7 +28,7 @@ class UserEngagementEmailService {
|
||||
]);
|
||||
|
||||
this.initialized = true;
|
||||
console.log("User Engagement Email Service initialized successfully");
|
||||
logger.info("User Engagement Email Service initialized successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,7 +47,7 @@ class UserEngagementEmailService {
|
||||
}
|
||||
|
||||
try {
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const frontendUrl = process.env.FRONTEND_URL;
|
||||
|
||||
const variables = {
|
||||
ownerName: owner.firstName || "there",
|
||||
@@ -57,18 +58,18 @@ class UserEngagementEmailService {
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
"firstListingCelebrationToOwner",
|
||||
variables
|
||||
variables,
|
||||
);
|
||||
|
||||
const subject = `Congratulations! Your first item is live on RentAll`;
|
||||
const subject = `Congratulations! Your first item is live on Village Share`;
|
||||
|
||||
return await this.emailClient.sendEmail(
|
||||
owner.email,
|
||||
subject,
|
||||
htmlContent
|
||||
htmlContent,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to send first listing celebration email:", error);
|
||||
logger.error("Failed to send first listing celebration email", { error });
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
@@ -90,8 +91,8 @@ class UserEngagementEmailService {
|
||||
}
|
||||
|
||||
try {
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const supportEmail = process.env.SUPPORT_EMAIL;
|
||||
const frontendUrl = process.env.FRONTEND_URL;
|
||||
const supportEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
|
||||
|
||||
const variables = {
|
||||
ownerName: owner.firstName || "there",
|
||||
@@ -103,7 +104,7 @@ class UserEngagementEmailService {
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
"itemDeletionToOwner",
|
||||
variables
|
||||
variables,
|
||||
);
|
||||
|
||||
const subject = `Important: Your listing "${item.name}" has been removed`;
|
||||
@@ -111,10 +112,64 @@ class UserEngagementEmailService {
|
||||
return await this.emailClient.sendEmail(
|
||||
owner.email,
|
||||
subject,
|
||||
htmlContent
|
||||
htmlContent,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to send item deletion notification email:", error);
|
||||
logger.error("Failed to send item deletion notification email", {
|
||||
error,
|
||||
});
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ const RentalFlowEmailService = require("./domain/RentalFlowEmailService");
|
||||
const RentalReminderEmailService = require("./domain/RentalReminderEmailService");
|
||||
const UserEngagementEmailService = require("./domain/UserEngagementEmailService");
|
||||
const AlphaInvitationEmailService = require("./domain/AlphaInvitationEmailService");
|
||||
const PaymentEmailService = require("./domain/PaymentEmailService");
|
||||
|
||||
/**
|
||||
* EmailServices aggregates all domain-specific email services
|
||||
@@ -24,6 +25,7 @@ class EmailServices {
|
||||
this.rentalReminder = new RentalReminderEmailService();
|
||||
this.userEngagement = new UserEngagementEmailService();
|
||||
this.alphaInvitation = new AlphaInvitationEmailService();
|
||||
this.payment = new PaymentEmailService();
|
||||
|
||||
this.initialized = false;
|
||||
}
|
||||
@@ -45,6 +47,7 @@ class EmailServices {
|
||||
this.rentalReminder.initialize(),
|
||||
this.userEngagement.initialize(),
|
||||
this.alphaInvitation.initialize(),
|
||||
this.payment.initialize(),
|
||||
]);
|
||||
|
||||
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 logger = require('../utils/logger');
|
||||
|
||||
class GoogleMapsService {
|
||||
constructor() {
|
||||
@@ -6,9 +7,9 @@ class GoogleMapsService {
|
||||
this.apiKey = process.env.GOOGLE_MAPS_API_KEY;
|
||||
|
||||
if (!this.apiKey) {
|
||||
console.error('❌ Google Maps API key not configured in environment variables');
|
||||
logger.error('Google Maps API key not configured in environment variables');
|
||||
} else {
|
||||
console.log('✅ Google Maps service initialized');
|
||||
logger.info('Google Maps service initialized');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +62,7 @@ class GoogleMapsService {
|
||||
}))
|
||||
};
|
||||
} else {
|
||||
console.error('Places Autocomplete API error:', response.data.status, response.data.error_message);
|
||||
logger.error('Places Autocomplete API error', { status: response.data.status, errorMessage: response.data.error_message });
|
||||
return {
|
||||
predictions: [],
|
||||
error: this.getErrorMessage(response.data.status),
|
||||
@@ -69,7 +70,7 @@ class GoogleMapsService {
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Places Autocomplete service error:', error.message);
|
||||
logger.error('Places Autocomplete service error', { error });
|
||||
throw new Error('Failed to fetch place predictions');
|
||||
}
|
||||
}
|
||||
@@ -145,11 +146,11 @@ class GoogleMapsService {
|
||||
}
|
||||
};
|
||||
} else {
|
||||
console.error('Place Details API error:', response.data.status, response.data.error_message);
|
||||
logger.error('Place Details API error', { status: response.data.status, errorMessage: response.data.error_message });
|
||||
throw new Error(this.getErrorMessage(response.data.status));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Place Details service error:', error.message);
|
||||
logger.error('Place Details service error', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -200,14 +201,14 @@ class GoogleMapsService {
|
||||
placeId: result.place_id
|
||||
};
|
||||
} else {
|
||||
console.error('Geocoding API error:', response.data.status, response.data.error_message);
|
||||
logger.error('Geocoding API error', { status: response.data.status, errorMessage: response.data.error_message });
|
||||
return {
|
||||
error: this.getErrorMessage(response.data.status),
|
||||
status: response.data.status
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Geocoding service error:', error.message);
|
||||
logger.error('Geocoding service error', { error });
|
||||
throw new Error('Failed to geocode address');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const { Rental, Item, User } = require("../models");
|
||||
const emailServices = require("./email");
|
||||
const { isActive } = require("../utils/rentalStatus");
|
||||
const logger = require("../utils/logger");
|
||||
|
||||
class LateReturnService {
|
||||
/**
|
||||
@@ -71,7 +73,7 @@ class LateReturnService {
|
||||
throw new Error("Rental not found");
|
||||
}
|
||||
|
||||
if (rental.status !== "active") {
|
||||
if (!isActive(rental)) {
|
||||
throw new Error("Can only process late returns for active rentals");
|
||||
}
|
||||
|
||||
@@ -99,6 +101,18 @@ class LateReturnService {
|
||||
);
|
||||
}
|
||||
|
||||
// Trigger immediate payout if rental is verified to be actually completed not late
|
||||
if (!lateCalculation.isLate) {
|
||||
// Import here to avoid circular dependency
|
||||
const PayoutService = require("./payoutService");
|
||||
PayoutService.triggerPayoutOnCompletion(rentalId).catch((err) => {
|
||||
logger.error("Error triggering payout on late return processing", {
|
||||
rentalId,
|
||||
error: err.message,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
rental: updatedRental,
|
||||
lateCalculation,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const { sequelize } = require('../models');
|
||||
const { QueryTypes } = require('sequelize');
|
||||
const { sequelize } = require("../models");
|
||||
const { QueryTypes } = require("sequelize");
|
||||
const logger = require("../utils/logger");
|
||||
|
||||
class LocationService {
|
||||
/**
|
||||
@@ -13,25 +14,19 @@ class LocationService {
|
||||
*/
|
||||
async findUsersInRadius(latitude, longitude, radiusMiles = 10) {
|
||||
if (!latitude || !longitude) {
|
||||
throw new Error('Latitude and longitude are required');
|
||||
throw new Error("Latitude and longitude are required");
|
||||
}
|
||||
|
||||
if (radiusMiles <= 0 || radiusMiles > 100) {
|
||||
throw new Error('Radius must be between 1 and 100 miles');
|
||||
throw new Error("Radius must be between 1 and 100 miles");
|
||||
}
|
||||
|
||||
console.log('Finding users in radius:', {
|
||||
centerLatitude: latitude,
|
||||
centerLongitude: longitude,
|
||||
radiusMiles
|
||||
});
|
||||
|
||||
try {
|
||||
// Haversine formula:
|
||||
// distance = 3959 * acos(cos(radians(lat1)) * cos(radians(lat2))
|
||||
// * cos(radians(lng2) - radians(lng1))
|
||||
// + sin(radians(lat1)) * sin(radians(lat2)))
|
||||
// Note: 3959 is Earth's radius in miles
|
||||
// 3959 is Earth's radius in miles
|
||||
const query = `
|
||||
SELECT * FROM (
|
||||
SELECT
|
||||
@@ -62,29 +57,22 @@ class LocationService {
|
||||
replacements: {
|
||||
lat: parseFloat(latitude),
|
||||
lng: parseFloat(longitude),
|
||||
radiusMiles: parseFloat(radiusMiles)
|
||||
radiusMiles: parseFloat(radiusMiles),
|
||||
},
|
||||
type: QueryTypes.SELECT
|
||||
type: QueryTypes.SELECT,
|
||||
});
|
||||
|
||||
console.log('Users found in radius:', users.map(u => ({
|
||||
id: u.id,
|
||||
userLat: u.latitude,
|
||||
userLng: u.longitude,
|
||||
distance: parseFloat(u.distance).toFixed(2)
|
||||
})));
|
||||
|
||||
return users.map(user => ({
|
||||
return users.map((user) => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
latitude: parseFloat(user.latitude),
|
||||
longitude: parseFloat(user.longitude),
|
||||
distance: parseFloat(user.distance).toFixed(2) // Round to 2 decimal places
|
||||
distance: parseFloat(user.distance).toFixed(2), // Round to 2 decimal places
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error finding users in radius:', error);
|
||||
logger.error("Error finding users in radius", { error });
|
||||
throw new Error(`Failed to find users in radius: ${error.message}`);
|
||||
}
|
||||
}
|
||||
@@ -105,8 +93,10 @@ class LocationService {
|
||||
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(this.toRadians(lat1)) * Math.cos(this.toRadians(lat2)) *
|
||||
Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||
Math.cos(this.toRadians(lat1)) *
|
||||
Math.cos(this.toRadians(lat2)) *
|
||||
Math.sin(dLon / 2) *
|
||||
Math.sin(dLon / 2);
|
||||
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
const distance = R * c;
|
||||
|
||||
@@ -1,9 +1,91 @@
|
||||
const { Rental, User, Item } = require("../models");
|
||||
const StripeService = require("./stripeService");
|
||||
const emailServices = require("./email");
|
||||
const logger = require("../utils/logger");
|
||||
const { Op } = require("sequelize");
|
||||
|
||||
class PayoutService {
|
||||
/**
|
||||
* Attempt to process payout for a single rental immediately after completion.
|
||||
* Checks if owner's Stripe account has payouts enabled before attempting.
|
||||
* @param {string} rentalId - The rental ID to process
|
||||
* @returns {Object} - { attempted, success, reason, transferId, amount }
|
||||
*/
|
||||
static async triggerPayoutOnCompletion(rentalId) {
|
||||
try {
|
||||
const rental = await Rental.findByPk(rentalId, {
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: "owner",
|
||||
attributes: ["id", "email", "firstName", "lastName", "stripeConnectedAccountId", "stripePayoutsEnabled"],
|
||||
},
|
||||
{ model: Item, as: "item" },
|
||||
],
|
||||
});
|
||||
|
||||
if (!rental) {
|
||||
logger.warn("Rental not found for payout trigger", { rentalId });
|
||||
return { attempted: false, success: false, reason: "rental_not_found" };
|
||||
}
|
||||
|
||||
// Check eligibility conditions
|
||||
if (rental.paymentStatus !== "paid") {
|
||||
logger.info("Payout skipped: payment not paid", { rentalId, paymentStatus: rental.paymentStatus });
|
||||
return { attempted: false, success: false, reason: "payment_not_paid" };
|
||||
}
|
||||
|
||||
if (rental.payoutStatus !== "pending") {
|
||||
logger.info("Payout skipped: payout not pending", { rentalId, payoutStatus: rental.payoutStatus });
|
||||
return { attempted: false, success: false, reason: "payout_not_pending" };
|
||||
}
|
||||
|
||||
if (!rental.owner?.stripeConnectedAccountId) {
|
||||
logger.info("Payout skipped: owner has no Stripe account", { rentalId, ownerId: rental.ownerId });
|
||||
return { attempted: false, success: false, reason: "no_stripe_account" };
|
||||
}
|
||||
|
||||
// Check if owner has payouts enabled (onboarding complete)
|
||||
if (!rental.owner.stripePayoutsEnabled) {
|
||||
logger.info("Payout deferred: owner payouts not enabled, will process when onboarding completes", {
|
||||
rentalId,
|
||||
ownerId: rental.ownerId,
|
||||
});
|
||||
return { attempted: false, success: false, reason: "payouts_not_enabled" };
|
||||
}
|
||||
|
||||
// Attempt the payout
|
||||
const result = await this.processRentalPayout(rental);
|
||||
|
||||
logger.info("Payout triggered successfully on completion", {
|
||||
rentalId,
|
||||
transferId: result.transferId,
|
||||
amount: result.amount,
|
||||
});
|
||||
|
||||
return {
|
||||
attempted: true,
|
||||
success: true,
|
||||
transferId: result.transferId,
|
||||
amount: result.amount,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Error triggering payout on completion", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
rentalId,
|
||||
});
|
||||
|
||||
// Payout marked as failed by processRentalPayout, will be retried by daily retry job
|
||||
return {
|
||||
attempted: true,
|
||||
success: false,
|
||||
reason: "payout_failed",
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static async getEligiblePayouts() {
|
||||
try {
|
||||
const eligibleRentals = await Rental.findAll({
|
||||
@@ -20,6 +102,7 @@ class PayoutService {
|
||||
stripeConnectedAccountId: {
|
||||
[Op.not]: null,
|
||||
},
|
||||
stripePayoutsEnabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -31,7 +114,7 @@ class PayoutService {
|
||||
|
||||
return eligibleRentals;
|
||||
} catch (error) {
|
||||
console.error("Error getting eligible payouts:", error);
|
||||
logger.error("Error getting eligible payouts", { error: error.message, stack: error.stack });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -78,15 +161,18 @@ class PayoutService {
|
||||
// Send payout notification email to owner
|
||||
try {
|
||||
await emailServices.rentalFlow.sendPayoutReceivedEmail(rental.owner, rental);
|
||||
console.log(
|
||||
`Payout notification email sent to owner for rental ${rental.id}`
|
||||
);
|
||||
logger.info("Payout notification email sent to owner", {
|
||||
rentalId: rental.id,
|
||||
ownerId: rental.ownerId
|
||||
});
|
||||
} catch (emailError) {
|
||||
// Log error but don't fail the payout
|
||||
console.error(
|
||||
`Failed to send payout notification email for rental ${rental.id}:`,
|
||||
emailError.message
|
||||
);
|
||||
logger.error("Failed to send payout notification email", {
|
||||
error: emailError.message,
|
||||
stack: emailError.stack,
|
||||
rentalId: rental.id,
|
||||
ownerId: rental.ownerId
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -95,7 +181,7 @@ class PayoutService {
|
||||
amount: rental.payoutAmount,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error processing payout for rental ${rental.id}:`, error);
|
||||
logger.error("Error processing payout for rental", { error: error.message, stack: error.stack, rentalId: rental.id });
|
||||
|
||||
// Update status to failed
|
||||
await rental.update({
|
||||
@@ -142,7 +228,7 @@ class PayoutService {
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error("Error processing all eligible payouts:", error);
|
||||
logger.error("Error processing all eligible payouts", { error: error.message, stack: error.stack });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -163,6 +249,7 @@ class PayoutService {
|
||||
stripeConnectedAccountId: {
|
||||
[Op.not]: null,
|
||||
},
|
||||
stripePayoutsEnabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -205,7 +292,7 @@ class PayoutService {
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error("Error retrying failed payouts:", error);
|
||||
logger.error("Error retrying failed payouts", { error: error.message, stack: error.stack });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
const { Rental } = require("../models");
|
||||
const StripeService = require("./stripeService");
|
||||
const EventBridgeSchedulerService = require("./eventBridgeSchedulerService");
|
||||
const { isActive } = require("../utils/rentalStatus");
|
||||
const logger = require("../utils/logger");
|
||||
|
||||
class RefundService {
|
||||
/**
|
||||
@@ -69,8 +72,8 @@ class RefundService {
|
||||
};
|
||||
}
|
||||
|
||||
// Check if rental is active
|
||||
if (rental.status === "active") {
|
||||
// Check if rental is active (computed from confirmed + start time passed)
|
||||
if (isActive(rental)) {
|
||||
return {
|
||||
canCancel: false,
|
||||
reason: "Cannot cancel active rental",
|
||||
@@ -92,8 +95,12 @@ class RefundService {
|
||||
};
|
||||
}
|
||||
|
||||
// Check payment status - allow cancellation for both paid and free rentals
|
||||
if (rental.paymentStatus !== "paid" && rental.paymentStatus !== "not_required") {
|
||||
// Allow cancellation for pending rentals (before owner approval) or paid/free rentals
|
||||
const isPendingRequest = rental.status === "pending";
|
||||
const isPaymentSettled =
|
||||
rental.paymentStatus === "paid" || rental.paymentStatus === "not_required";
|
||||
|
||||
if (!isPendingRequest && !isPaymentSettled) {
|
||||
return {
|
||||
canCancel: false,
|
||||
reason: "Cannot cancel rental that hasn't been paid",
|
||||
@@ -156,13 +163,14 @@ class RefundService {
|
||||
stripeRefundId = refund.id;
|
||||
refundProcessedAt = new Date();
|
||||
} catch (error) {
|
||||
console.error("Error processing Stripe refund:", error);
|
||||
logger.error("Error processing Stripe refund", { error });
|
||||
throw new Error(`Failed to process refund: ${error.message}`);
|
||||
}
|
||||
} else if (refundCalculation.refundAmount > 0) {
|
||||
// Log warning if we should refund but don't have payment intent
|
||||
console.warn(
|
||||
`Refund amount calculated but no payment intent ID for rental ${rentalId}`
|
||||
logger.warn(
|
||||
"Refund amount calculated but no payment intent ID for rental",
|
||||
{ rentalId }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -180,6 +188,17 @@ class RefundService {
|
||||
payoutStatus: "pending",
|
||||
});
|
||||
|
||||
// Delete condition check schedules since rental is cancelled
|
||||
try {
|
||||
await EventBridgeSchedulerService.deleteConditionCheckSchedules(updatedRental);
|
||||
} catch (schedulerError) {
|
||||
logger.error("Failed to delete condition check schedules", {
|
||||
error: schedulerError.message,
|
||||
rentalId: updatedRental.id,
|
||||
});
|
||||
// Don't fail the cancellation - schedule cleanup is non-critical
|
||||
}
|
||||
|
||||
return {
|
||||
rental: updatedRental,
|
||||
refund: {
|
||||
|
||||
124
backend/services/s3OwnershipService.js
Normal file
124
backend/services/s3OwnershipService.js
Normal file
@@ -0,0 +1,124 @@
|
||||
const { Message, ConditionCheck, Rental } = require("../models");
|
||||
const { Op } = require("sequelize");
|
||||
|
||||
/**
|
||||
* Service for verifying ownership/access to S3 files
|
||||
* Used to authorize signed URL requests for private content
|
||||
*/
|
||||
class S3OwnershipService {
|
||||
/**
|
||||
* Image size variant suffixes
|
||||
*/
|
||||
static SIZE_SUFFIXES = ["_th", "_md"];
|
||||
|
||||
/**
|
||||
* Extract the base key from a variant key (strips _th or _md suffix)
|
||||
* @param {string} key - S3 key like "messages/uuid_th.jpg" or "messages/uuid.jpg"
|
||||
* @returns {string} - Base key like "messages/uuid.jpg"
|
||||
*/
|
||||
static getBaseKey(key) {
|
||||
if (!key) return key;
|
||||
for (const suffix of this.SIZE_SUFFIXES) {
|
||||
// Match suffix before file extension (e.g., _th.jpg, _md.png)
|
||||
const regex = new RegExp(`${suffix}(\\.[^.]+)$`);
|
||||
if (regex.test(key)) {
|
||||
return key.replace(regex, "$1");
|
||||
}
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file type from S3 key
|
||||
* @param {string} key - S3 key like "messages/uuid.jpg"
|
||||
* @returns {string|null} - File type or null if unknown
|
||||
*/
|
||||
static getFileTypeFromKey(key) {
|
||||
if (!key) return null;
|
||||
const folder = key.split("/")[0];
|
||||
const folderMap = {
|
||||
profiles: "profile",
|
||||
items: "item",
|
||||
messages: "message",
|
||||
forum: "forum",
|
||||
"condition-checks": "condition-check",
|
||||
};
|
||||
return folderMap[folder] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify if a user can access a file
|
||||
* @param {string} key - S3 key
|
||||
* @param {string} userId - User ID making the request
|
||||
* @returns {Promise<{authorized: boolean, reason?: string}>}
|
||||
*/
|
||||
static async canAccessFile(key, userId) {
|
||||
const fileType = this.getFileTypeFromKey(key);
|
||||
|
||||
switch (fileType) {
|
||||
case "profile":
|
||||
case "item":
|
||||
case "forum":
|
||||
// Public folders - anyone can access
|
||||
return { authorized: true };
|
||||
case "message":
|
||||
return this.verifyMessageAccess(key, userId);
|
||||
case "condition-check":
|
||||
return this.verifyConditionCheckAccess(key, userId);
|
||||
default:
|
||||
return { authorized: false, reason: "Unknown file type" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify message image access - user must be sender OR receiver
|
||||
* @param {string} key - S3 key (may be variant like uuid_th.jpg)
|
||||
* @param {string} userId - User ID making the request
|
||||
* @returns {Promise<{authorized: boolean, reason?: string}>}
|
||||
*/
|
||||
static async verifyMessageAccess(key, userId) {
|
||||
// Use base key for lookup (DB stores original key, not variants)
|
||||
const baseKey = this.getBaseKey(key);
|
||||
const message = await Message.findOne({
|
||||
where: {
|
||||
imageFilename: baseKey,
|
||||
[Op.or]: [{ senderId: userId }, { receiverId: userId }],
|
||||
},
|
||||
});
|
||||
return {
|
||||
authorized: !!message,
|
||||
reason: message ? null : "Not a participant in this message",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify condition check image access - user must be rental owner OR renter
|
||||
* @param {string} key - S3 key (may be variant like uuid_th.jpg)
|
||||
* @param {string} userId - User ID making the request
|
||||
* @returns {Promise<{authorized: boolean, reason?: string}>}
|
||||
*/
|
||||
static async verifyConditionCheckAccess(key, userId) {
|
||||
// Use base key for lookup (DB stores original key, not variants)
|
||||
const baseKey = this.getBaseKey(key);
|
||||
const check = await ConditionCheck.findOne({
|
||||
where: {
|
||||
imageFilenames: { [Op.contains]: [baseKey] },
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Rental,
|
||||
as: "rental",
|
||||
where: {
|
||||
[Op.or]: [{ ownerId: userId }, { renterId: userId }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return {
|
||||
authorized: !!check,
|
||||
reason: check ? null : "Not a participant in this rental",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = S3OwnershipService;
|
||||
269
backend/services/s3Service.js
Normal file
269
backend/services/s3Service.js
Normal file
@@ -0,0 +1,269 @@
|
||||
const {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
} = require("@aws-sdk/client-s3");
|
||||
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
|
||||
const { getAWSConfig } = require("../config/aws");
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
const path = require("path");
|
||||
const logger = require("../utils/logger");
|
||||
|
||||
// Cache-Control: 24 hours for public content (allows moderation takedowns to propagate)
|
||||
// Private content (messages, condition-checks) uses presigned URLs so cache doesn't matter as much
|
||||
const DEFAULT_CACHE_MAX_AGE = 86400; // 24 hours in seconds
|
||||
|
||||
const UPLOAD_CONFIGS = {
|
||||
profile: {
|
||||
folder: "profiles",
|
||||
maxSize: 5 * 1024 * 1024,
|
||||
cacheMaxAge: DEFAULT_CACHE_MAX_AGE,
|
||||
public: true,
|
||||
},
|
||||
item: {
|
||||
folder: "items",
|
||||
maxSize: 10 * 1024 * 1024,
|
||||
cacheMaxAge: DEFAULT_CACHE_MAX_AGE,
|
||||
public: true,
|
||||
},
|
||||
message: {
|
||||
folder: "messages",
|
||||
maxSize: 5 * 1024 * 1024,
|
||||
cacheMaxAge: 3600,
|
||||
public: false,
|
||||
},
|
||||
forum: {
|
||||
folder: "forum",
|
||||
maxSize: 10 * 1024 * 1024,
|
||||
cacheMaxAge: DEFAULT_CACHE_MAX_AGE,
|
||||
public: true,
|
||||
},
|
||||
"condition-check": {
|
||||
folder: "condition-checks",
|
||||
maxSize: 10 * 1024 * 1024,
|
||||
cacheMaxAge: 3600,
|
||||
public: false,
|
||||
},
|
||||
};
|
||||
|
||||
const ALLOWED_TYPES = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
];
|
||||
const PRESIGN_EXPIRY = 300; // 5 minutes
|
||||
|
||||
class S3Service {
|
||||
constructor() {
|
||||
this.client = null;
|
||||
this.bucket = null;
|
||||
this.region = null;
|
||||
this.enabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if S3 is enabled
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isEnabled() {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
initialize() {
|
||||
if (process.env.S3_ENABLED !== "true") {
|
||||
logger.info("S3 Service disabled (S3_ENABLED !== true)");
|
||||
this.enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// S3 is enabled - validate required configuration
|
||||
const bucket = process.env.S3_BUCKET;
|
||||
if (!bucket) {
|
||||
logger.error("S3_ENABLED=true but S3_BUCKET is not set");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const config = getAWSConfig();
|
||||
this.client = new S3Client({
|
||||
...config,
|
||||
// Disable automatic checksums - browser uploads can't calculate them
|
||||
requestChecksumCalculation: "WHEN_REQUIRED",
|
||||
});
|
||||
this.bucket = bucket;
|
||||
this.region = config.region || "us-east-1";
|
||||
this.enabled = true;
|
||||
logger.info("S3 Service initialized", {
|
||||
bucket: this.bucket,
|
||||
region: this.region,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to initialize S3 Service", { error: error.message, stack: error.stack });
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if image processing (metadata stripping) is enabled
|
||||
* When enabled, uploads go to staging/ prefix and Lambda processes them
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isImageProcessingEnabled() {
|
||||
return process.env.IMAGE_PROCESSING_ENABLED === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a presigned URL for uploading a file directly to S3
|
||||
* @param {string} uploadType - Type of upload (profile, item, message, forum, condition-check)
|
||||
* @param {string} contentType - MIME type of the file
|
||||
* @param {string} fileName - Original filename (used for extension)
|
||||
* @param {number} fileSize - File size in bytes (required for size enforcement)
|
||||
* @param {string} [baseKey] - Optional base key (UUID) for coordinated variant uploads
|
||||
* @returns {Promise<{uploadUrl: string, key: string, stagingKey: string|null, publicUrl: string, expiresAt: Date}>}
|
||||
*/
|
||||
async getPresignedUploadUrl(uploadType, contentType, fileName, fileSize, baseKey = null) {
|
||||
if (!this.enabled) {
|
||||
throw new Error("S3 storage is not enabled");
|
||||
}
|
||||
|
||||
const config = UPLOAD_CONFIGS[uploadType];
|
||||
if (!config) {
|
||||
throw new Error(`Invalid upload type: ${uploadType}`);
|
||||
}
|
||||
if (!ALLOWED_TYPES.includes(contentType)) {
|
||||
throw new Error(`Invalid content type: ${contentType}`);
|
||||
}
|
||||
if (!fileSize || fileSize <= 0) {
|
||||
throw new Error("File size is required");
|
||||
}
|
||||
if (fileSize > config.maxSize) {
|
||||
throw new Error(
|
||||
`File too large. Maximum size is ${config.maxSize / (1024 * 1024)}MB`
|
||||
);
|
||||
}
|
||||
|
||||
// Extract known variant suffix from fileName if present (e.g., "photo_th.jpg" -> "_th")
|
||||
const ext = path.extname(fileName) || this.getExtFromMime(contentType);
|
||||
const baseName = path.basename(fileName, ext);
|
||||
|
||||
// Only recognize known variant suffixes
|
||||
let suffix = "";
|
||||
if (baseName.endsWith("_th")) {
|
||||
suffix = "_th";
|
||||
} else if (baseName.endsWith("_md")) {
|
||||
suffix = "_md";
|
||||
}
|
||||
|
||||
// Use provided baseKey or generate new UUID
|
||||
const uuid = baseKey || uuidv4();
|
||||
|
||||
// Final key is where the processed image will be (what frontend stores in DB)
|
||||
const finalKey = `${config.folder}/${uuid}${suffix}${ext}`;
|
||||
|
||||
// When image processing is enabled, upload to staging/ prefix
|
||||
// Lambda will process and move to final location
|
||||
const useStaging = this.isImageProcessingEnabled();
|
||||
const uploadKey = useStaging ? `staging/${finalKey}` : finalKey;
|
||||
|
||||
const cacheDirective = config.public ? "public" : "private";
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: uploadKey,
|
||||
ContentType: contentType,
|
||||
ContentLength: fileSize, // Enforce exact file size
|
||||
CacheControl: `${cacheDirective}, max-age=${config.cacheMaxAge}`,
|
||||
});
|
||||
|
||||
const uploadUrl = await getSignedUrl(this.client, command, {
|
||||
expiresIn: PRESIGN_EXPIRY,
|
||||
});
|
||||
|
||||
return {
|
||||
uploadUrl,
|
||||
key: finalKey, // Frontend stores this in database
|
||||
stagingKey: useStaging ? uploadKey : null, // Actual upload location (if staging enabled)
|
||||
publicUrl: config.public
|
||||
? `https://${this.bucket}.s3.${this.region}.amazonaws.com/${finalKey}`
|
||||
: null,
|
||||
expiresAt: new Date(Date.now() + PRESIGN_EXPIRY * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a presigned URL for downloading a private file from S3
|
||||
* @param {string} key - S3 object key
|
||||
* @param {number} expiresIn - Expiration time in seconds (default 1 hour)
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async getPresignedDownloadUrl(key, expiresIn = 3600) {
|
||||
if (!this.enabled) {
|
||||
throw new Error("S3 storage is not enabled");
|
||||
}
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
});
|
||||
return getSignedUrl(this.client, command, { expiresIn });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public URL for a file (only for public folders)
|
||||
* @param {string} key - S3 object key
|
||||
* @returns {string|null}
|
||||
*/
|
||||
getPublicUrl(key) {
|
||||
if (!this.enabled) {
|
||||
return null;
|
||||
}
|
||||
return `https://${this.bucket}.s3.${this.region}.amazonaws.com/${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that a file exists in S3
|
||||
* @param {string} key - S3 object key
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async verifyUpload(key) {
|
||||
if (!this.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.client.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
})
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err.name === "NotFound" || err.$metadata?.httpStatusCode === 404) {
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension from MIME type
|
||||
* @param {string} mime - MIME type
|
||||
* @returns {string}
|
||||
*/
|
||||
getExtFromMime(mime) {
|
||||
const map = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/jpg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
};
|
||||
return map[mime] || ".jpg";
|
||||
}
|
||||
}
|
||||
|
||||
const s3Service = new S3Service();
|
||||
module.exports = s3Service;
|
||||
@@ -1,14 +1,20 @@
|
||||
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
|
||||
const logger = require("../utils/logger");
|
||||
const { parseStripeError, PaymentError } = require("../utils/stripeErrors");
|
||||
const { User } = require("../models");
|
||||
const emailServices = require("./email");
|
||||
|
||||
class StripeService {
|
||||
|
||||
static async getCheckoutSession(sessionId) {
|
||||
try {
|
||||
return await stripe.checkout.sessions.retrieve(sessionId, {
|
||||
expand: ['setup_intent', 'setup_intent.payment_method']
|
||||
expand: ["setup_intent", "setup_intent.payment_method"],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error retrieving checkout session:", error);
|
||||
logger.error("Error retrieving checkout session", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -26,7 +32,10 @@ class StripeService {
|
||||
|
||||
return account;
|
||||
} catch (error) {
|
||||
console.error("Error creating connected account:", error);
|
||||
logger.error("Error creating connected account", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -42,7 +51,10 @@ class StripeService {
|
||||
|
||||
return accountLink;
|
||||
} catch (error) {
|
||||
console.error("Error creating account link:", error);
|
||||
logger.error("Error creating account link", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -58,7 +70,29 @@ class StripeService {
|
||||
requirements: account.requirements,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error retrieving account status:", error);
|
||||
logger.error("Error retrieving account status", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async createAccountSession(accountId) {
|
||||
try {
|
||||
const accountSession = await stripe.accountSessions.create({
|
||||
account: accountId,
|
||||
components: {
|
||||
account_onboarding: { enabled: true },
|
||||
},
|
||||
});
|
||||
|
||||
return accountSession;
|
||||
} catch (error) {
|
||||
logger.error("Error creating account session", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -70,20 +104,119 @@ class StripeService {
|
||||
metadata = {},
|
||||
}) {
|
||||
try {
|
||||
const transfer = await stripe.transfers.create({
|
||||
amount: Math.round(amount * 100), // Convert to cents
|
||||
currency,
|
||||
destination,
|
||||
metadata,
|
||||
});
|
||||
// Generate idempotency key from rental ID to prevent duplicate transfers
|
||||
const idempotencyKey = metadata?.rentalId
|
||||
? `transfer_rental_${metadata.rentalId}`
|
||||
: undefined;
|
||||
|
||||
const transfer = await stripe.transfers.create(
|
||||
{
|
||||
amount: Math.round(amount * 100), // Convert to cents
|
||||
currency,
|
||||
destination,
|
||||
metadata,
|
||||
},
|
||||
idempotencyKey ? { idempotencyKey } : undefined,
|
||||
);
|
||||
|
||||
return transfer;
|
||||
} catch (error) {
|
||||
console.error("Error creating transfer:", error);
|
||||
// Check if this is a disconnected account error (fallback for missed webhooks)
|
||||
if (this.isAccountDisconnectedError(error)) {
|
||||
logger.warn("Transfer failed - account appears disconnected", {
|
||||
destination,
|
||||
errorCode: error.code,
|
||||
errorType: error.type,
|
||||
});
|
||||
|
||||
// Clean up stale connection data asynchronously (don't block the error)
|
||||
this.handleDisconnectedAccount(destination).catch((cleanupError) => {
|
||||
logger.error("Failed to clean up disconnected account", {
|
||||
destination,
|
||||
error: cleanupError.message,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
logger.error("Error creating transfer", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error indicates the connected account is disconnected.
|
||||
* Used as fallback detection when webhook was missed.
|
||||
* @param {Error} error - Stripe error object
|
||||
* @returns {boolean} - True if error indicates disconnected account
|
||||
*/
|
||||
static isAccountDisconnectedError(error) {
|
||||
// Stripe returns these error codes when account is disconnected or invalid
|
||||
const disconnectedCodes = ["account_invalid", "platform_api_key_expired"];
|
||||
|
||||
// Error messages that indicate disconnection
|
||||
const disconnectedMessages = [
|
||||
"cannot transfer",
|
||||
"not connected",
|
||||
"no longer connected",
|
||||
"account has been deauthorized",
|
||||
];
|
||||
|
||||
if (disconnectedCodes.includes(error.code)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const message = (error.message || "").toLowerCase();
|
||||
return disconnectedMessages.some((msg) => message.includes(msg));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle disconnected account - cleanup and notify.
|
||||
* Called as fallback when webhook was missed.
|
||||
* @param {string} accountId - The disconnected Stripe account ID
|
||||
*/
|
||||
static async handleDisconnectedAccount(accountId) {
|
||||
try {
|
||||
const user = await User.findOne({
|
||||
where: { stripeConnectedAccountId: accountId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn("Cleaning up disconnected account (webhook likely missed)", {
|
||||
userId: user.id,
|
||||
accountId,
|
||||
});
|
||||
|
||||
// Clear connection
|
||||
await user.update({
|
||||
stripeConnectedAccountId: null,
|
||||
stripePayoutsEnabled: false,
|
||||
});
|
||||
|
||||
// Send notification
|
||||
await emailServices.payment.sendAccountDisconnectedEmail(user.email, {
|
||||
ownerName: user.firstName || user.lastName,
|
||||
hasPendingPayouts: true, // We're in a transfer, so there's at least one
|
||||
pendingPayoutCount: 1,
|
||||
});
|
||||
|
||||
logger.info("Sent account disconnected notification (fallback)", {
|
||||
userId: user.id,
|
||||
});
|
||||
} catch (cleanupError) {
|
||||
logger.error("Failed to clean up disconnected account", {
|
||||
accountId,
|
||||
error: cleanupError.message,
|
||||
});
|
||||
// Don't throw - let original error propagate
|
||||
}
|
||||
}
|
||||
|
||||
static async createRefund({
|
||||
paymentIntentId,
|
||||
amount,
|
||||
@@ -91,16 +224,27 @@ class StripeService {
|
||||
reason = "requested_by_customer",
|
||||
}) {
|
||||
try {
|
||||
const refund = await stripe.refunds.create({
|
||||
payment_intent: paymentIntentId,
|
||||
amount: Math.round(amount * 100), // Convert to cents
|
||||
metadata,
|
||||
reason,
|
||||
});
|
||||
// Generate idempotency key - include amount to allow multiple partial refunds
|
||||
const idempotencyKey = metadata?.rentalId
|
||||
? `refund_rental_${metadata.rentalId}_${Math.round(amount * 100)}`
|
||||
: undefined;
|
||||
|
||||
const refund = await stripe.refunds.create(
|
||||
{
|
||||
payment_intent: paymentIntentId,
|
||||
amount: Math.round(amount * 100), // Convert to cents
|
||||
metadata,
|
||||
reason,
|
||||
},
|
||||
idempotencyKey ? { idempotencyKey } : undefined,
|
||||
);
|
||||
|
||||
return refund;
|
||||
} catch (error) {
|
||||
console.error("Error creating refund:", error);
|
||||
logger.error("Error creating refund", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -109,66 +253,115 @@ class StripeService {
|
||||
try {
|
||||
return await stripe.refunds.retrieve(refundId);
|
||||
} catch (error) {
|
||||
console.error("Error retrieving refund:", error);
|
||||
logger.error("Error retrieving refund", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async chargePaymentMethod(paymentMethodId, amount, customerId, metadata = {}) {
|
||||
static async chargePaymentMethod(
|
||||
paymentMethodId,
|
||||
amount,
|
||||
customerId,
|
||||
metadata = {},
|
||||
) {
|
||||
try {
|
||||
// Create a payment intent with the stored payment method
|
||||
const paymentIntent = await stripe.paymentIntents.create({
|
||||
amount: Math.round(amount * 100), // Convert to cents
|
||||
currency: "usd",
|
||||
payment_method: paymentMethodId,
|
||||
customer: customerId, // Include customer ID
|
||||
confirm: true, // Automatically confirm the payment
|
||||
off_session: true, // Indicate this is an off-session payment
|
||||
return_url: `${process.env.FRONTEND_URL || 'http://localhost:3000'}/payment-complete`,
|
||||
metadata,
|
||||
expand: ['charges.data.payment_method_details'], // Expand to get payment method details
|
||||
});
|
||||
// Generate idempotency key to prevent duplicate charges for same rental
|
||||
const idempotencyKey = metadata?.rentalId
|
||||
? `charge_rental_${metadata.rentalId}`
|
||||
: undefined;
|
||||
|
||||
// Extract payment method details from charges
|
||||
const charge = paymentIntent.charges?.data?.[0];
|
||||
// Create a payment intent with the stored payment method
|
||||
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;
|
||||
|
||||
// Build payment method info object
|
||||
let paymentMethod = null;
|
||||
if (paymentMethodDetails) {
|
||||
const type = paymentMethodDetails.type;
|
||||
if (type === 'card') {
|
||||
if (type === "card") {
|
||||
paymentMethod = {
|
||||
type: 'card',
|
||||
brand: paymentMethodDetails.card?.brand || 'card',
|
||||
last4: paymentMethodDetails.card?.last4 || '****',
|
||||
type: "card",
|
||||
brand: paymentMethodDetails.card?.brand || "card",
|
||||
last4: paymentMethodDetails.card?.last4 || "****",
|
||||
};
|
||||
} else if (type === 'us_bank_account') {
|
||||
} else if (type === "us_bank_account") {
|
||||
paymentMethod = {
|
||||
type: 'bank',
|
||||
brand: 'bank_account',
|
||||
last4: paymentMethodDetails.us_bank_account?.last4 || '****',
|
||||
type: "bank",
|
||||
brand: "bank_account",
|
||||
last4: paymentMethodDetails.us_bank_account?.last4 || "****",
|
||||
};
|
||||
} else {
|
||||
paymentMethod = {
|
||||
type: type || 'unknown',
|
||||
brand: type || 'payment',
|
||||
type: type || "unknown",
|
||||
brand: type || "payment",
|
||||
last4: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: "succeeded",
|
||||
paymentIntentId: paymentIntent.id,
|
||||
status: paymentIntent.status,
|
||||
clientSecret: paymentIntent.client_secret,
|
||||
paymentMethod: paymentMethod,
|
||||
chargedAt: new Date(paymentIntent.created * 1000), // Convert Unix timestamp to Date
|
||||
amountCharged: amount, // Original amount in dollars
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error charging payment method:", error);
|
||||
throw error;
|
||||
// Handle authentication_required error (thrown for off-session 3DS)
|
||||
if (error.code === "authentication_required") {
|
||||
return {
|
||||
status: "requires_action",
|
||||
requiresAction: true,
|
||||
paymentIntentId: error.payment_intent?.id,
|
||||
clientSecret: error.payment_intent?.client_secret,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse Stripe error into structured format
|
||||
const parsedError = parseStripeError(error);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,29 +375,52 @@ class StripeService {
|
||||
|
||||
return customer;
|
||||
} catch (error) {
|
||||
console.error("Error creating customer:", error);
|
||||
logger.error("Error creating customer", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async getPaymentMethod(paymentMethodId) {
|
||||
try {
|
||||
return await stripe.paymentMethods.retrieve(paymentMethodId);
|
||||
} catch (error) {
|
||||
logger.error("Error retrieving payment method", {
|
||||
error: error.message,
|
||||
paymentMethodId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async createSetupCheckoutSession({ customerId, metadata = {} }) {
|
||||
try {
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
payment_method_types: ['card', 'us_bank_account', 'link'],
|
||||
mode: 'setup',
|
||||
ui_mode: 'embedded',
|
||||
redirect_on_completion: 'never',
|
||||
payment_method_types: ["card", "link"],
|
||||
mode: "setup",
|
||||
ui_mode: "embedded",
|
||||
redirect_on_completion: "never",
|
||||
// Configure for off-session usage - triggers 3DS during setup
|
||||
payment_method_options: {
|
||||
card: {
|
||||
request_three_d_secure: "any",
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
type: 'payment_method_setup',
|
||||
...metadata
|
||||
}
|
||||
type: "payment_method_setup",
|
||||
...metadata,
|
||||
},
|
||||
});
|
||||
|
||||
return session;
|
||||
} catch (error) {
|
||||
console.error("Error creating setup checkout session:", error);
|
||||
logger.error("Error creating setup checkout session", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
821
backend/services/stripeWebhookService.js
Normal file
821
backend/services/stripeWebhookService.js
Normal file
@@ -0,0 +1,821 @@
|
||||
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
|
||||
const { User, Rental, Item } = require("../models");
|
||||
const PayoutService = require("./payoutService");
|
||||
const logger = require("../utils/logger");
|
||||
const { Op } = require("sequelize");
|
||||
const { getPayoutFailureMessage } = require("../utils/payoutErrors");
|
||||
const emailServices = require("./email");
|
||||
|
||||
class StripeWebhookService {
|
||||
/**
|
||||
* Verify webhook signature and construct event
|
||||
*/
|
||||
static constructEvent(rawBody, signature, webhookSecret) {
|
||||
return stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle account.updated webhook event.
|
||||
* Tracks requirements, triggers payouts when enabled, and notifies when disabled.
|
||||
* @param {Object} account - The Stripe account object from the webhook
|
||||
* @returns {Object} - { processed, payoutsTriggered, payoutResults, notificationSent }
|
||||
*/
|
||||
static async handleAccountUpdated(account) {
|
||||
const accountId = account.id;
|
||||
const payoutsEnabled = account.payouts_enabled;
|
||||
const requirements = account.requirements || {};
|
||||
|
||||
logger.info("Processing account.updated webhook", {
|
||||
accountId,
|
||||
payoutsEnabled,
|
||||
chargesEnabled: account.charges_enabled,
|
||||
detailsSubmitted: account.details_submitted,
|
||||
currentlyDue: requirements.currently_due?.length || 0,
|
||||
pastDue: requirements.past_due?.length || 0,
|
||||
disabledReason: requirements.disabled_reason,
|
||||
});
|
||||
|
||||
// Find user with this Stripe account
|
||||
const user = await User.findOne({
|
||||
where: { stripeConnectedAccountId: accountId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
logger.warn("No user found for Stripe account", { accountId });
|
||||
return { processed: false, reason: "user_not_found" };
|
||||
}
|
||||
|
||||
// Store previous state before update
|
||||
const previousPayoutsEnabled = user.stripePayoutsEnabled;
|
||||
|
||||
// Update user with all account status fields
|
||||
await user.update({
|
||||
stripePayoutsEnabled: payoutsEnabled,
|
||||
stripeRequirementsCurrentlyDue: requirements.currently_due || [],
|
||||
stripeRequirementsPastDue: requirements.past_due || [],
|
||||
stripeDisabledReason: requirements.disabled_reason || null,
|
||||
stripeRequirementsLastUpdated: new Date(),
|
||||
});
|
||||
|
||||
logger.info("Updated user Stripe account status", {
|
||||
userId: user.id,
|
||||
accountId,
|
||||
previousPayoutsEnabled,
|
||||
newPayoutsEnabled: payoutsEnabled,
|
||||
currentlyDue: requirements.currently_due?.length || 0,
|
||||
pastDue: requirements.past_due?.length || 0,
|
||||
});
|
||||
|
||||
const result = {
|
||||
processed: true,
|
||||
payoutsTriggered: false,
|
||||
notificationSent: false,
|
||||
};
|
||||
|
||||
// If payouts just became enabled (false -> true), process pending payouts
|
||||
if (payoutsEnabled && !previousPayoutsEnabled) {
|
||||
logger.info("Payouts enabled for user, processing pending payouts", {
|
||||
userId: user.id,
|
||||
accountId,
|
||||
});
|
||||
|
||||
result.payoutsTriggered = true;
|
||||
result.payoutResults = await this.processPayoutsForOwner(user.id);
|
||||
}
|
||||
|
||||
// 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.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all eligible payouts for a specific owner.
|
||||
* Called when owner completes Stripe onboarding.
|
||||
* @param {string} ownerId - The owner's user ID
|
||||
* @returns {Object} - { successful, failed, totalProcessed }
|
||||
*/
|
||||
static async processPayoutsForOwner(ownerId) {
|
||||
const eligibleRentals = await Rental.findAll({
|
||||
where: {
|
||||
ownerId,
|
||||
status: "completed",
|
||||
paymentStatus: "paid",
|
||||
payoutStatus: "pending",
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: "owner",
|
||||
where: {
|
||||
stripeConnectedAccountId: { [Op.not]: null },
|
||||
stripePayoutsEnabled: true,
|
||||
},
|
||||
},
|
||||
{ model: Item, as: "item" },
|
||||
],
|
||||
});
|
||||
|
||||
logger.info("Found eligible rentals for owner payout", {
|
||||
ownerId,
|
||||
count: eligibleRentals.length,
|
||||
});
|
||||
|
||||
const results = {
|
||||
successful: [],
|
||||
failed: [],
|
||||
totalProcessed: eligibleRentals.length,
|
||||
};
|
||||
|
||||
for (const rental of eligibleRentals) {
|
||||
try {
|
||||
const result = await PayoutService.processRentalPayout(rental);
|
||||
results.successful.push({
|
||||
rentalId: rental.id,
|
||||
amount: result.amount,
|
||||
transferId: result.transferId,
|
||||
});
|
||||
} catch (error) {
|
||||
results.failed.push({
|
||||
rentalId: rental.id,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Processed payouts for owner", {
|
||||
ownerId,
|
||||
successful: results.successful.length,
|
||||
failed: results.failed.length,
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle payout.paid webhook event.
|
||||
* Updates rentals when funds are deposited to owner's bank account.
|
||||
* @param {Object} payout - The Stripe payout object
|
||||
* @param {string} connectedAccountId - The connected account ID (from event.account)
|
||||
* @returns {Object} - { processed, rentalsUpdated }
|
||||
*/
|
||||
static async handlePayoutPaid(payout, connectedAccountId) {
|
||||
logger.info("Processing payout.paid webhook", {
|
||||
payoutId: payout.id,
|
||||
connectedAccountId,
|
||||
amount: payout.amount,
|
||||
arrivalDate: payout.arrival_date,
|
||||
});
|
||||
|
||||
if (!connectedAccountId) {
|
||||
logger.warn("payout.paid webhook missing connected account ID", {
|
||||
payoutId: payout.id,
|
||||
});
|
||||
return { processed: false, reason: "missing_account_id" };
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch balance transactions included in this payout
|
||||
// Filter by type 'transfer' to get only our platform transfers
|
||||
const balanceTransactions = await stripe.balanceTransactions.list(
|
||||
{
|
||||
payout: payout.id,
|
||||
type: "transfer",
|
||||
limit: 100,
|
||||
},
|
||||
{ stripeAccount: connectedAccountId }
|
||||
);
|
||||
|
||||
// Extract transfer IDs from balance transactions
|
||||
// The 'source' field contains the transfer ID
|
||||
const transferIds = balanceTransactions.data
|
||||
.map((bt) => bt.source)
|
||||
.filter(Boolean);
|
||||
|
||||
if (transferIds.length === 0) {
|
||||
logger.info("No transfer balance transactions in payout", {
|
||||
payoutId: payout.id,
|
||||
connectedAccountId,
|
||||
});
|
||||
return { processed: true, rentalsUpdated: 0 };
|
||||
}
|
||||
|
||||
logger.info("Found transfers in payout", {
|
||||
payoutId: payout.id,
|
||||
transferCount: transferIds.length,
|
||||
transferIds,
|
||||
});
|
||||
|
||||
// Update all rentals with matching stripeTransferId
|
||||
const [updatedCount] = await Rental.update(
|
||||
{
|
||||
bankDepositStatus: "paid",
|
||||
bankDepositAt: new Date(payout.arrival_date * 1000),
|
||||
stripePayoutId: payout.id,
|
||||
},
|
||||
{
|
||||
where: {
|
||||
stripeTransferId: { [Op.in]: transferIds },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
logger.info("Updated rentals with bank deposit status", {
|
||||
payoutId: payout.id,
|
||||
rentalsUpdated: updatedCount,
|
||||
});
|
||||
|
||||
return { processed: true, rentalsUpdated: updatedCount };
|
||||
} catch (error) {
|
||||
logger.error("Error processing payout.paid webhook", {
|
||||
payoutId: payout.id,
|
||||
connectedAccountId,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle payout.failed webhook event.
|
||||
* Updates rentals when bank deposit fails and notifies the owner.
|
||||
* @param {Object} payout - The Stripe payout object
|
||||
* @param {string} connectedAccountId - The connected account ID (from event.account)
|
||||
* @returns {Object} - { processed, rentalsUpdated, notificationSent }
|
||||
*/
|
||||
static async handlePayoutFailed(payout, connectedAccountId) {
|
||||
logger.info("Processing payout.failed webhook", {
|
||||
payoutId: payout.id,
|
||||
connectedAccountId,
|
||||
failureCode: payout.failure_code,
|
||||
failureMessage: payout.failure_message,
|
||||
});
|
||||
|
||||
if (!connectedAccountId) {
|
||||
logger.warn("payout.failed webhook missing connected account ID", {
|
||||
payoutId: payout.id,
|
||||
});
|
||||
return { processed: false, reason: "missing_account_id" };
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch balance transactions included in this payout
|
||||
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 transfer balance transactions in failed payout", {
|
||||
payoutId: payout.id,
|
||||
connectedAccountId,
|
||||
});
|
||||
return { processed: true, rentalsUpdated: 0, notificationSent: false };
|
||||
}
|
||||
|
||||
// Update all rentals with matching stripeTransferId
|
||||
const [updatedCount] = await Rental.update(
|
||||
{
|
||||
bankDepositStatus: "failed",
|
||||
stripePayoutId: payout.id,
|
||||
bankDepositFailureCode: payout.failure_code || "unknown",
|
||||
},
|
||||
{
|
||||
where: {
|
||||
stripeTransferId: { [Op.in]: transferIds },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
logger.warn("Updated rentals with failed bank deposit status", {
|
||||
payoutId: payout.id,
|
||||
rentalsUpdated: updatedCount,
|
||||
failureCode: payout.failure_code,
|
||||
});
|
||||
|
||||
// Find owner and send notification
|
||||
const user = await User.findOne({
|
||||
where: { stripeConnectedAccountId: connectedAccountId },
|
||||
});
|
||||
|
||||
let notificationSent = false;
|
||||
|
||||
if (user) {
|
||||
// Get user-friendly message
|
||||
const failureInfo = getPayoutFailureMessage(payout.failure_code);
|
||||
|
||||
try {
|
||||
await emailServices.payment.sendPayoutFailedNotification(user.email, {
|
||||
ownerName: user.firstName || user.lastName,
|
||||
payoutAmount: payout.amount / 100,
|
||||
failureMessage: failureInfo.message,
|
||||
actionRequired: failureInfo.action,
|
||||
failureCode: payout.failure_code || "unknown",
|
||||
requiresBankUpdate: failureInfo.requiresBankUpdate,
|
||||
});
|
||||
|
||||
notificationSent = true;
|
||||
|
||||
logger.info("Sent payout failed notification to owner", {
|
||||
userId: user.id,
|
||||
payoutId: payout.id,
|
||||
failureCode: payout.failure_code,
|
||||
});
|
||||
} catch (emailError) {
|
||||
logger.error("Failed to send payout failed notification", {
|
||||
userId: user.id,
|
||||
payoutId: payout.id,
|
||||
error: emailError.message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.warn("No user found for connected account", {
|
||||
connectedAccountId,
|
||||
payoutId: payout.id,
|
||||
});
|
||||
}
|
||||
|
||||
return { processed: true, rentalsUpdated: updatedCount, notificationSent };
|
||||
} catch (error) {
|
||||
logger.error("Error processing payout.failed webhook", {
|
||||
payoutId: payout.id,
|
||||
connectedAccountId,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
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;
|
||||
@@ -94,6 +94,7 @@ const initializeMessageSocket = (io) => {
|
||||
socketId: socket.id,
|
||||
userId,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -124,6 +125,7 @@ const initializeMessageSocket = (io) => {
|
||||
socketId: socket.id,
|
||||
userId,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -170,6 +172,7 @@ const initializeMessageSocket = (io) => {
|
||||
socketId: socket.id,
|
||||
userId,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -208,6 +211,7 @@ const initializeMessageSocket = (io) => {
|
||||
socketId: socket.id,
|
||||
userId,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -243,6 +247,7 @@ const initializeMessageSocket = (io) => {
|
||||
socketId: socket.id,
|
||||
userId,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -305,6 +310,7 @@ const emitNewMessage = (io, receiverId, messageData) => {
|
||||
receiverId,
|
||||
messageId: messageData.id,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -330,6 +336,7 @@ const emitMessageRead = (io, senderId, readData) => {
|
||||
senderId,
|
||||
messageId: readData.messageId,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ const cookie = require("cookie");
|
||||
* Verifies JWT token and attaches user to socket
|
||||
* Tokens can be provided via:
|
||||
* 1. Cookie (accessToken) - preferred for browser clients
|
||||
* 2. Query parameter (token) - fallback for mobile/other clients
|
||||
* 2. Auth object (auth.token) - for mobile/native clients
|
||||
*/
|
||||
const authenticateSocket = async (socket, next) => {
|
||||
try {
|
||||
@@ -20,16 +20,11 @@ const authenticateSocket = async (socket, next) => {
|
||||
token = cookies.accessToken;
|
||||
}
|
||||
|
||||
// Fallback to query parameter (mobile/other clients)
|
||||
// Auth object for mobile/native clients
|
||||
if (!token && socket.handshake.auth?.token) {
|
||||
token = socket.handshake.auth.token;
|
||||
}
|
||||
|
||||
// Fallback to legacy query parameter
|
||||
if (!token && socket.handshake.query?.token) {
|
||||
token = socket.handshake.query.token;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
logger.warn("Socket connection rejected - no token provided", {
|
||||
socketId: socket.id,
|
||||
@@ -38,8 +33,8 @@ const authenticateSocket = async (socket, next) => {
|
||||
return next(new Error("Authentication required"));
|
||||
}
|
||||
|
||||
// Verify JWT
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
// Verify JWT (access tokens only)
|
||||
const decoded = jwt.verify(token, process.env.JWT_ACCESS_SECRET);
|
||||
const userId = decoded.id;
|
||||
|
||||
if (!userId) {
|
||||
@@ -69,7 +64,9 @@ const authenticateSocket = async (socket, next) => {
|
||||
userVersion: user.jwtVersion,
|
||||
});
|
||||
return next(
|
||||
new Error("Session expired due to password change. Please log in again.")
|
||||
new Error(
|
||||
"Session expired due to password change. Please log in again."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>Your Alpha Access Code - RentAll</title>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<title>Your Alpha Access Code - Village Share</title>
|
||||
<style>
|
||||
/* Reset styles */
|
||||
body, table, td, p, a, li, blockquote {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
table, td {
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
img {
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
/* Reset styles */
|
||||
body,
|
||||
table,
|
||||
td,
|
||||
p,
|
||||
a,
|
||||
li,
|
||||
blockquote {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
table,
|
||||
td {
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
img {
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100% !important;
|
||||
min-width: 100%;
|
||||
height: 100%;
|
||||
background-color: #f8f9fa;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #212529;
|
||||
}
|
||||
/* Base styles */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100% !important;
|
||||
min-width: 100%;
|
||||
height: 100%;
|
||||
background-color: #f8f9fa;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
|
||||
Cantarell, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
/* Container */
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 40px 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
color: #e0e7ff;
|
||||
font-size: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.content {
|
||||
padding: 40px 30px;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 20px 0;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin: 0 0 16px 0;
|
||||
color: #6c757d;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.content strong {
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
/* Code box */
|
||||
.code-box {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
margin: 30px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.code-label {
|
||||
color: #e0e7ff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
letter-spacing: 4px;
|
||||
margin: 10px 0;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
/* Button */
|
||||
.button {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #ffffff !important;
|
||||
text-decoration: none;
|
||||
padding: 16px 32px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
margin: 20px 0;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
/* Info box */
|
||||
.info-box {
|
||||
background-color: #e7f3ff;
|
||||
border-left: 4px solid #667eea;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0 0 10px 0;
|
||||
color: #004085;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-box p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.info-box ul {
|
||||
margin: 10px 0 0 0;
|
||||
padding-left: 20px;
|
||||
color: #004085;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-box li {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
background-color: #f8f9fa;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media only screen and (max-width: 600px) {
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 40px 30px;
|
||||
text-align: center;
|
||||
.header,
|
||||
.content,
|
||||
.footer {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
color: #e0e7ff;
|
||||
font-size: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.content {
|
||||
padding: 40px 30px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 20px 0;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin: 0 0 16px 0;
|
||||
color: #6c757d;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.content strong {
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
/* Code box */
|
||||
.code-box {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
margin: 30px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.code-label {
|
||||
color: #e0e7ff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
letter-spacing: 4px;
|
||||
margin: 10px 0;
|
||||
user-select: all;
|
||||
font-size: 24px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
/* Button */
|
||||
.button {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #ffffff !important;
|
||||
text-decoration: none;
|
||||
padding: 16px 32px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
margin: 20px 0;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
/* Info box */
|
||||
.info-box {
|
||||
background-color: #e7f3ff;
|
||||
border-left: 4px solid #667eea;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0 0 10px 0;
|
||||
color: #004085;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-box p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.info-box ul {
|
||||
margin: 10px 0 0 0;
|
||||
padding-left: 20px;
|
||||
color: #004085;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-box li {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
background-color: #f8f9fa;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media only screen and (max-width: 600px) {
|
||||
.email-container {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.header, .content, .footer {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-size: 24px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="header">
|
||||
<div class="logo">RentAll</div>
|
||||
<div class="tagline">Alpha Access Invitation</div>
|
||||
<div class="header">
|
||||
<div class="logo">Village Share</div>
|
||||
<div class="tagline">Alpha Access Invitation</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<h1>Welcome to Alpha Testing!</h1>
|
||||
|
||||
<p>
|
||||
Congratulations! You've been selected to participate in the exclusive
|
||||
alpha testing program for Village Share, the community-powered rental
|
||||
marketplace.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Your unique alpha access code is:
|
||||
<strong style="font-family: monospace">{{code}}</strong>
|
||||
</p>
|
||||
|
||||
<p>To get started:</p>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>Steps to Access:</strong></p>
|
||||
<ul>
|
||||
<li>
|
||||
Visit
|
||||
<a href="{{frontendUrl}}" style="color: #667eea; font-weight: 600"
|
||||
>{{frontendUrl}}</a
|
||||
>
|
||||
</li>
|
||||
<li>Enter your alpha access code when prompted</li>
|
||||
<li>
|
||||
Register with <strong>this email address</strong> ({{email}})
|
||||
</li>
|
||||
<li>Start exploring the platform!</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<h1>Welcome to Alpha Testing!</h1>
|
||||
|
||||
<p>Congratulations! You've been selected to participate in the exclusive alpha testing program for RentAll, 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 RentAll 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 RentAll the best it can be.</p>
|
||||
|
||||
<p>If you have any questions or encounter any issues, please don't hesitate to reach out to us.</p>
|
||||
|
||||
<p>Happy renting!</p>
|
||||
<div style="text-align: center">
|
||||
<a href="{{frontendUrl}}" class="button"
|
||||
>Access Village Share Alpha</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p><strong>RentAll Alpha Testing Program</strong></p>
|
||||
<p>Need help? Contact us at <a href="mailto:support@rentall.app">support@rentall.app</a></p>
|
||||
<p>© 2024 RentAll. All rights reserved.</p>
|
||||
<p><strong>What to expect as an alpha tester:</strong></p>
|
||||
|
||||
<div class="info-box">
|
||||
<ul>
|
||||
<li>Early access to new features before public launch</li>
|
||||
<li>Opportunity to shape the product with your feedback</li>
|
||||
<li>Direct communication with the development team</li>
|
||||
<li>Special recognition as an early supporter</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p><strong>Important notes:</strong></p>
|
||||
<ul style="color: #6c757d; font-size: 14px">
|
||||
<li>Your code is tied to this email address only</li>
|
||||
<li>This is a permanent access code (no expiration)</li>
|
||||
<li>Please keep your code confidential</li>
|
||||
<li>We value your feedback - let us know what you think!</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
We're excited to have you as part of our alpha testing community. Your
|
||||
feedback will be invaluable in making Village Share the best it can
|
||||
be.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
If you have any questions or encounter any issues, please don't
|
||||
hesitate to reach out to us.
|
||||
</p>
|
||||
|
||||
<p>Happy renting!</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p><strong>Village Share Alpha Testing Program</strong></p>
|
||||
<p>
|
||||
Need help? Contact us at
|
||||
<a href="mailto:community-support@village-share.com"
|
||||
>community-support@village-share.com</a
|
||||
>
|
||||
</p>
|
||||
<p>© 2025 Village Share. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</body>
|
||||
</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,241 +1,271 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<title>{{title}}</title>
|
||||
<style>
|
||||
/* Reset styles */
|
||||
body, table, td, p, a, li, blockquote {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
table, td {
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
img {
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
/* Reset styles */
|
||||
body,
|
||||
table,
|
||||
td,
|
||||
p,
|
||||
a,
|
||||
li,
|
||||
blockquote {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
table,
|
||||
td {
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
img {
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100% !important;
|
||||
min-width: 100%;
|
||||
height: 100%;
|
||||
background-color: #f8f9fa;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #212529;
|
||||
}
|
||||
/* Base styles */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100% !important;
|
||||
min-width: 100%;
|
||||
height: 100%;
|
||||
background-color: #f8f9fa;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
|
||||
Cantarell, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
/* Container */
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 40px 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
color: #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);
|
||||
}
|
||||
|
||||
/* Info box */
|
||||
.info-box {
|
||||
background-color: #e3f2fd;
|
||||
border-left: 4px solid #2196f3;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.info-box .icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
background-color: #f8f9fa;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media only screen and (max-width: 600px) {
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 40px 30px;
|
||||
text-align: center;
|
||||
.header,
|
||||
.content,
|
||||
.footer {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
color: #e9ecef;
|
||||
font-size: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.content {
|
||||
padding: 40px 30px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 20px 0;
|
||||
color: #212529;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Info box */
|
||||
.info-box {
|
||||
background-color: #e3f2fd;
|
||||
border-left: 4px solid #2196f3;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.info-box .icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="header">
|
||||
<div class="logo">RentAll</div>
|
||||
<div class="tagline">Your trusted rental marketplace</div>
|
||||
<div class="header">
|
||||
<div class="logo">Village Share</div>
|
||||
<div class="tagline">Your trusted rental marketplace</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<h1>📸 {{title}}</h1>
|
||||
|
||||
<p>{{message}}</p>
|
||||
|
||||
<div class="info-box">
|
||||
<div class="icon">📦</div>
|
||||
<p><strong>Rental Item:</strong> {{itemName}}</p>
|
||||
<p><strong>Deadline:</strong> {{deadline}}</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<h1>📸 {{title}}</h1>
|
||||
<p>
|
||||
Taking condition photos helps protect both renters and owners by
|
||||
providing clear documentation of the item's state. This is an
|
||||
important step in the rental process.
|
||||
</p>
|
||||
|
||||
<p>{{message}}</p>
|
||||
|
||||
<div class="info-box">
|
||||
<div class="icon">📦</div>
|
||||
<p><strong>Rental Item:</strong> {{itemName}}</p>
|
||||
<p><strong>Deadline:</strong> {{deadline}}</p>
|
||||
</div>
|
||||
|
||||
<p>Taking condition photos helps protect both renters and owners by providing clear documentation of the item's state. This is an important step in the rental process.</p>
|
||||
|
||||
<div class="alert-box">
|
||||
<p><strong>Important:</strong> Please complete this condition check as soon as possible. Missing this deadline may affect dispute resolution if issues arise.</p>
|
||||
</div>
|
||||
|
||||
<a href="#" class="button">Complete Condition Check</a>
|
||||
|
||||
<h2>What to photograph:</h2>
|
||||
<ul>
|
||||
<li>Overall view of the item</li>
|
||||
<li>Any existing damage or wear</li>
|
||||
<li>Serial numbers or identifying marks</li>
|
||||
<li>Accessories or additional components</li>
|
||||
</ul>
|
||||
|
||||
<p>If you have any questions about the condition check process, please don't hesitate to contact our support team.</p>
|
||||
<div class="alert-box">
|
||||
<p>
|
||||
<strong>Important:</strong> Please complete this condition check as
|
||||
soon as possible. Missing this deadline may affect dispute
|
||||
resolution if issues arise.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>© 2024 RentAll. All rights reserved.</p>
|
||||
<p>You received this email because you have an active rental on RentAll.</p>
|
||||
<p>If you have any questions, please <a href="mailto:support@rentall.com">contact our support team</a>.</p>
|
||||
</div>
|
||||
<a href="#" class="button">Complete Condition Check</a>
|
||||
|
||||
<h2>What to photograph:</h2>
|
||||
<ul>
|
||||
<li>Overall view of the item</li>
|
||||
<li>Any existing damage or wear</li>
|
||||
<li>Serial numbers or identifying marks</li>
|
||||
<li>Accessories or additional components</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
If you have any questions about the condition check process, please
|
||||
don't hesitate to contact our support team.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>© 2025 Village Share. All rights reserved.</p>
|
||||
<p>
|
||||
You received this email because you have an active rental on Village
|
||||
Share.
|
||||
</p>
|
||||
<p>
|
||||
If you have any questions, please
|
||||
<a href="mailto:community-support@village-share.com"
|
||||
>contact our support team</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user