Compare commits

...

112 Commits

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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 18:26:53 -05:00
jackiettran
e9bc87da99 review viewable after 72 hours instead of 10 minutes if only one side submits a review 2026-01-02 17:54:01 -05:00
jackiettran
b89a0e3de7 Renter can now see owner's pre rental condition 2026-01-02 17:39:45 -05:00
jackiettran
4209dcc8fc removed cron job that made rentals active. Now whether or not the rental is active is determined on the fly 2026-01-02 17:08:49 -05:00
jackiettran
bc01c818aa Condition check modal title text edit 2026-01-02 14:24:22 -05:00
jackiettran
0104f369a9 Owner should only be able to complete an active rental not a confirmed rental. Removed an icon 2026-01-01 23:49:03 -05:00
jackiettran
0682494ee0 Fixed an email bug where it wasn't getting email from the db 2026-01-01 23:29:39 -05:00
jackiettran
fe38ef430a Fixed a bug with What will you use it for, fixed a bug with the sticky pricing card, text change 2026-01-01 18:48:01 -05:00
jackiettran
9e41f328e0 layout and styling changes for RentItem 2026-01-01 17:17:02 -05:00
jackiettran
fd2312fe47 Edited layout of mmddyyyy and time dropdown. Changed algorithm for determining pricing so that it choosest the cheapest option for users 2026-01-01 14:46:40 -05:00
jackiettran
3d0e553620 date time validation and added ability to type in date 2026-01-01 00:50:19 -05:00
jackiettran
f66dccdfa3 fixed bug where avatar wasn't showing on desktop mode 2025-12-30 23:48:38 -05:00
jackiettran
3ff98fbe1e avatar menu closes properly 2025-12-30 23:25:50 -05:00
jackiettran
1b4e86be29 fixed image previews 2025-12-30 22:49:34 -05:00
jackiettran
807082eebf image optimization. Image resizing client side, index added to db, pagination 2025-12-30 20:23:32 -05:00
jackiettran
3e31b9d08b fixing intemittent undefined errors 2025-12-30 18:07:23 -05:00
jackiettran
e3acf45ba0 fixed sticky bottom pricing card for mobile 2025-12-30 17:35:48 -05:00
jackiettran
4bb4e7bcb6 Grouping markers and changing pin to tear shape 2025-12-30 16:58:03 -05:00
jackiettran
6cf8a009ff location filter 2025-12-30 14:23:21 -05:00
jackiettran
546c881701 rental price calculation bug, sticky pricing cards on mobile, bigger font app wide, removed delivery options from frontened, searching by location with zipcode works when there's multiple zipcodes in the area, 2025-12-30 00:20:15 -05:00
jackiettran
7dd3aff0f8 Image is required for creating an item, required fields actually required, Available After and Available Before defaults changed, delete confirmation modal for deleting an item 2025-12-29 19:26:37 -05:00
jackiettran
ac1e22f194 better UX when resetting pw 2025-12-29 00:38:10 -05:00
jackiettran
e153614993 login attempts 2025-12-28 12:43:10 -05:00
jackiettran
2e18137b5b 404 page 2025-12-25 23:32:55 -05:00
jackiettran
36cf5b65fa improved email verification experience wording 2025-12-25 23:09:10 -05:00
jackiettran
4f85243815 more stack traces 2025-12-25 19:05:12 -05:00
jackiettran
76e4039ba8 added stack trace to some logging 2025-12-25 18:41:42 -05:00
jackiettran
b02ec19d5c navbar menu styling 2025-12-23 23:08:36 -05:00
jackiettran
2a32470758 text changes, error styling, navbar menu styling 2025-12-23 23:08:22 -05:00
jackiettran
5ec22c2a5b Navbar UX consistency 2025-12-23 19:39:23 -05:00
jackiettran
426f974ed3 users can click outside of modal to close the modal for info only modals. Take away that ability for important modals 2025-12-23 18:43:17 -05:00
jackiettran
347f709f72 Updated search bar to remove location. Will get or ask for user's location. Removed Start Earning button. Works on desktop and mobile 2025-12-23 18:09:12 -05:00
jackiettran
07e5a2a320 Rebrand and updated copyright date 2025-12-22 22:35:57 -05:00
jackiettran
955517347e health endpoint 2025-12-20 15:21:33 -05:00
jackiettran
bd1bd5014c updating unit and integration tests 2025-12-20 14:59:09 -05:00
jackiettran
4e0a4ef019 updated upload unit tests for s3 image handling 2025-12-19 18:58:30 -05:00
jackiettran
4b4584bc0f sending images through messages works 2025-12-18 19:37:16 -05:00
jackiettran
996e815d57 if authmodal is up, cursor is already in it 2025-12-18 18:43:08 -05:00
jackiettran
38e0b6a16d condition checks in rental history in profile 2025-12-16 14:15:07 -05:00
jackiettran
27a7b641dd condition check gallery 2025-12-16 13:50:23 -05:00
jackiettran
372ab093ef email verification flow updated 2025-12-15 22:45:55 -05:00
jackiettran
5e01bb8cff images for forum and forum comments 2025-12-13 20:32:25 -05:00
jackiettran
55e08e14b8 consistent profile image, initials with background color as backup, better profile image editing 2025-12-12 23:08:54 -05:00
jackiettran
3f319bfdd0 unit tests 2025-12-12 16:27:56 -05:00
jackiettran
25bbf5d20b mass assignment vulnerabilites and refactoring of photos 2025-12-12 13:57:44 -05:00
jackiettran
1dee5232a0 s3 image file validation 2025-12-12 13:33:24 -05:00
jackiettran
763945fef4 S3 markdown file 2025-12-12 11:47:16 -05:00
jackiettran
b0268a2fb7 s3 2025-12-11 20:05:18 -05:00
jackiettran
11593606aa imageFilenames and imageFilename, backend integration tests, frontend tests, removed username references 2025-11-26 23:13:23 -05:00
jackiettran
f2d3aac029 sanitized errors 2025-11-26 15:49:42 -05:00
jackiettran
fab79e64ee removed console logs from frontend and a logs from locationService 2025-11-26 15:01:00 -05:00
jackiettran
8b10103ae4 csrf token handling, two jwt tokens 2025-11-26 14:25:49 -05:00
369 changed files with 102262 additions and 28297 deletions

9
.gitignore vendored
View File

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

113
README.md
View File

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

1
backend/.gitignore vendored
View File

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

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

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

View File

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

View File

@@ -8,7 +8,7 @@ if (!process.env.DB_NAME && process.env.NODE_ENV) {
const result = dotenv.config({ path: envFile }); const result = dotenv.config({ path: envFile });
if (result.error && process.env.NODE_ENV !== "production") { if (result.error && process.env.NODE_ENV !== "production") {
console.warn( console.warn(
`Warning: Could not load ${envFile}, using existing environment variables` `Warning: Could not load ${envFile}, using existing environment variables`,
); );
} }
} }
@@ -20,7 +20,7 @@ const dbConfig = {
password: process.env.DB_PASSWORD, password: process.env.DB_PASSWORD,
database: process.env.DB_NAME, database: process.env.DB_NAME,
host: process.env.DB_HOST, host: process.env.DB_HOST,
port: process.env.DB_PORT || 5432, port: process.env.DB_PORT,
dialect: "postgres", dialect: "postgres",
logging: false, logging: false,
pool: { pool: {
@@ -34,7 +34,6 @@ const dbConfig = {
// Configuration for Sequelize CLI (supports multiple environments) // Configuration for Sequelize CLI (supports multiple environments)
// All environments use the same configuration from environment variables // All environments use the same configuration from environment variables
const cliConfig = { const cliConfig = {
development: dbConfig,
dev: dbConfig, dev: dbConfig,
test: dbConfig, test: dbConfig,
qa: dbConfig, qa: dbConfig,
@@ -53,7 +52,7 @@ const sequelize = new Sequelize(
dialect: dbConfig.dialect, dialect: dbConfig.dialect,
logging: dbConfig.logging, logging: dbConfig.logging,
pool: dbConfig.pool, pool: dbConfig.pool,
} },
); );
// Export the sequelize instance as default (for backward compatibility) // Export the sequelize instance as default (for backward compatibility)

View 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 };

View File

@@ -1,18 +1,40 @@
module.exports = { 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', coverageDirectory: 'coverage',
collectCoverageFrom: [ collectCoverageFrom: [
'**/*.js', '**/*.js',
'!**/node_modules/**', '!**/node_modules/**',
'!**/coverage/**', '!**/coverage/**',
'!**/tests/**', '!**/tests/**',
'!jest.config.js' '!**/migrations/**',
'!**/scripts/**',
'!jest.config.js',
'!babel.config.js',
], ],
coverageReporters: ['text', 'lcov', 'html'], coverageReporters: ['text', 'lcov', 'html'],
testMatch: ['**/tests/**/*.test.js'],
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
forceExit: true,
testTimeout: 10000,
coverageThreshold: { coverageThreshold: {
global: { global: {
lines: 80, lines: 80,

View File

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

View File

@@ -1,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;

View File

@@ -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;

View File

@@ -36,6 +36,34 @@ const apiLogger = (req, res, next) => {
userId: req.user?.id || 'anonymous' 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) { if (res.statusCode >= 400 && res.statusCode < 500) {
// Don't log 401s for /users/profile - these are expected auth checks // Don't log 401s for /users/profile - these are expected auth checks
if (!(res.statusCode === 401 && req.url === '/profile')) { if (!(res.statusCode === 401 && req.url === '/profile')) {

View File

@@ -14,7 +14,7 @@ const authenticateToken = async (req, res, next) => {
} }
try { try {
const decoded = jwt.verify(token, process.env.JWT_SECRET); const decoded = jwt.verify(token, process.env.JWT_ACCESS_SECRET);
const userId = decoded.id; const userId = decoded.id;
if (!userId) { 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 // Validate JWT version to invalidate old tokens after password change
if (decoded.jwtVersion !== user.jwtVersion) { if (decoded.jwtVersion !== user.jwtVersion) {
return res.status(401).json({ return res.status(401).json({
@@ -78,7 +86,7 @@ const optionalAuth = async (req, res, next) => {
} }
try { try {
const decoded = jwt.verify(token, process.env.JWT_SECRET); const decoded = jwt.verify(token, process.env.JWT_ACCESS_SECRET);
const userId = decoded.id; const userId = decoded.id;
if (!userId) { if (!userId) {
@@ -93,6 +101,12 @@ const optionalAuth = async (req, res, next) => {
return next(); return next();
} }
// Banned users are treated as unauthenticated for optional auth
if (user.isBanned) {
req.user = null;
return next();
}
// Validate JWT version to invalidate old tokens after password change // Validate JWT version to invalidate old tokens after password change
if (decoded.jwtVersion !== user.jwtVersion) { if (decoded.jwtVersion !== user.jwtVersion) {
req.user = null; req.user = null;

View File

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

View File

@@ -1,4 +1,5 @@
const rateLimit = require("express-rate-limit"); const rateLimit = require("express-rate-limit");
const logger = require("../utils/logger");
// General rate limiter for Maps API endpoints // General rate limiter for Maps API endpoints
const createMapsRateLimiter = (windowMs, max, message) => { const createMapsRateLimiter = (windowMs, max, message) => {
@@ -104,6 +105,28 @@ const burstProtection = createUserBasedRateLimiter(
"Too many requests in a short period. Please slow down." "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 // Authentication rate limiters
const authRateLimiters = { const authRateLimiters = {
// Login rate limiter - stricter to prevent brute force // Login rate limiter - stricter to prevent brute force
@@ -117,6 +140,7 @@ const authRateLimiters = {
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
skipSuccessfulRequests: true, // Don't count successful logins skipSuccessfulRequests: true, // Don't count successful logins
handler: createRateLimitHandler('login'),
}), }),
// Registration rate limiter // Registration rate limiter
@@ -129,6 +153,7 @@ const authRateLimiters = {
}, },
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
handler: createRateLimitHandler('register'),
}), }),
// Password reset rate limiter // Password reset rate limiter
@@ -141,6 +166,7 @@ const authRateLimiters = {
}, },
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
handler: createRateLimitHandler('passwordReset'),
}), }),
// Alpha code validation rate limiter // Alpha code validation rate limiter
@@ -153,6 +179,20 @@ const authRateLimiters = {
}, },
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, 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 // General API rate limiter
@@ -165,6 +205,58 @@ const authRateLimiters = {
}, },
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, 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, registerLimiter: authRateLimiters.register,
passwordResetLimiter: authRateLimiters.passwordReset, passwordResetLimiter: authRateLimiters.passwordReset,
alphaCodeValidationLimiter: authRateLimiters.alphaCodeValidation, alphaCodeValidationLimiter: authRateLimiters.alphaCodeValidation,
emailVerificationLimiter: authRateLimiters.emailVerification,
generalLimiter: authRateLimiters.general, generalLimiter: authRateLimiters.general,
// Two-Factor Authentication rate limiters
twoFactorVerificationLimiter: authRateLimiters.twoFactorVerification,
twoFactorSetupLimiter: authRateLimiters.twoFactorSetup,
recoveryCodeLimiter: authRateLimiters.recoveryCode,
emailOtpSendLimiter: authRateLimiters.emailOtpSend,
// Burst protection // Burst protection
burstProtection, burstProtection,
// Upload rate limiter
uploadPresignLimiter,
// Utility functions // Utility functions
createMapsRateLimiter, createMapsRateLimiter,
createUserBasedRateLimiter, createUserBasedRateLimiter,

View File

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

View File

@@ -1,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
};

View File

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

View File

@@ -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: [],
});
},
};

View File

@@ -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: [],
}),
]);
},
};

View File

@@ -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");
},
};

View File

@@ -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");
},
};

View File

@@ -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,
});
},
};

View 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');
}
};

View File

@@ -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");
},
};

View File

@@ -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";'
);
},
};

View File

@@ -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");
},
};

View File

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

View File

@@ -0,0 +1,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");
},
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,8 +24,8 @@ const ConditionCheck = sequelize.define("ConditionCheck", {
), ),
allowNull: false, allowNull: false,
}, },
photos: { imageFilenames: {
type: DataTypes.ARRAY(DataTypes.STRING), type: DataTypes.ARRAY(DataTypes.TEXT),
defaultValue: [], defaultValue: [],
}, },
notes: { notes: {

View File

@@ -39,7 +39,7 @@ const ForumComment = sequelize.define('ForumComment', {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
defaultValue: false defaultValue: false
}, },
images: { imageFilenames: {
type: DataTypes.ARRAY(DataTypes.TEXT), type: DataTypes.ARRAY(DataTypes.TEXT),
allowNull: true, allowNull: true,
defaultValue: [] defaultValue: []

View File

@@ -52,7 +52,7 @@ const ForumPost = sequelize.define('ForumPost', {
key: 'id' key: 'id'
} }
}, },
images: { imageFilenames: {
type: DataTypes.ARRAY(DataTypes.TEXT), type: DataTypes.ARRAY(DataTypes.TEXT),
allowNull: true, allowNull: true,
defaultValue: [] defaultValue: []

View File

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

View File

@@ -82,8 +82,8 @@ const Item = sequelize.define("Item", {
longitude: { longitude: {
type: DataTypes.DECIMAL(11, 8), type: DataTypes.DECIMAL(11, 8),
}, },
images: { imageFilenames: {
type: DataTypes.ARRAY(DataTypes.STRING), type: DataTypes.ARRAY(DataTypes.TEXT),
defaultValue: [], defaultValue: [],
}, },
isAvailable: { isAvailable: {
@@ -95,11 +95,11 @@ const Item = sequelize.define("Item", {
}, },
availableAfter: { availableAfter: {
type: DataTypes.STRING, type: DataTypes.STRING,
defaultValue: "09:00", defaultValue: "00:00",
}, },
availableBefore: { availableBefore: {
type: DataTypes.STRING, type: DataTypes.STRING,
defaultValue: "17:00", defaultValue: "23:00",
}, },
specifyTimesPerDay: { specifyTimesPerDay: {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
@@ -108,13 +108,13 @@ const Item = sequelize.define("Item", {
weeklyTimes: { weeklyTimes: {
type: DataTypes.JSONB, type: DataTypes.JSONB,
defaultValue: { defaultValue: {
sunday: { availableAfter: "09:00", availableBefore: "17:00" }, sunday: { availableAfter: "00:00", availableBefore: "23:00" },
monday: { availableAfter: "09:00", availableBefore: "17:00" }, monday: { availableAfter: "00:00", availableBefore: "23:00" },
tuesday: { availableAfter: "09:00", availableBefore: "17:00" }, tuesday: { availableAfter: "00:00", availableBefore: "23:00" },
wednesday: { availableAfter: "09:00", availableBefore: "17:00" }, wednesday: { availableAfter: "00:00", availableBefore: "23:00" },
thursday: { availableAfter: "09:00", availableBefore: "17:00" }, thursday: { availableAfter: "00:00", availableBefore: "23:00" },
friday: { availableAfter: "09:00", availableBefore: "17:00" }, friday: { availableAfter: "00:00", availableBefore: "23:00" },
saturday: { availableAfter: "09:00", availableBefore: "17:00" }, saturday: { availableAfter: "00:00", availableBefore: "23:00" },
}, },
}, },
ownerId: { ownerId: {

View File

@@ -25,18 +25,26 @@ const Message = sequelize.define('Message', {
}, },
content: { content: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
allowNull: false allowNull: true
}, },
isRead: { isRead: {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
defaultValue: false defaultValue: false
}, },
imagePath: { imageFilename: {
type: DataTypes.STRING, type: DataTypes.TEXT,
allowNull: true 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; module.exports = Message;

View File

@@ -67,11 +67,11 @@ const Rental = sequelize.define("Rental", {
allowNull: false, allowNull: false,
}, },
paymentStatus: { paymentStatus: {
type: DataTypes.ENUM("pending", "paid", "refunded", "not_required"), type: DataTypes.ENUM("pending", "paid", "refunded", "not_required", "requires_action"),
allowNull: false, allowNull: false,
}, },
payoutStatus: { payoutStatus: {
type: DataTypes.ENUM("pending", "completed", "failed"), type: DataTypes.ENUM("pending", "completed", "failed", "on_hold"),
allowNull: true, allowNull: true,
}, },
payoutProcessedAt: { payoutProcessedAt: {
@@ -80,6 +80,66 @@ const Rental = sequelize.define("Rental", {
stripeTransferId: { stripeTransferId: {
type: DataTypes.STRING, 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 // Refund tracking fields
refundAmount: { refundAmount: {
type: DataTypes.DECIMAL(10, 2), type: DataTypes.DECIMAL(10, 2),
@@ -117,6 +177,21 @@ const Rental = sequelize.define("Rental", {
chargedAt: { chargedAt: {
type: DataTypes.DATE, type: DataTypes.DATE,
}, },
// Payment failure notification tracking
paymentFailedNotifiedAt: {
type: DataTypes.DATE,
},
paymentFailedReason: {
type: DataTypes.TEXT,
},
// Payment method update rate limiting
paymentMethodUpdatedAt: {
type: DataTypes.DATE,
},
paymentMethodUpdateCount: {
type: DataTypes.INTEGER,
defaultValue: 0,
},
deliveryMethod: { deliveryMethod: {
type: DataTypes.ENUM("pickup", "delivery"), type: DataTypes.ENUM("pickup", "delivery"),
defaultValue: "pickup", defaultValue: "pickup",

View File

@@ -60,8 +60,8 @@ const User = sequelize.define(
country: { country: {
type: DataTypes.STRING, type: DataTypes.STRING,
}, },
profileImage: { imageFilename: {
type: DataTypes.STRING, type: DataTypes.TEXT,
}, },
isVerified: { isVerified: {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
@@ -89,11 +89,11 @@ const User = sequelize.define(
}, },
defaultAvailableAfter: { defaultAvailableAfter: {
type: DataTypes.STRING, type: DataTypes.STRING,
defaultValue: "09:00", defaultValue: "00:00",
}, },
defaultAvailableBefore: { defaultAvailableBefore: {
type: DataTypes.STRING, type: DataTypes.STRING,
defaultValue: "17:00", defaultValue: "23:00",
}, },
defaultSpecifyTimesPerDay: { defaultSpecifyTimesPerDay: {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
@@ -102,23 +102,46 @@ const User = sequelize.define(
defaultWeeklyTimes: { defaultWeeklyTimes: {
type: DataTypes.JSONB, type: DataTypes.JSONB,
defaultValue: { defaultValue: {
sunday: { availableAfter: "09:00", availableBefore: "17:00" }, sunday: { availableAfter: "00:00", availableBefore: "23:00" },
monday: { availableAfter: "09:00", availableBefore: "17:00" }, monday: { availableAfter: "00:00", availableBefore: "23:00" },
tuesday: { availableAfter: "09:00", availableBefore: "17:00" }, tuesday: { availableAfter: "00:00", availableBefore: "23:00" },
wednesday: { availableAfter: "09:00", availableBefore: "17:00" }, wednesday: { availableAfter: "00:00", availableBefore: "23:00" },
thursday: { availableAfter: "09:00", availableBefore: "17:00" }, thursday: { availableAfter: "00:00", availableBefore: "23:00" },
friday: { availableAfter: "09:00", availableBefore: "17:00" }, friday: { availableAfter: "00:00", availableBefore: "23:00" },
saturday: { availableAfter: "09:00", availableBefore: "17:00" }, saturday: { availableAfter: "00:00", availableBefore: "23:00" },
}, },
}, },
stripeConnectedAccountId: { stripeConnectedAccountId: {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: true, allowNull: true,
}, },
stripePayoutsEnabled: {
type: DataTypes.BOOLEAN,
defaultValue: false,
allowNull: true,
},
stripeCustomerId: { stripeCustomerId: {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: true, allowNull: true,
}, },
stripeRequirementsCurrentlyDue: {
type: DataTypes.JSON,
defaultValue: [],
allowNull: true,
},
stripeRequirementsPastDue: {
type: DataTypes.JSON,
defaultValue: [],
allowNull: true,
},
stripeDisabledReason: {
type: DataTypes.STRING,
allowNull: true,
},
stripeRequirementsLastUpdated: {
type: DataTypes.DATE,
allowNull: true,
},
loginAttempts: { loginAttempts: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
defaultValue: 0, defaultValue: 0,
@@ -137,6 +160,23 @@ const User = sequelize.define(
defaultValue: "user", defaultValue: "user",
allowNull: false, allowNull: false,
}, },
isBanned: {
type: DataTypes.BOOLEAN,
defaultValue: false,
allowNull: false,
},
bannedAt: {
type: DataTypes.DATE,
allowNull: true,
},
bannedBy: {
type: DataTypes.UUID,
allowNull: true,
},
banReason: {
type: DataTypes.TEXT,
allowNull: true,
},
itemRequestNotificationRadius: { itemRequestNotificationRadius: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
defaultValue: 10, defaultValue: 10,
@@ -146,6 +186,71 @@ const User = sequelize.define(
max: 100, 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: { hooks: {
@@ -160,7 +265,7 @@ const User = sequelize.define(
} }
}, },
}, },
} },
); );
User.prototype.comparePassword = async function (password) { User.prototype.comparePassword = async function (password) {
@@ -171,7 +276,7 @@ User.prototype.comparePassword = async function (password) {
}; };
// Account lockout constants // Account lockout constants
const MAX_LOGIN_ATTEMPTS = 5; const MAX_LOGIN_ATTEMPTS = 10;
const LOCK_TIME = 2 * 60 * 60 * 1000; // 2 hours const LOCK_TIME = 2 * 60 * 60 * 1000; // 2 hours
// Check if account is locked // Check if account is locked
@@ -208,31 +313,64 @@ User.prototype.resetLoginAttempts = async function () {
}; };
// Email verification methods // Email verification methods
// Maximum verification attempts before requiring a new code
const MAX_VERIFICATION_ATTEMPTS = 5;
User.prototype.generateVerificationToken = async function () { User.prototype.generateVerificationToken = async function () {
const crypto = require("crypto"); 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 const expiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
return this.update({ return this.update({
verificationToken: token, verificationToken: code,
verificationTokenExpiry: expiry, verificationTokenExpiry: expiry,
verificationAttempts: 0, // Reset attempts on new code
}); });
}; };
User.prototype.isVerificationTokenValid = function (token) { User.prototype.isVerificationTokenValid = function (token) {
const crypto = require("crypto");
if (!this.verificationToken || !this.verificationTokenExpiry) { if (!this.verificationToken || !this.verificationTokenExpiry) {
return false; return false;
} }
if (this.verificationToken !== token) { // Check if token is expired
return false;
}
if (new Date() > new Date(this.verificationTokenExpiry)) { if (new Date() > new Date(this.verificationTokenExpiry)) {
return false; 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 () { User.prototype.verifyEmail = async function () {
@@ -241,6 +379,7 @@ User.prototype.verifyEmail = async function () {
verifiedAt: new Date(), verifiedAt: new Date(),
verificationToken: null, verificationToken: null,
verificationTokenExpiry: 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; module.exports = User;

View File

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

4783
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,10 +12,10 @@
"dev:qa": "NODE_ENV=qa nodemon -r dotenv/config server.js dotenv_config_path=.env.qa", "dev:qa": "NODE_ENV=qa nodemon -r dotenv/config server.js dotenv_config_path=.env.qa",
"test": "NODE_ENV=test jest", "test": "NODE_ENV=test jest",
"test:watch": "NODE_ENV=test jest --watch", "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:unit": "NODE_ENV=test jest tests/unit",
"test:integration": "NODE_ENV=test jest tests/integration", "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": "sequelize-cli db:migrate",
"db:migrate:undo": "sequelize-cli db:migrate:undo", "db:migrate:undo": "sequelize-cli db:migrate:undo",
"db:migrate:undo:all": "sequelize-cli db:migrate:undo:all", "db:migrate:undo:all": "sequelize-cli db:migrate:undo:all",
@@ -34,8 +34,11 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.940.0",
"@aws-sdk/client-scheduler": "^3.896.0",
"@aws-sdk/client-ses": "^3.896.0", "@aws-sdk/client-ses": "^3.896.0",
"@aws-sdk/credential-providers": "^3.901.0", "@aws-sdk/credential-providers": "^3.901.0",
"@aws-sdk/s3-request-presigner": "^3.940.0",
"@googlemaps/google-maps-services-js": "^3.4.2", "@googlemaps/google-maps-services-js": "^3.4.2",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"body-parser": "^2.2.0", "body-parser": "^2.2.0",
@@ -52,9 +55,9 @@
"jsdom": "^27.0.0", "jsdom": "^27.0.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"morgan": "^1.10.1", "morgan": "^1.10.1",
"multer": "^2.0.2", "otplib": "^13.1.1",
"node-cron": "^3.0.3",
"pg": "^8.16.3", "pg": "^8.16.3",
"qrcode": "^1.5.4",
"sequelize": "^6.37.7", "sequelize": "^6.37.7",
"sequelize-cli": "^6.6.3", "sequelize-cli": "^6.6.3",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
@@ -64,7 +67,10 @@
"winston-daily-rotate-file": "^5.0.0" "winston-daily-rotate-file": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.28.6",
"@babel/preset-env": "^7.28.6",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"babel-jest": "^30.2.0",
"jest": "^30.1.3", "jest": "^30.1.3",
"nodemon": "^3.1.10", "nodemon": "^3.1.10",
"sequelize-mock": "^0.10.2", "sequelize-mock": "^0.10.2",

View File

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

View File

@@ -20,14 +20,15 @@ const {
loginLimiter, loginLimiter,
registerLimiter, registerLimiter,
passwordResetLimiter, passwordResetLimiter,
emailVerificationLimiter,
} = require("../middleware/rateLimiter"); } = require("../middleware/rateLimiter");
const { authenticateToken } = require("../middleware/auth");
const router = express.Router(); const router = express.Router();
const googleClient = new OAuth2Client( const googleClient = new OAuth2Client(
process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET, process.env.GOOGLE_CLIENT_SECRET,
process.env.GOOGLE_REDIRECT_URI || process.env.GOOGLE_REDIRECT_URI,
"http://localhost:3000/auth/google/callback"
); );
// Get CSRF token endpoint // Get CSRF token endpoint
@@ -43,8 +44,7 @@ router.post(
validateRegistration, validateRegistration,
async (req, res) => { async (req, res) => {
try { try {
const { email, password, firstName, lastName, phone } = const { email, password, firstName, lastName, phone } = req.body;
req.body;
const existingUser = await User.findOne({ const existingUser = await User.findOne({
where: { email }, where: { email },
@@ -64,7 +64,7 @@ router.post(
// Alpha access validation // Alpha access validation
let alphaInvitation = null; let alphaInvitation = null;
if (process.env.ALPHA_TESTING_ENABLED === 'true') { if (process.env.ALPHA_TESTING_ENABLED === "true") {
if (req.cookies && req.cookies.alphaAccessCode) { if (req.cookies && req.cookies.alphaAccessCode) {
const { code } = req.cookies.alphaAccessCode; const { code } = req.cookies.alphaAccessCode;
if (code) { if (code) {
@@ -88,7 +88,8 @@ router.post(
if (!alphaInvitation) { if (!alphaInvitation) {
return res.status(403).json({ 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, phone,
}); });
// Link alpha invitation to user // Link alpha invitation to user (only if alpha testing is enabled)
await alphaInvitation.update({ if (alphaInvitation) {
usedBy: user.id, await alphaInvitation.update({
usedAt: new Date(), usedBy: user.id,
status: "active", usedAt: new Date(),
}); status: "active",
});
}
// Generate verification token and send email // Generate verification token and send email
await user.generateVerificationToken(); await user.generateVerificationToken();
@@ -114,12 +117,16 @@ router.post(
// Send verification email (don't block registration if email fails) // Send verification email (don't block registration if email fails)
let verificationEmailSent = false; let verificationEmailSent = false;
try { try {
await emailServices.auth.sendVerificationEmail(user, user.verificationToken); await emailServices.auth.sendVerificationEmail(
user,
user.verificationToken,
);
verificationEmailSent = true; verificationEmailSent = true;
} catch (emailError) { } catch (emailError) {
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Failed to send verification email", { reqLogger.error("Failed to send verification email", {
error: emailError.message, error: emailError.message,
stack: emailError.stack,
userId: user.id, userId: user.id,
email: user.email, email: user.email,
}); });
@@ -128,29 +135,27 @@ router.post(
const token = jwt.sign( const token = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion }, { id: user.id, jwtVersion: user.jwtVersion },
process.env.JWT_SECRET, process.env.JWT_ACCESS_SECRET,
{ expiresIn: "15m" } // Short-lived access token { expiresIn: "15m" }, // Short-lived access token
); );
const refreshToken = jwt.sign( const refreshToken = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" }, { id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
process.env.JWT_SECRET, process.env.JWT_REFRESH_SECRET,
{ expiresIn: "7d" } { expiresIn: "7d" },
); );
// Set tokens as httpOnly cookies // Set tokens as httpOnly cookies
res.cookie("accessToken", token, { res.cookie("accessToken", token, {
httpOnly: true, httpOnly: true,
secure: secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
sameSite: "strict", sameSite: "strict",
maxAge: 15 * 60 * 1000, // 15 minutes maxAge: 15 * 60 * 1000, // 15 minutes
}); });
res.cookie("refreshToken", refreshToken, { res.cookie("refreshToken", refreshToken, {
httpOnly: true, httpOnly: true,
secure: secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
sameSite: "strict", sameSite: "strict",
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
}); });
@@ -182,7 +187,7 @@ router.post(
}); });
res.status(500).json({ error: "Registration failed. Please try again." }); res.status(500).json({ error: "Registration failed. Please try again." });
} }
} },
); );
router.post( router.post(
@@ -198,14 +203,25 @@ router.post(
const user = await User.findOne({ where: { email } }); const user = await User.findOne({ where: { email } });
if (!user) { 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 // Check if account is locked
if (user.isLocked()) { if (user.isLocked()) {
return res.status(423).json({ return res.status(423).json({
error: 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) { if (!isPasswordValid) {
// Increment login attempts // Increment login attempts
await user.incLoginAttempts(); 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 // Reset login attempts on successful login
@@ -223,29 +241,27 @@ router.post(
const token = jwt.sign( const token = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion }, { id: user.id, jwtVersion: user.jwtVersion },
process.env.JWT_SECRET, process.env.JWT_ACCESS_SECRET,
{ expiresIn: "15m" } // Short-lived access token { expiresIn: "15m" }, // Short-lived access token
); );
const refreshToken = jwt.sign( const refreshToken = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" }, { id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
process.env.JWT_SECRET, process.env.JWT_REFRESH_SECRET,
{ expiresIn: "7d" } { expiresIn: "7d" },
); );
// Set tokens as httpOnly cookies // Set tokens as httpOnly cookies
res.cookie("accessToken", token, { res.cookie("accessToken", token, {
httpOnly: true, httpOnly: true,
secure: secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
sameSite: "strict", sameSite: "strict",
maxAge: 15 * 60 * 1000, // 15 minutes maxAge: 15 * 60 * 1000, // 15 minutes
}); });
res.cookie("refreshToken", refreshToken, { res.cookie("refreshToken", refreshToken, {
httpOnly: true, httpOnly: true,
secure: secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
sameSite: "strict", sameSite: "strict",
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
}); });
@@ -276,7 +292,7 @@ router.post(
}); });
res.status(500).json({ error: "Login failed. Please try again." }); res.status(500).json({ error: "Login failed. Please try again." });
} }
} },
); );
router.post( router.post(
@@ -298,9 +314,7 @@ router.post(
// Exchange authorization code for tokens // Exchange authorization code for tokens
const { tokens } = await googleClient.getToken({ const { tokens } = await googleClient.getToken({
code, code,
redirect_uri: redirect_uri: process.env.GOOGLE_REDIRECT_URI,
process.env.GOOGLE_REDIRECT_URI ||
"http://localhost:3000/auth/google/callback",
}); });
// Verify the ID token from the token response // Verify the ID token from the token response
@@ -320,7 +334,8 @@ router.post(
if (!email) { if (!email) {
return res.status(400).json({ 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; let lastName = familyName;
if (!firstName || !lastName) { if (!firstName || !lastName) {
const emailUsername = email.split('@')[0]; const emailUsername = email.split("@")[0];
// Try to split email username by common separators // Try to split email username by common separators
const nameParts = emailUsername.split(/[._-]/); const nameParts = emailUsername.split(/[._-]/);
if (!firstName) { 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) { if (!lastName) {
lastName = nameParts.length > 1 lastName =
? nameParts[nameParts.length - 1].charAt(0).toUpperCase() + nameParts[nameParts.length - 1].slice(1) nameParts.length > 1
: 'User'; ? nameParts[nameParts.length - 1].charAt(0).toUpperCase() +
nameParts[nameParts.length - 1].slice(1)
: "User";
} }
} }
@@ -367,13 +386,13 @@ router.post(
lastName, lastName,
authProvider: "google", authProvider: "google",
providerId: googleId, providerId: googleId,
profileImage: picture, imageFilename: picture,
isVerified: true, isVerified: true,
verifiedAt: new Date(), verifiedAt: new Date(),
}); });
// Check if there's an alpha invitation for this email // 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({ const alphaInvitation = await AlphaInvitation.findOne({
where: { email: email.toLowerCase().trim() }, 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 // Generate JWT tokens
const token = jwt.sign( const token = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion }, { id: user.id, jwtVersion: user.jwtVersion },
process.env.JWT_SECRET, process.env.JWT_ACCESS_SECRET,
{ expiresIn: "15m" } { expiresIn: "15m" },
); );
const refreshToken = jwt.sign( const refreshToken = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" }, { id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
process.env.JWT_SECRET, process.env.JWT_REFRESH_SECRET,
{ expiresIn: "7d" } { expiresIn: "7d" },
); );
// Set tokens as httpOnly cookies // Set tokens as httpOnly cookies
res.cookie("accessToken", token, { res.cookie("accessToken", token, {
httpOnly: true, httpOnly: true,
secure: secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
sameSite: "strict", sameSite: "strict",
maxAge: 15 * 60 * 1000, maxAge: 15 * 60 * 1000,
}); });
res.cookie("refreshToken", refreshToken, { res.cookie("refreshToken", refreshToken, {
httpOnly: true, httpOnly: true,
secure: secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
sameSite: "strict", sameSite: "strict",
maxAge: 7 * 24 * 60 * 60 * 1000, maxAge: 7 * 24 * 60 * 60 * 1000,
}); });
@@ -434,7 +460,7 @@ router.post(
email: user.email, email: user.email,
firstName: user.firstName, firstName: user.firstName,
lastName: user.lastName, lastName: user.lastName,
profileImage: user.profileImage, imageFilename: user.imageFilename,
isVerified: user.isVerified, isVerified: user.isVerified,
role: user.role, role: user.role,
}, },
@@ -461,77 +487,125 @@ router.post(
.status(500) .status(500)
.json({ error: "Google authentication failed. Please try again." }); .json({ error: "Google authentication failed. Please try again." });
} }
} },
); );
// Email verification endpoint // Email verification endpoint
router.post("/verify-email", sanitizeInput, async (req, res) => { router.post(
try { "/verify-email",
const { token } = req.body; emailVerificationLimiter,
authenticateToken,
sanitizeInput,
async (req, res) => {
try {
const { code } = req.body;
if (!token) { if (!code) {
return res.status(400).json({ return res.status(400).json({
error: "Verification token required", error: "Verification code required",
code: "TOKEN_REQUIRED", code: "CODE_REQUIRED",
}); });
} }
// Find user with this verification token // Validate 6-digit format
const user = await User.findOne({ if (!/^\d{6}$/.test(code)) {
where: { verificationToken: token }, return res.status(400).json({
}); error: "Verification code must be 6 digits",
code: "INVALID_CODE_FORMAT",
});
}
if (!user) { // Get the authenticated user
return res.status(400).json({ const user = await User.findByPk(req.user.id);
error: "Invalid verification token",
code: "VERIFICATION_TOKEN_INVALID",
});
}
// Check if already verified if (!user) {
if (user.isVerified) { return res.status(404).json({
return res.status(400).json({ error: "User not found",
error: "Email already verified", code: "USER_NOT_FOUND",
code: "ALREADY_VERIFIED", });
}); }
}
// Check if token is valid (not expired) // Check if already verified
if (!user.isVerificationTokenValid(token)) { if (user.isVerified) {
return res.status(400).json({ return res.status(400).json({
error: "Verification token has expired. Please request a new one.", error: "Email already verified",
code: "VERIFICATION_TOKEN_EXPIRED", code: "ALREADY_VERIFIED",
}); });
} }
// Verify the email // Check if too many failed attempts
await user.verifyEmail(); 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); // Check if user has a verification token
reqLogger.info("Email verified successfully", { if (!user.verificationToken) {
userId: user.id, return res.status(400).json({
email: user.email, error: "No verification code found. Please request a new one.",
}); code: "NO_CODE",
});
}
res.json({ // Check if code is expired
message: "Email verified successfully", if (
user: { user.verificationTokenExpiry &&
id: user.id, 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, email: user.email,
isVerified: true, });
},
}); res.json({
} catch (error) { message: "Email verified successfully",
const reqLogger = logger.withRequestId(req.id); user: {
reqLogger.error("Email verification error", { id: user.id,
error: error.message, email: user.email,
stack: error.stack, isVerified: true,
}); },
res.status(500).json({ });
error: "Email verification failed. Please try again.", } 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 // Resend verification email endpoint
router.post( 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); const user = await User.findByPk(decoded.id);
if (!user) { if (!user) {
@@ -573,11 +647,15 @@ router.post(
// Send verification email // Send verification email
try { try {
await emailServices.auth.sendVerificationEmail(user, user.verificationToken); await emailServices.auth.sendVerificationEmail(
user,
user.verificationToken,
);
} catch (emailError) { } catch (emailError) {
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Failed to resend verification email", { reqLogger.error("Failed to resend verification email", {
error: emailError.message, error: emailError.message,
stack: emailError.stack,
userId: user.id, userId: user.id,
email: user.email, email: user.email,
}); });
@@ -612,7 +690,7 @@ router.post(
error: "Failed to resend verification email. Please try again.", error: "Failed to resend verification email. Please try again.",
}); });
} }
} },
); );
// Refresh token endpoint // Refresh token endpoint
@@ -625,7 +703,7 @@ router.post("/refresh", async (req, res) => {
} }
// Verify refresh token // 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") { if (!decoded.id || decoded.type !== "refresh") {
return res.status(401).json({ error: "Invalid refresh token" }); 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 // Generate new access token
const newAccessToken = jwt.sign( const newAccessToken = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion }, { id: user.id, jwtVersion: user.jwtVersion },
process.env.JWT_SECRET, process.env.JWT_ACCESS_SECRET,
{ expiresIn: "15m" } { expiresIn: "15m" },
); );
// Set new access token cookie // Set new access token cookie
res.cookie("accessToken", newAccessToken, { res.cookie("accessToken", newAccessToken, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa", secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
sameSite: "strict", sameSite: "strict",
maxAge: 15 * 60 * 1000, maxAge: 15 * 60 * 1000,
}); });
@@ -752,6 +839,7 @@ router.post(
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Failed to send password reset email", { reqLogger.error("Failed to send password reset email", {
error: emailError.message, error: emailError.message,
stack: emailError.stack,
userId: user.id, userId: user.id,
email: user.email, email: user.email,
}); });
@@ -763,7 +851,7 @@ router.post(
"Password reset requested for non-existent or OAuth user", "Password reset requested for non-existent or OAuth user",
{ {
email: email, email: email,
} },
); );
} }
@@ -783,7 +871,7 @@ router.post(
error: "Failed to process password reset request. Please try again.", error: "Failed to process password reset request. Please try again.",
}); });
} }
} },
); );
// Verify reset token endpoint (optional - for frontend UX) // Verify reset token endpoint (optional - for frontend UX)
@@ -837,7 +925,7 @@ router.post(
error: "Failed to verify reset token. Please try again.", error: "Failed to verify reset token. Please try again.",
}); });
} }
} },
); );
// Reset password endpoint // Reset password endpoint
@@ -893,6 +981,7 @@ router.post(
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Failed to send password changed notification", { reqLogger.error("Failed to send password changed notification", {
error: emailError.message, error: emailError.message,
stack: emailError.stack,
userId: user.id, userId: user.id,
email: user.email, email: user.email,
}); });
@@ -919,7 +1008,7 @@ router.post(
error: "Failed to reset password. Please try again.", error: "Failed to reset password. Please try again.",
}); });
} }
} },
); );
module.exports = router; module.exports = router;

View File

@@ -1,86 +1,35 @@
const express = require("express"); const express = require("express");
const multer = require("multer");
const { authenticateToken } = require("../middleware/auth"); const { authenticateToken } = require("../middleware/auth");
const ConditionCheckService = require("../services/conditionCheckService"); const ConditionCheckService = require("../services/conditionCheckService");
const logger = require("../utils/logger"); const logger = require("../utils/logger");
const { validateS3Keys } = require("../utils/s3KeyValidator");
const { IMAGE_LIMITS } = require("../config/imageLimits");
const router = express.Router(); const router = express.Router();
// Configure multer for photo uploads // Get condition checks for multiple rentals in a single request (batch)
const upload = multer({ router.get("/batch", authenticateToken, async (req, res) => {
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) => {
try { try {
const { rentalId } = req.params; const { rentalIds } = req.query;
const conditionChecks = await ConditionCheckService.getConditionChecks( if (!rentalIds) {
rentalId 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({ res.json({
success: true, success: true,
@@ -88,9 +37,10 @@ router.get("/:rentalId", authenticateToken, async (req, res) => {
}); });
} catch (error) { } catch (error) {
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error fetching condition checks", { reqLogger.error("Error fetching batch condition checks", {
error: error.message, error: error.message,
rentalId: req.params.rentalId, stack: error.stack,
rentalIds: req.query.rentalIds,
}); });
res.status(500).json({ res.status(500).json({
@@ -100,27 +50,66 @@ router.get("/:rentalId", authenticateToken, async (req, res) => {
} }
}); });
// Get condition check timeline for a rental // Submit a condition check
router.get("/:rentalId/timeline", authenticateToken, async (req, res) => { router.post("/:rentalId", authenticateToken, async (req, res) => {
try { try {
const { rentalId } = req.params; const { rentalId } = req.params;
const { checkType, notes, imageFilenames: rawImageFilenames } = req.body;
const userId = req.user.id;
const timeline = await ConditionCheckService.getConditionCheckTimeline( // Ensure imageFilenames is an array (S3 keys)
rentalId 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, success: true,
timeline, conditionCheck,
}); });
} catch (error) { } catch (error) {
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error fetching condition check timeline", { reqLogger.error("Error submitting condition check", {
error: error.message, error: error.message,
stack: error.stack,
rentalId: req.params.rentalId, rentalId: req.params.rentalId,
userId: req.user?.id,
}); });
res.status(500).json({ res.status(400).json({
success: false, success: false,
error: error.message, error: error.message,
}); });
@@ -131,9 +120,12 @@ router.get("/:rentalId/timeline", authenticateToken, async (req, res) => {
router.get("/", authenticateToken, async (req, res) => { router.get("/", authenticateToken, async (req, res) => {
try { try {
const userId = req.user.id; const userId = req.user.id;
const { rentalIds } = req.query;
const ids = rentalIds ? rentalIds.split(",").filter((id) => id.trim()) : [];
const availableChecks = await ConditionCheckService.getAvailableChecks( const availableChecks = await ConditionCheckService.getAvailableChecks(
userId userId,
ids
); );
res.json({ res.json({
@@ -144,6 +136,7 @@ router.get("/", authenticateToken, async (req, res) => {
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error fetching available checks", { reqLogger.error("Error fetching available checks", {
error: error.message, error: error.message,
stack: error.stack,
userId: req.user?.id, userId: req.user?.id,
}); });

View File

@@ -7,7 +7,7 @@ const emailServices = require('../services/email');
const router = express.Router(); const router = express.Router();
// Submit new feedback // Submit new feedback
router.post('/', authenticateToken, sanitizeInput, validateFeedback, async (req, res) => { router.post('/', authenticateToken, sanitizeInput, validateFeedback, async (req, res, next) => {
try { try {
const { feedbackText, url } = req.body; const { feedbackText, url } = req.body;
@@ -33,6 +33,7 @@ router.post('/', authenticateToken, sanitizeInput, validateFeedback, async (req,
} catch (emailError) { } catch (emailError) {
reqLogger.error("Failed to send feedback confirmation email", { reqLogger.error("Failed to send feedback confirmation email", {
error: emailError.message, error: emailError.message,
stack: emailError.stack,
userId: req.user.id, userId: req.user.id,
feedbackId: feedback.id feedbackId: feedback.id
}); });
@@ -45,6 +46,7 @@ router.post('/', authenticateToken, sanitizeInput, validateFeedback, async (req,
} catch (emailError) { } catch (emailError) {
reqLogger.error("Failed to send feedback notification to admin", { reqLogger.error("Failed to send feedback notification to admin", {
error: emailError.message, error: emailError.message,
stack: emailError.stack,
userId: req.user.id, userId: req.user.id,
feedbackId: feedback.id feedbackId: feedback.id
}); });
@@ -59,7 +61,7 @@ router.post('/', authenticateToken, sanitizeInput, validateFeedback, async (req,
stack: error.stack, stack: error.stack,
userId: req.user.id userId: req.user.id
}); });
res.status(500).json({ error: error.message }); next(error);
} }
}); });

View File

@@ -2,11 +2,13 @@ const express = require('express');
const { Op } = require('sequelize'); const { Op } = require('sequelize');
const { ForumPost, ForumComment, PostTag, User } = require('../models'); const { ForumPost, ForumComment, PostTag, User } = require('../models');
const { authenticateToken, requireAdmin, optionalAuth } = require('../middleware/auth'); const { authenticateToken, requireAdmin, optionalAuth } = require('../middleware/auth');
const { uploadForumPostImages, uploadForumCommentImages } = require('../middleware/upload'); const { validateCoordinatesBody, handleValidationErrors } = require('../middleware/validation');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const emailServices = require('../services/email'); const emailServices = require('../services/email');
const googleMapsService = require('../services/googleMapsService'); const googleMapsService = require('../services/googleMapsService');
const locationService = require('../services/locationService'); const locationService = require('../services/locationService');
const { validateS3Keys } = require('../utils/s3KeyValidator');
const { IMAGE_LIMITS } = require('../config/imageLimits');
const router = express.Router(); const router = express.Router();
// Helper function to build nested comment tree // Helper function to build nested comment tree
@@ -21,7 +23,7 @@ const buildCommentTree = (comments, isAdmin = false) => {
// Sanitize deleted comments for non-admin users // Sanitize deleted comments for non-admin users
if (commentJson.isDeleted && !isAdmin) { if (commentJson.isDeleted && !isAdmin) {
commentJson.content = ''; commentJson.content = '';
commentJson.images = []; commentJson.imageFilenames = [];
} }
commentMap[comment.id] = { ...commentJson, replies: [] }; commentMap[comment.id] = { ...commentJson, replies: [] };
@@ -40,7 +42,7 @@ const buildCommentTree = (comments, isAdmin = false) => {
}; };
// GET /api/forum/posts - Browse all posts // GET /api/forum/posts - Browse all posts
router.get('/posts', optionalAuth, async (req, res) => { router.get('/posts', optionalAuth, async (req, res, next) => {
try { try {
const { const {
search, search,
@@ -158,12 +160,12 @@ router.get('/posts', optionalAuth, async (req, res) => {
stack: error.stack, stack: error.stack,
query: req.query query: req.query
}); });
res.status(500).json({ error: error.message }); next(error);
} }
}); });
// GET /api/forum/posts/:id - Get single post with all comments // 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 { try {
const post = await ForumPost.findByPk(req.params.id, { const post = await ForumPost.findByPk(req.params.id, {
include: [ include: [
@@ -233,26 +235,35 @@ router.get('/posts/:id', optionalAuth, async (req, res) => {
stack: error.stack, stack: error.stack,
postId: req.params.id postId: req.params.id
}); });
res.status(500).json({ error: error.message }); next(error);
} }
}); });
// POST /api/forum/posts - Create new post // 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 { try {
let { title, content, category, tags, zipCode, latitude: providedLat, longitude: providedLng } = req.body; // Require email verification
if (!req.user.isVerified) {
// Parse tags if they come as JSON string (from FormData) return res.status(403).json({
if (typeof tags === 'string') { error: "Please verify your email address before creating forum posts.",
try { code: "EMAIL_NOT_VERIFIED"
tags = JSON.parse(tags); });
} catch (e) {
tags = [];
}
} }
// Extract image filenames if uploaded let { title, content, category, tags, zipCode, latitude: providedLat, longitude: providedLng, imageFilenames: rawImageFilenames } = req.body;
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;
// Initialize location fields // Initialize location fields
let latitude = null; let latitude = null;
@@ -301,6 +312,7 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res)
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Geocoding failed for item request", { reqLogger.error("Geocoding failed for item request", {
error: error.message, error: error.message,
stack: error.stack,
zipCode zipCode
}); });
// Continue without coordinates - post will still be created // Continue without coordinates - post will still be created
@@ -313,7 +325,7 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res)
content, content,
category, category,
authorId: req.user.id, authorId: req.user.id,
images, imageFilenames,
zipCode: zipCode || null, zipCode: zipCode || null,
latitude, latitude,
longitude longitude
@@ -440,6 +452,7 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res)
} catch (emailError) { } catch (emailError) {
logger.error("Failed to send item request notification", { logger.error("Failed to send item request notification", {
error: emailError.message, error: emailError.message,
stack: emailError.stack,
recipientId: user.id, recipientId: user.id,
postId: post.id postId: post.id
}); });
@@ -481,13 +494,21 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res)
authorId: req.user.id, authorId: req.user.id,
postData: logger.sanitize(req.body) postData: logger.sanitize(req.body)
}); });
res.status(500).json({ error: error.message }); next(error);
} }
}); });
// PUT /api/forum/posts/:id - Update post // 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 { 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); const post = await ForumPost.findByPk(req.params.id);
if (!post) { if (!post) {
@@ -498,9 +519,26 @@ router.put('/posts/:id', authenticateToken, async (req, res) => {
return res.status(403).json({ error: 'Unauthorized' }); 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 // Update tags if provided
if (tags !== undefined) { if (tags !== undefined) {
@@ -549,12 +587,12 @@ router.put('/posts/:id', authenticateToken, async (req, res) => {
postId: req.params.id, postId: req.params.id,
authorId: req.user.id authorId: req.user.id
}); });
res.status(500).json({ error: error.message }); next(error);
} }
}); });
// DELETE /api/forum/posts/:id - Delete post // 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 { try {
const post = await ForumPost.findByPk(req.params.id); const post = await ForumPost.findByPk(req.params.id);
@@ -586,12 +624,12 @@ router.delete('/posts/:id', authenticateToken, async (req, res) => {
postId: req.params.id, postId: req.params.id,
authorId: req.user.id authorId: req.user.id
}); });
res.status(500).json({ error: error.message }); next(error);
} }
}); });
// PATCH /api/forum/posts/:id/status - Update post status // 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 { try {
const { status } = req.body; const { status } = req.body;
const post = await ForumPost.findByPk(req.params.id); const post = await ForumPost.findByPk(req.params.id);
@@ -692,7 +730,7 @@ router.patch('/posts/:id/status', authenticateToken, async (req, res) => {
stack: emailError.stack, stack: emailError.stack,
postId: req.params.id postId: req.params.id
}); });
console.error("Email notification error:", emailError); logger.error("Email notification error", { error: emailError });
} }
})(); })();
} }
@@ -734,12 +772,12 @@ router.patch('/posts/:id/status', authenticateToken, async (req, res) => {
postId: req.params.id, postId: req.params.id,
authorId: req.user.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 // 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 { try {
const { commentId } = req.body; const { commentId } = req.body;
const post = await ForumPost.findByPk(req.params.id); const post = await ForumPost.findByPk(req.params.id);
@@ -872,7 +910,7 @@ router.patch('/posts/:id/accept-answer', authenticateToken, async (req, res) =>
commentId: commentId, commentId: commentId,
postId: req.params.id postId: req.params.id
}); });
console.error("Email notification error:", emailError); logger.error("Email notification error", { error: emailError });
} }
})(); })();
} }
@@ -908,14 +946,24 @@ router.patch('/posts/:id/accept-answer', authenticateToken, async (req, res) =>
postId: req.params.id, postId: req.params.id,
authorId: req.user.id authorId: req.user.id
}); });
res.status(500).json({ error: error.message }); next(error);
} }
}); });
// POST /api/forum/posts/:id/comments - Add comment/reply // 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 { 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); const post = await ForumPost.findByPk(req.params.id);
if (!post) { if (!post) {
@@ -928,22 +976,32 @@ router.post('/posts/:id/comments', authenticateToken, uploadForumCommentImages,
} }
// Validate parent comment if provided // Validate parent comment if provided
if (parentCommentId) { if (parentIdResolved) {
const parentComment = await ForumComment.findByPk(parentCommentId); const parentComment = await ForumComment.findByPk(parentIdResolved);
if (!parentComment || parentComment.postId !== post.id) { if (!parentComment || parentComment.postId !== post.id) {
return res.status(400).json({ error: 'Invalid parent comment' }); return res.status(400).json({ error: 'Invalid parent comment' });
} }
} }
// Extract image filenames if uploaded // Ensure imageFilenames is an array and validate S3 keys
const images = req.files ? req.files.map(file => file.filename) : []; 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({ const comment = await ForumComment.create({
postId: req.params.id, postId: req.params.id,
authorId: req.user.id, authorId: req.user.id,
content, content,
parentCommentId: parentCommentId || null, parentCommentId: parentIdResolved || null,
images imageFilenames
}); });
// Increment comment count and update post's updatedAt to reflect activity // Increment comment count and update post's updatedAt to reflect activity
@@ -955,7 +1013,7 @@ router.post('/posts/:id/comments', authenticateToken, uploadForumCommentImages,
{ {
model: User, model: User,
as: 'author', 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, commentId: comment.id,
postId: req.params.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, postId: req.params.id,
authorId: req.user.id authorId: req.user.id
}); });
res.status(500).json({ error: error.message }); next(error);
} }
}); });
// PUT /api/forum/comments/:id - Edit comment // 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 { 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); const comment = await ForumComment.findByPk(req.params.id);
if (!comment) { if (!comment) {
@@ -1095,7 +1161,19 @@ router.put('/comments/:id', authenticateToken, async (req, res) => {
return res.status(400).json({ error: 'Cannot edit deleted comment' }); 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, { const updatedComment = await ForumComment.findByPk(comment.id, {
include: [ include: [
@@ -1122,12 +1200,12 @@ router.put('/comments/:id', authenticateToken, async (req, res) => {
commentId: req.params.id, commentId: req.params.id,
authorId: req.user.id authorId: req.user.id
}); });
res.status(500).json({ error: error.message }); next(error);
} }
}); });
// DELETE /api/forum/comments/:id - Soft delete comment // 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 { try {
const comment = await ForumComment.findByPk(req.params.id); const comment = await ForumComment.findByPk(req.params.id);
@@ -1164,12 +1242,12 @@ router.delete('/comments/:id', authenticateToken, async (req, res) => {
commentId: req.params.id, commentId: req.params.id,
authorId: req.user.id authorId: req.user.id
}); });
res.status(500).json({ error: error.message }); next(error);
} }
}); });
// GET /api/forum/my-posts - Get user's posts // 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 { try {
const posts = await ForumPost.findAll({ const posts = await ForumPost.findAll({
where: { authorId: req.user.id }, where: { authorId: req.user.id },
@@ -1202,12 +1280,12 @@ router.get('/my-posts', authenticateToken, async (req, res) => {
stack: error.stack, stack: error.stack,
userId: req.user.id userId: req.user.id
}); });
res.status(500).json({ error: error.message }); next(error);
} }
}); });
// GET /api/forum/tags - Get all unique tags for autocomplete // GET /api/forum/tags - Get all unique tags for autocomplete
router.get('/tags', async (req, res) => { router.get('/tags', async (req, res, next) => {
try { try {
const { search } = req.query; const { search } = req.query;
@@ -1241,14 +1319,14 @@ router.get('/tags', async (req, res) => {
stack: error.stack, stack: error.stack,
query: req.query query: req.query
}); });
res.status(500).json({ error: error.message }); next(error);
} }
}); });
// ============ ADMIN ROUTES ============ // ============ ADMIN ROUTES ============
// DELETE /api/forum/admin/posts/:id - Admin soft-delete post // 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 { try {
const { reason } = req.body; const { reason } = req.body;
@@ -1261,7 +1339,7 @@ router.delete('/admin/posts/:id', authenticateToken, requireAdmin, async (req, r
{ {
model: User, model: User,
as: 'author', 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) { } catch (emailError) {
// Log but don't fail the deletion // 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, postId: req.params.id,
adminId: req.user.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 // 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 { try {
const post = await ForumPost.findByPk(req.params.id); 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, postId: req.params.id,
adminId: req.user.id adminId: req.user.id
}); });
res.status(500).json({ error: error.message }); next(error);
} }
}); });
// DELETE /api/forum/admin/comments/:id - Admin soft-delete comment // 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 { try {
const { reason } = req.body; const { reason } = req.body;
@@ -1380,7 +1463,7 @@ router.delete('/admin/comments/:id', authenticateToken, requireAdmin, async (req
{ {
model: User, model: User,
as: 'author', 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) { } catch (emailError) {
// Log but don't fail the deletion // 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, commentId: req.params.id,
adminId: req.user.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 // 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 { try {
const comment = await ForumComment.findByPk(req.params.id); 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, commentId: req.params.id,
adminId: req.user.id adminId: req.user.id
}); });
res.status(500).json({ error: error.message }); next(error);
} }
}); });
// PATCH /api/forum/admin/posts/:id/close - Admin close discussion // 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 { try {
const post = await ForumPost.findByPk(req.params.id, { const post = await ForumPost.findByPk(req.params.id, {
include: [ include: [
{ {
model: User, model: User,
as: 'author', 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 () => { (async () => {
try { try {
const admin = await User.findByPk(req.user.id, { 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) // Get all unique participants (author + commenters)
@@ -1602,7 +1690,7 @@ router.patch('/admin/posts/:id/close', authenticateToken, requireAdmin, async (r
stack: emailError.stack, stack: emailError.stack,
postId: req.params.id postId: req.params.id
}); });
console.error("Email notification error:", emailError); logger.error("Email notification error", { error: emailError });
} }
})(); })();
@@ -1615,12 +1703,12 @@ router.patch('/admin/posts/:id/close', authenticateToken, requireAdmin, async (r
postId: req.params.id, postId: req.params.id,
adminId: req.user.id adminId: req.user.id
}); });
res.status(500).json({ error: error.message }); next(error);
} }
}); });
// PATCH /api/forum/admin/posts/:id/reopen - Admin reopen discussion // 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 { try {
const post = await ForumPost.findByPk(req.params.id); 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, postId: req.params.id,
adminId: req.user.id adminId: req.user.id
}); });
res.status(500).json({ error: error.message }); next(error);
} }
}); });

121
backend/routes/health.js Normal file
View 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;

View File

@@ -1,11 +1,60 @@
const express = require("express"); const express = require("express");
const { Op } = require("sequelize"); const { Op, Sequelize } = require("sequelize");
const { Item, User, Rental } = require("../models"); // Import from models/index.js to get models with associations const { Item, User, Rental, sequelize } = require("../models"); // Import from models/index.js to get models with associations
const { authenticateToken, requireVerifiedEmail, requireAdmin, optionalAuth } = require("../middleware/auth"); const { authenticateToken, requireVerifiedEmail, requireAdmin, optionalAuth } = require("../middleware/auth");
const { validateCoordinatesQuery, validateCoordinatesBody, handleValidationErrors } = require("../middleware/validation");
const logger = require("../utils/logger"); const logger = require("../utils/logger");
const { validateS3Keys } = require("../utils/s3KeyValidator");
const { IMAGE_LIMITS } = require("../config/imageLimits");
const router = express.Router(); 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 { try {
const { const {
minPrice, minPrice,
@@ -13,6 +62,9 @@ router.get("/", async (req, res) => {
city, city,
zipCode, zipCode,
search, search,
lat,
lng,
radius = 25,
page = 1, page = 1,
limit = 20, limit = 20,
} = req.query; } = req.query;
@@ -26,8 +78,50 @@ router.get("/", async (req, res) => {
if (minPrice) where.pricePerDay[Op.gte] = minPrice; if (minPrice) where.pricePerDay[Op.gte] = minPrice;
if (maxPrice) where.pricePerDay[Op.lte] = maxPrice; 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) { if (search) {
where[Op.or] = [ where[Op.or] = [
{ name: { [Op.iLike]: `%${search}%` } }, { name: { [Op.iLike]: `%${search}%` } },
@@ -43,7 +137,11 @@ router.get("/", async (req, res) => {
{ {
model: User, model: User,
as: "owner", as: "owner",
attributes: ["id", "firstName", "lastName"], attributes: ["id", "firstName", "lastName", "imageFilename"],
where: {
isBanned: { [Op.ne]: true }
},
required: true,
}, },
], ],
limit: parseInt(limit), limit: parseInt(limit),
@@ -65,7 +163,7 @@ router.get("/", async (req, res) => {
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Items search completed", { reqLogger.info("Items search completed", {
filters: { minPrice, maxPrice, city, zipCode, search }, filters: { minPrice, maxPrice, city, zipCode, search, lat, lng, radius },
resultsCount: count, resultsCount: count,
page: parseInt(page), page: parseInt(page),
limit: parseInt(limit) limit: parseInt(limit)
@@ -84,11 +182,11 @@ router.get("/", async (req, res) => {
stack: error.stack, stack: error.stack,
query: req.query 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 { try {
const userRentals = await Rental.findAll({ const userRentals = await Rental.findAll({
where: { renterId: req.user.id }, where: { renterId: req.user.id },
@@ -119,15 +217,15 @@ router.get("/recommendations", authenticateToken, async (req, res) => {
stack: error.stack, stack: error.stack,
userId: req.user.id 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) // 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 { try {
const { Rental, User } = require('../models'); const { Rental, User } = require('../models');
const reviews = await Rental.findAll({ const reviews = await Rental.findAll({
where: { where: {
itemId: req.params.id, itemId: req.params.id,
@@ -137,10 +235,10 @@ router.get('/:id/reviews', async (req, res) => {
itemReviewVisible: true itemReviewVisible: true
}, },
include: [ include: [
{ {
model: User, model: User,
as: 'renter', as: 'renter',
attributes: ['id', 'firstName', 'lastName'] attributes: ['id', 'firstName', 'lastName', 'imageFilename']
} }
], ],
order: [['createdAt', 'DESC']] order: [['createdAt', 'DESC']]
@@ -169,18 +267,18 @@ router.get('/:id/reviews', async (req, res) => {
stack: error.stack, stack: error.stack,
itemId: req.params.id 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 { try {
const item = await Item.findByPk(req.params.id, { const item = await Item.findByPk(req.params.id, {
include: [ include: [
{ {
model: User, model: User,
as: "owner", as: "owner",
attributes: ["id", "firstName", "lastName"], attributes: ["id", "firstName", "lastName", "imageFilename"],
}, },
{ {
model: User, model: User,
@@ -226,14 +324,57 @@ router.get("/:id", optionalAuth, async (req, res) => {
stack: error.stack, stack: error.stack,
itemId: req.params.id 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 { 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({ const item = await Item.create({
...req.body, ...allowedData,
ownerId: req.user.id, ownerId: req.user.id,
}); });
@@ -260,10 +401,17 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
itemWithOwner.owner, itemWithOwner.owner,
itemWithOwner 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) { } catch (emailError) {
// Log but don't fail the item creation // 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, ownerId: req.user.id,
itemData: logger.sanitize(req.body) 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 { try {
const item = await Item.findByPk(req.params.id); 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" }); 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, { const updatedItem = await Item.findByPk(item.id, {
include: [ include: [
@@ -327,11 +521,11 @@ router.put("/:id", authenticateToken, async (req, res) => {
itemId: req.params.id, itemId: req.params.id,
ownerId: req.user.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 { try {
const item = await Item.findByPk(req.params.id); const item = await Item.findByPk(req.params.id);
@@ -360,12 +554,12 @@ router.delete("/:id", authenticateToken, async (req, res) => {
itemId: req.params.id, itemId: req.params.id,
ownerId: req.user.id ownerId: req.user.id
}); });
res.status(500).json({ error: error.message }); next(error);
} }
}); });
// Admin endpoints // Admin endpoints
router.delete("/admin/:id", authenticateToken, requireAdmin, async (req, res) => { router.delete("/admin/:id", authenticateToken, requireAdmin, async (req, res, next) => {
try { try {
const { reason } = req.body; const { reason } = req.body;
@@ -440,10 +634,15 @@ router.delete("/admin/:id", authenticateToken, requireAdmin, async (req, res) =>
item, item,
reason.trim() 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) { } catch (emailError) {
// Log but don't fail the deletion // 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); const reqLogger = logger.withRequestId(req.id);
@@ -463,11 +662,11 @@ router.delete("/admin/:id", authenticateToken, requireAdmin, async (req, res) =>
itemId: req.params.id, itemId: req.params.id,
adminId: req.user.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 { try {
const item = await Item.findByPk(req.params.id); 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, itemId: req.params.id,
adminId: req.user.id adminId: req.user.id
}); });
res.status(500).json({ error: error.message }); next(error);
} }
}); });

View File

@@ -1,18 +1,16 @@
const express = require('express'); const express = require('express');
const helmet = require('helmet');
const { Message, User } = require('../models'); const { Message, User } = require('../models');
const { authenticateToken } = require('../middleware/auth'); const { authenticateToken } = require('../middleware/auth');
const { uploadMessageImage } = require('../middleware/upload');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { emitNewMessage, emitMessageRead } = require('../sockets/messageSocket'); const { emitNewMessage, emitMessageRead } = require('../sockets/messageSocket');
const { Op } = require('sequelize'); const { Op } = require('sequelize');
const emailServices = require('../services/email'); const emailServices = require('../services/email');
const fs = require('fs'); const { validateS3Keys } = require('../utils/s3KeyValidator');
const path = require('path'); const { IMAGE_LIMITS } = require('../config/imageLimits');
const router = express.Router(); const router = express.Router();
// Get all messages for the current user (inbox) // Get all messages for the current user (inbox)
router.get('/', authenticateToken, async (req, res) => { router.get('/', authenticateToken, async (req, res, next) => {
try { try {
const messages = await Message.findAll({ const messages = await Message.findAll({
where: { receiverId: req.user.id }, where: { receiverId: req.user.id },
@@ -20,7 +18,7 @@ router.get('/', authenticateToken, async (req, res) => {
{ {
model: User, model: User,
as: 'sender', as: 'sender',
attributes: ['id', 'firstName', 'lastName', 'profileImage'] attributes: ['id', 'firstName', 'lastName', 'imageFilename']
} }
], ],
order: [['createdAt', 'DESC']] order: [['createdAt', 'DESC']]
@@ -40,12 +38,12 @@ router.get('/', authenticateToken, async (req, res) => {
stack: error.stack, stack: error.stack,
userId: req.user.id userId: req.user.id
}); });
res.status(500).json({ error: error.message }); next(error);
} }
}); });
// Get conversations grouped by user pairs // Get conversations grouped by user pairs
router.get('/conversations', authenticateToken, async (req, res) => { router.get('/conversations', authenticateToken, async (req, res, next) => {
try { try {
const userId = req.user.id; const userId = req.user.id;
@@ -61,12 +59,12 @@ router.get('/conversations', authenticateToken, async (req, res) => {
{ {
model: User, model: User,
as: 'sender', as: 'sender',
attributes: ['id', 'firstName', 'lastName', 'profileImage'] attributes: ['id', 'firstName', 'lastName', 'imageFilename']
}, },
{ {
model: User, model: User,
as: 'receiver', as: 'receiver',
attributes: ['id', 'firstName', 'lastName', 'profileImage'] attributes: ['id', 'firstName', 'lastName', 'imageFilename']
} }
], ],
order: [['createdAt', 'DESC']] order: [['createdAt', 'DESC']]
@@ -134,12 +132,12 @@ router.get('/conversations', authenticateToken, async (req, res) => {
stack: error.stack, stack: error.stack,
userId: req.user.id userId: req.user.id
}); });
res.status(500).json({ error: error.message }); next(error);
} }
}); });
// Get sent messages // Get sent messages
router.get('/sent', authenticateToken, async (req, res) => { router.get('/sent', authenticateToken, async (req, res, next) => {
try { try {
const messages = await Message.findAll({ const messages = await Message.findAll({
where: { senderId: req.user.id }, where: { senderId: req.user.id },
@@ -147,7 +145,7 @@ router.get('/sent', authenticateToken, async (req, res) => {
{ {
model: User, model: User,
as: 'receiver', as: 'receiver',
attributes: ['id', 'firstName', 'lastName', 'profileImage'] attributes: ['id', 'firstName', 'lastName', 'imageFilename']
} }
], ],
order: [['createdAt', 'DESC']] order: [['createdAt', 'DESC']]
@@ -167,12 +165,12 @@ router.get('/sent', authenticateToken, async (req, res) => {
stack: error.stack, stack: error.stack,
userId: req.user.id userId: req.user.id
}); });
res.status(500).json({ error: error.message }); next(error);
} }
}); });
// Get a single message // Get a single message
router.get('/:id', authenticateToken, async (req, res) => { router.get('/:id', authenticateToken, async (req, res, next) => {
try { try {
const message = await Message.findOne({ const message = await Message.findOne({
where: { where: {
@@ -186,12 +184,12 @@ router.get('/:id', authenticateToken, async (req, res) => {
{ {
model: User, model: User,
as: 'sender', as: 'sender',
attributes: ['id', 'firstName', 'lastName', 'profileImage'] attributes: ['id', 'firstName', 'lastName', 'imageFilename']
}, },
{ {
model: User, model: User,
as: 'receiver', 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, userId: req.user.id,
messageId: req.params.id messageId: req.params.id
}); });
res.status(500).json({ error: error.message }); next(error);
} }
}); });
// Send a new message // Send a new message
router.post('/', authenticateToken, uploadMessageImage, async (req, res) => { router.post('/', authenticateToken, async (req, res, next) => {
try { 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 // Check if receiver exists
const receiver = await User.findByPk(receiverId); 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' }); 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({ const message = await Message.create({
senderId: req.user.id, senderId: req.user.id,
receiverId, receiverId,
content, content,
imagePath imageFilename: imageFilename || null
}); });
const messageWithSender = await Message.findByPk(message.id, { const messageWithSender = await Message.findByPk(message.id, {
include: [{ include: [{
model: User, model: User,
as: 'sender', 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); const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Failed to send message notification email", { reqLogger.error("Failed to send message notification email", {
error: emailError.message, error: emailError.message,
stack: emailError.stack,
messageId: message.id, messageId: message.id,
receiverId: receiverId receiverId: receiverId
}); });
@@ -307,14 +314,14 @@ router.post('/', authenticateToken, uploadMessageImage, async (req, res) => {
error: error.message, error: error.message,
stack: error.stack, stack: error.stack,
senderId: req.user.id, 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 // Mark message as read
router.put('/:id/read', authenticateToken, async (req, res) => { router.put('/:id/read', authenticateToken, async (req, res, next) => {
try { try {
const message = await Message.findOne({ const message = await Message.findOne({
where: { where: {
@@ -354,12 +361,12 @@ router.put('/:id/read', authenticateToken, async (req, res) => {
userId: req.user.id, userId: req.user.id,
messageId: req.params.id messageId: req.params.id
}); });
res.status(500).json({ error: error.message }); next(error);
} }
}); });
// Get unread message count // Get unread message count
router.get('/unread/count', authenticateToken, async (req, res) => { router.get('/unread/count', authenticateToken, async (req, res, next) => {
try { try {
const count = await Message.count({ const count = await Message.count({
where: { where: {
@@ -381,54 +388,7 @@ router.get('/unread/count', authenticateToken, async (req, res) => {
stack: error.stack, stack: error.stack,
userId: req.user.id userId: req.user.id
}); });
res.status(500).json({ error: error.message }); next(error);
}
});
// 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' });
} }
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -2,11 +2,13 @@ const express = require("express");
const { authenticateToken, requireVerifiedEmail } = require("../middleware/auth"); const { authenticateToken, requireVerifiedEmail } = require("../middleware/auth");
const { User, Item } = require("../models"); const { User, Item } = require("../models");
const StripeService = require("../services/stripeService"); const StripeService = require("../services/stripeService");
const StripeWebhookService = require("../services/stripeWebhookService");
const emailServices = require("../services/email");
const logger = require("../utils/logger"); const logger = require("../utils/logger");
const router = express.Router(); const router = express.Router();
// Get checkout session status // Get checkout session status
router.get("/checkout-session/:sessionId", async (req, res) => { router.get("/checkout-session/:sessionId", async (req, res, next) => {
try { try {
const { sessionId } = req.params; const { sessionId } = req.params;
@@ -32,14 +34,14 @@ router.get("/checkout-session/:sessionId", async (req, res) => {
reqLogger.error("Stripe checkout session retrieval failed", { reqLogger.error("Stripe checkout session retrieval failed", {
error: error.message, error: error.message,
stack: error.stack, stack: error.stack,
sessionId: sessionId, sessionId: req.params.sessionId,
}); });
res.status(500).json({ error: error.message }); next(error);
} }
}); });
// Create connected account // Create connected account
router.post("/accounts", authenticateToken, requireVerifiedEmail, async (req, res) => { router.post("/accounts", authenticateToken, requireVerifiedEmail, async (req, res, next) => {
try { try {
const user = await User.findByPk(req.user.id); const user = await User.findByPk(req.user.id);
@@ -82,14 +84,15 @@ router.post("/accounts", authenticateToken, requireVerifiedEmail, async (req, re
stack: error.stack, stack: error.stack,
userId: req.user.id, userId: req.user.id,
}); });
res.status(500).json({ error: error.message }); next(error);
} }
}); });
// Generate onboarding link // 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 { try {
const user = await User.findByPk(req.user.id); user = await User.findByPk(req.user.id);
if (!user || !user.stripeConnectedAccountId) { if (!user || !user.stripeConnectedAccountId) {
return res.status(400).json({ error: "No connected account found" }); 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, userId: req.user.id,
stripeConnectedAccountId: user?.stripeConnectedAccountId, stripeConnectedAccountId: user?.stripeConnectedAccountId,
}); });
res.status(500).json({ error: error.message }); next(error);
} }
}); });
// Get account status // Create account session for embedded onboarding
router.get("/account-status", authenticateToken, async (req, res) => { router.post("/account-sessions", authenticateToken, requireVerifiedEmail, async (req, res, next) => {
let user = null;
try { 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) { if (!user || !user.stripeConnectedAccountId) {
return res.status(400).json({ error: "No connected account found" }); 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, payoutsEnabled: accountStatus.payouts_enabled,
}); });
// Reconciliation: Compare fetched status with stored User fields
const previousPayoutsEnabled = user.stripePayoutsEnabled;
const currentPayoutsEnabled = accountStatus.payouts_enabled;
const requirements = accountStatus.requirements || {};
// Check if status has changed and needs updating
const statusChanged =
previousPayoutsEnabled !== currentPayoutsEnabled ||
JSON.stringify(user.stripeRequirementsCurrentlyDue || []) !==
JSON.stringify(requirements.currently_due || []);
if (statusChanged) {
reqLogger.info("Reconciling account status from API call", {
userId: req.user.id,
previousPayoutsEnabled,
currentPayoutsEnabled,
previousCurrentlyDue: user.stripeRequirementsCurrentlyDue?.length || 0,
newCurrentlyDue: requirements.currently_due?.length || 0,
});
// Update user with current status
await user.update({
stripePayoutsEnabled: currentPayoutsEnabled,
stripeRequirementsCurrentlyDue: requirements.currently_due || [],
stripeRequirementsPastDue: requirements.past_due || [],
stripeDisabledReason: requirements.disabled_reason || null,
stripeRequirementsLastUpdated: new Date(),
});
// If payouts just became disabled (true -> false), send notification
if (!currentPayoutsEnabled && previousPayoutsEnabled) {
reqLogger.warn("Payouts disabled detected during reconciliation", {
userId: req.user.id,
disabledReason: requirements.disabled_reason,
});
try {
const disabledReason = StripeWebhookService.formatDisabledReason(
requirements.disabled_reason
);
await emailServices.payment.sendPayoutsDisabledEmail(user.email, {
ownerName: user.firstName || user.lastName,
disabledReason,
});
reqLogger.info("Sent payouts disabled email during reconciliation", {
userId: req.user.id,
});
} catch (emailError) {
reqLogger.error("Failed to send payouts disabled email", {
userId: req.user.id,
error: emailError.message,
});
}
}
}
res.json({ res.json({
accountId: accountStatus.id, accountId: accountStatus.id,
detailsSubmitted: accountStatus.details_submitted, detailsSubmitted: accountStatus.details_submitted,
@@ -168,7 +265,7 @@ router.get("/account-status", authenticateToken, async (req, res) => {
userId: req.user.id, userId: req.user.id,
stripeConnectedAccountId: user?.stripeConnectedAccountId, stripeConnectedAccountId: user?.stripeConnectedAccountId,
}); });
res.status(500).json({ error: error.message }); next(error);
} }
}); });
@@ -177,11 +274,12 @@ router.post(
"/create-setup-checkout-session", "/create-setup-checkout-session",
authenticateToken, authenticateToken,
requireVerifiedEmail, requireVerifiedEmail,
async (req, res) => { async (req, res, next) => {
let user = null;
try { try {
const { rentalData } = req.body; const { rentalData } = req.body;
const user = await User.findByPk(req.user.id); user = await User.findByPk(req.user.id);
if (!user) { if (!user) {
return res.status(404).json({ error: "User not found" }); return res.status(404).json({ error: "User not found" });
@@ -238,7 +336,7 @@ router.post(
userId: req.user.id, userId: req.user.id,
stripeCustomerId: user?.stripeCustomerId, stripeCustomerId: user?.stripeCustomerId,
}); });
res.status(500).json({ error: error.message }); next(error);
} }
} }
); );

View 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
View File

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

230
backend/routes/upload.js Normal file
View 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;

View File

@@ -1,14 +1,71 @@
const express = require('express'); const express = require('express');
const { User, UserAddress } = require('../models'); // Import from models/index.js to get models with associations const { User, UserAddress } = require('../models'); // Import from models/index.js to get models with associations
const { authenticateToken } = require('../middleware/auth'); const { authenticateToken, optionalAuth, requireAdmin } = require('../middleware/auth');
const { uploadProfileImage } = require('../middleware/upload'); const { validateCoordinatesBody, validatePasswordChange, handleValidationErrors, sanitizeInput } = require('../middleware/validation');
const { requireStepUpAuth } = require('../middleware/stepUpAuth');
const { csrfProtection } = require('../middleware/csrf');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const userService = require('../services/UserService'); const userService = require('../services/UserService');
const fs = require('fs').promises; const emailServices = require('../services/email');
const path = require('path'); const { validateS3Keys } = require('../utils/s3KeyValidator');
const { IMAGE_LIMITS } = require('../config/imageLimits');
const router = express.Router(); 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 { try {
const user = await User.findByPk(req.user.id, { const user = await User.findByPk(req.user.id, {
attributes: { exclude: ['password'] } attributes: { exclude: ['password'] }
@@ -27,12 +84,12 @@ router.get('/profile', authenticateToken, async (req, res) => {
stack: error.stack, stack: error.stack,
userId: req.user.id userId: req.user.id
}); });
res.status(500).json({ error: error.message }); next(error);
} }
}); });
// Address routes (must come before /:id route) // Address routes (must come before /:id route)
router.get('/addresses', authenticateToken, async (req, res) => { router.get('/addresses', authenticateToken, async (req, res, next) => {
try { try {
const addresses = await UserAddress.findAll({ const addresses = await UserAddress.findAll({
where: { userId: req.user.id }, where: { userId: req.user.id },
@@ -52,13 +109,15 @@ router.get('/addresses', authenticateToken, async (req, res) => {
stack: error.stack, stack: error.stack,
userId: req.user.id 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 { 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); res.status(201).json(address);
} catch (error) { } catch (error) {
@@ -69,13 +128,15 @@ router.post('/addresses', authenticateToken, async (req, res) => {
userId: req.user.id, userId: req.user.id,
addressData: logger.sanitize(req.body) 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 { 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); res.json(address);
} catch (error) { } catch (error) {
@@ -88,14 +149,14 @@ router.put('/addresses/:id', authenticateToken, async (req, res) => {
}); });
if (error.message === 'Address not found') { 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 { try {
await userService.deleteUserAddress(req.user.id, req.params.id); 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') { 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) // User availability routes (must come before /:id route)
router.get('/availability', authenticateToken, async (req, res) => { router.get('/availability', authenticateToken, async (req, res, next) => {
try { try {
const user = await User.findByPk(req.user.id, { const user = await User.findByPk(req.user.id, {
attributes: ['defaultAvailableAfter', 'defaultAvailableBefore', 'defaultSpecifyTimesPerDay', 'defaultWeeklyTimes'] attributes: ['defaultAvailableAfter', 'defaultAvailableBefore', 'defaultSpecifyTimesPerDay', 'defaultWeeklyTimes']
@@ -130,11 +191,11 @@ router.get('/availability', authenticateToken, async (req, res) => {
weeklyTimes: user.defaultWeeklyTimes weeklyTimes: user.defaultWeeklyTimes
}); });
} catch (error) { } 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 { try {
const { generalAvailableAfter, generalAvailableBefore, specifyTimesPerDay, weeklyTimes } = req.body; const { generalAvailableAfter, generalAvailableBefore, specifyTimesPerDay, weeklyTimes } = req.body;
@@ -149,14 +210,24 @@ router.put('/availability', authenticateToken, async (req, res) => {
res.json({ message: 'Availability updated successfully' }); res.json({ message: 'Availability updated successfully' });
} catch (error) { } 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 { try {
const isAdmin = req.user?.role === 'admin';
// Base attributes to exclude
const excludedAttributes = ['password', 'email', 'phone', 'address', 'verificationToken', 'passwordResetToken'];
// If not admin, also exclude ban-related fields
if (!isAdmin) {
excludedAttributes.push('isBanned', 'bannedAt', 'bannedBy', 'banReason');
}
const user = await User.findByPk(req.params.id, { const user = await User.findByPk(req.params.id, {
attributes: { exclude: ['password', 'email', 'phone', 'address'] } attributes: { exclude: excludedAttributes }
}); });
if (!user) { if (!user) {
@@ -165,7 +236,8 @@ router.get('/:id', async (req, res) => {
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Public user profile fetched", { reqLogger.info("Public user profile fetched", {
requestedUserId: req.params.id requestedUserId: req.params.id,
viewerIsAdmin: isAdmin
}); });
res.json(user); res.json(user);
@@ -176,84 +248,219 @@ router.get('/:id', async (req, res) => {
stack: error.stack, stack: error.stack,
requestedUserId: req.params.id 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 { 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 // 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); res.json(updatedUser);
} catch (error) { } catch (error) {
console.error('Profile update error:', error); logger.error('Profile update error', { error });
res.status(500).json({ next(error);
error: error.message,
details: error.errors ? error.errors.map(e => ({ field: e.path, message: e.message })) : undefined
});
} }
}); });
// Upload profile image endpoint // Admin: Ban a user
router.post('/profile/image', authenticateToken, (req, res) => { router.post('/admin/:id/ban', authenticateToken, requireAdmin, async (req, res, next) => {
uploadProfileImage(req, res, async (err) => { try {
if (err) { const { reason } = req.body;
const reqLogger = logger.withRequestId(req.id); const targetUserId = req.params.id;
reqLogger.error("Profile image upload error", {
error: err.message, // Validate reason is provided
userId: req.user.id if (!reason || !reason.trim()) {
}); return res.status(400).json({ error: "Ban reason is required" });
return res.status(400).json({ error: err.message });
} }
if (!req.file) { // Prevent banning yourself
return res.status(400).json({ error: 'No file uploaded' }); 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 { try {
// Delete old profile image if exists const emailServices = require("../services/email");
const user = await User.findByPk(req.user.id); await emailServices.userEngagement.sendUserBannedNotification(
if (user.profileImage) { targetUser,
const oldImagePath = path.join(__dirname, '../uploads/profiles', user.profileImage); req.user,
try { reason.trim()
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 reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Profile image uploaded successfully", { reqLogger.info("User ban notification email sent", {
userId: req.user.id, bannedUserId: targetUserId,
filename: req.file.filename adminId: req.user.id
}); });
} catch (emailError) {
res.json({ // Log but don't fail the ban operation
message: 'Profile image uploaded successfully',
filename: req.file.filename,
imageUrl: `/uploads/profiles/${req.file.filename}`
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Profile image database update failed", { reqLogger.error('Failed to send user ban notification email', {
error: error.message, error: emailError.message,
stack: error.stack, 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 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; module.exports = router;

View File

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

View File

@@ -1,5 +1,5 @@
// Load environment-specific config // Load environment-specific config
const env = process.env.NODE_ENV || "dev"; const env = process.env.NODE_ENV;
const envFile = `.env.${env}`; const envFile = `.env.${env}`;
require("dotenv").config({ require("dotenv").config({
@@ -25,14 +25,16 @@ const rentalRoutes = require("./routes/rentals");
const messageRoutes = require("./routes/messages"); const messageRoutes = require("./routes/messages");
const forumRoutes = require("./routes/forum"); const forumRoutes = require("./routes/forum");
const stripeRoutes = require("./routes/stripe"); const stripeRoutes = require("./routes/stripe");
const stripeWebhookRoutes = require("./routes/stripeWebhooks");
const mapsRoutes = require("./routes/maps"); const mapsRoutes = require("./routes/maps");
const conditionCheckRoutes = require("./routes/conditionChecks"); const conditionCheckRoutes = require("./routes/conditionChecks");
const feedbackRoutes = require("./routes/feedback"); 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 emailServices = require("./services/email");
const s3Service = require("./services/s3Service");
// Socket.io setup // Socket.io setup
const { authenticateSocket } = require("./sockets/socketAuth"); const { authenticateSocket } = require("./sockets/socketAuth");
@@ -44,7 +46,7 @@ const server = http.createServer(app);
// Initialize Socket.io with CORS // Initialize Socket.io with CORS
const io = new Server(server, { const io = new Server(server, {
cors: { cors: {
origin: process.env.FRONTEND_URL || "http://localhost:3000", origin: process.env.FRONTEND_URL,
credentials: true, credentials: true,
methods: ["GET", "POST"], methods: ["GET", "POST"],
}, },
@@ -66,6 +68,7 @@ const {
addRequestId, addRequestId,
sanitizeError, sanitizeError,
} = require("./middleware/security"); } = require("./middleware/security");
const { sanitizeInput } = require("./middleware/validation");
const { generalLimiter } = require("./middleware/rateLimiter"); const { generalLimiter } = require("./middleware/rateLimiter");
const errorLogger = require("./middleware/errorLogger"); const errorLogger = require("./middleware/errorLogger");
const apiLogger = require("./middleware/apiLogger"); const apiLogger = require("./middleware/apiLogger");
@@ -90,7 +93,7 @@ app.use(
frameSrc: ["'self'", "https://accounts.google.com"], frameSrc: ["'self'", "https://accounts.google.com"],
}, },
}, },
}) }),
); );
// Cookie parser for CSRF // Cookie parser for CSRF
@@ -105,10 +108,11 @@ app.use("/api/", apiLogger);
// CORS with security settings (must come BEFORE rate limiter to ensure headers on all responses) // CORS with security settings (must come BEFORE rate limiter to ensure headers on all responses)
app.use( app.use(
cors({ cors({
origin: process.env.FRONTEND_URL || "http://localhost:3000", origin: process.env.FRONTEND_URL,
credentials: true, credentials: true,
optionsSuccessStatus: 200, optionsSuccessStatus: 200,
}) exposedHeaders: ["X-CSRF-Token"],
}),
); );
// General rate limiting for all routes // General rate limiting for all routes
@@ -122,31 +126,34 @@ app.use(
// Store raw body for webhook verification // Store raw body for webhook verification
req.rawBody = buf; req.rawBody = buf;
}, },
}) }),
); );
app.use( app.use(
bodyParser.urlencoded({ bodyParser.urlencoded({
extended: true, extended: true,
limit: "1mb", limit: "1mb",
parameterLimit: 100, // Limit number of parameters parameterLimit: 100, // Limit number of parameters
}) }),
); );
// Serve static files from uploads directory with CORS headers // Apply input sanitization to all API routes (XSS prevention)
app.use( app.use("/api/", sanitizeInput);
"/uploads",
helmet.crossOriginResourcePolicy({ policy: "cross-origin" }), // Health check endpoints (no auth, no rate limiting)
express.static(path.join(__dirname, "uploads")) 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) // Public routes (no alpha access required)
app.use("/api/alpha", alphaRoutes); app.use("/api/alpha", alphaRoutes);
app.use("/api/auth", authRoutes); // Auth has its own alpha checks in registration app.use("/api/auth", authRoutes); // Auth has its own alpha checks in registration
app.use("/api/2fa", twoFactorRoutes); // 2FA routes require authentication (handled in router)
// Health check endpoint
app.get("/", (req, res) => {
res.json({ message: "CommunityRentals.App API is running!" });
});
// Protected routes (require alpha access) // Protected routes (require alpha access)
app.use("/api/users", requireAlphaAccess, userRoutes); 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/maps", requireAlphaAccess, mapsRoutes);
app.use("/api/condition-checks", requireAlphaAccess, conditionCheckRoutes); app.use("/api/condition-checks", requireAlphaAccess, conditionCheckRoutes);
app.use("/api/feedback", requireAlphaAccess, feedbackRoutes); app.use("/api/feedback", requireAlphaAccess, feedbackRoutes);
app.use("/api/upload", requireAlphaAccess, uploadRoutes);
// Error handling middleware (must be last) // Error handling middleware (must be last)
app.use(errorLogger); app.use(errorLogger);
app.use(sanitizeError); app.use(sanitizeError);
const PORT = process.env.PORT || 5000; const PORT = process.env.PORT;
const { checkPendingMigrations } = require("./utils/checkMigrations"); const { checkPendingMigrations } = require("./utils/checkMigrations");
@@ -177,7 +185,7 @@ sequelize
if (pendingMigrations.length > 0) { if (pendingMigrations.length > 0) {
logger.error( logger.error(
`Found ${pendingMigrations.length} pending migration(s). Please run 'npm run db:migrate'`, `Found ${pendingMigrations.length} pending migration(s). Please run 'npm run db:migrate'`,
{ pendingMigrations } { pendingMigrations },
); );
process.exit(1); process.exit(1);
} }
@@ -194,24 +202,29 @@ sequelize
}); });
// Fail fast - don't start server if email templates can't load // Fail fast - don't start server if email templates can't load
if (env === "prod" || env === "production") { if (env === "prod" || env === "production") {
logger.error("Cannot start server without email services in production"); logger.error(
"Cannot start server without email services in production",
);
process.exit(1); process.exit(1);
} else { } 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 // Initialize S3 service for image uploads
const payoutJobs = PayoutProcessor.startScheduledPayouts(); try {
logger.info("Payout processor started"); s3Service.initialize();
logger.info("S3 service initialized successfully");
// Start the rental status job } catch (err) {
const rentalStatusJobs = RentalStatusJob.startScheduledStatusUpdates(); logger.error("Failed to initialize S3 service", {
logger.info("Rental status job started"); error: err.message,
stack: err.stack,
// Start the condition check reminder job });
const conditionCheckJobs = ConditionCheckReminderJob.startScheduledReminders(); logger.error("Cannot start server without S3 service in production");
logger.info("Condition check reminder job started"); process.exit(1);
}
server.listen(PORT, () => { server.listen(PORT, () => {
logger.info(`Server is running on port ${PORT}`, { logger.info(`Server is running on port ${PORT}`, {

View File

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

View File

@@ -89,6 +89,7 @@ class UserService {
"Failed to send personal information changed notification", "Failed to send personal information changed notification",
{ {
error: emailError.message, error: emailError.message,
stack: emailError.stack,
userId: user.id, userId: user.id,
email: user.email, email: user.email,
changedFields, changedFields,
@@ -138,6 +139,7 @@ class UserService {
} catch (emailError) { } catch (emailError) {
logger.error("Failed to send notification for address creation", { logger.error("Failed to send notification for address creation", {
error: emailError.message, error: emailError.message,
stack: emailError.stack,
userId: user.id, userId: user.id,
addressId: address.id, addressId: address.id,
}); });
@@ -181,6 +183,7 @@ class UserService {
} catch (emailError) { } catch (emailError) {
logger.error("Failed to send notification for address update", { logger.error("Failed to send notification for address update", {
error: emailError.message, error: emailError.message,
stack: emailError.stack,
userId, userId,
addressId: address.id, addressId: address.id,
}); });
@@ -223,6 +226,7 @@ class UserService {
} catch (emailError) { } catch (emailError) {
logger.error("Failed to send notification for address deletion", { logger.error("Failed to send notification for address deletion", {
error: emailError.message, error: emailError.message,
stack: emailError.stack,
userId, userId,
addressId, addressId,
}); });

View File

@@ -1,5 +1,6 @@
const { ConditionCheck, Rental, User } = require("../models"); const { ConditionCheck, Rental, User } = require("../models");
const { Op } = require("sequelize"); const { Op } = require("sequelize");
const { isActive } = require("../utils/rentalStatus");
class ConditionCheckService { class ConditionCheckService {
/** /**
@@ -70,7 +71,7 @@ class ConditionCheckService {
canSubmit = canSubmit =
now >= timeWindow.start && now >= timeWindow.start &&
now <= timeWindow.end && now <= timeWindow.end &&
rental.status === "active"; isActive(rental);
break; break;
case "rental_end_renter": case "rental_end_renter":
@@ -80,7 +81,7 @@ class ConditionCheckService {
canSubmit = canSubmit =
now >= timeWindow.start && now >= timeWindow.start &&
now <= timeWindow.end && now <= timeWindow.end &&
rental.status === "active"; isActive(rental);
break; break;
case "post_rental_owner": case "post_rental_owner":
@@ -116,7 +117,7 @@ class ConditionCheckService {
* @param {string} rentalId - Rental ID * @param {string} rentalId - Rental ID
* @param {string} checkType - Type of check * @param {string} checkType - Type of check
* @param {string} userId - User submitting the 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 * @param {string} notes - Optional notes
* @returns {Object} - Created condition check * @returns {Object} - Created condition check
*/ */
@@ -124,7 +125,7 @@ class ConditionCheckService {
rentalId, rentalId,
checkType, checkType,
userId, userId,
photos = [], imageFilenames = [],
notes = null notes = null
) { ) {
// Validate the check // Validate the check
@@ -139,7 +140,7 @@ class ConditionCheckService {
} }
// Validate photos (basic validation) // Validate photos (basic validation)
if (photos.length > 20) { if (imageFilenames.length > 20) {
throw new Error("Maximum 20 photos allowed per condition check"); throw new Error("Maximum 20 photos allowed per condition check");
} }
@@ -147,7 +148,7 @@ class ConditionCheckService {
rentalId, rentalId,
checkType, checkType,
submittedBy: userId, submittedBy: userId,
photos, imageFilenames,
notes, notes,
}); });
@@ -155,18 +156,26 @@ class ConditionCheckService {
} }
/** /**
* Get all condition checks for a rental * Get all condition checks for multiple rentals (batch)
* @param {string} rentalId - Rental ID * @param {Array<string>} rentalIds - Array of Rental IDs
* @returns {Array} - Array of condition checks with user info * @returns {Array} - Array of condition checks with user info
*/ */
static async getConditionChecks(rentalId) { static async getConditionChecksForRentals(rentalIds) {
if (!rentalIds || rentalIds.length === 0) {
return [];
}
const checks = await ConditionCheck.findAll({ const checks = await ConditionCheck.findAll({
where: { rentalId }, where: {
rentalId: {
[Op.in]: rentalIds,
},
},
include: [ include: [
{ {
model: User, model: User,
as: "submittedByUser", as: "submittedByUser",
attributes: ["id", "username", "firstName", "lastName"], attributes: ["id", "firstName", "lastName"],
}, },
], ],
order: [["submittedAt", "ASC"]], order: [["submittedAt", "ASC"]],
@@ -175,119 +184,24 @@ class ConditionCheckService {
return checks; return checks;
} }
/**
* Get condition check timeline for a rental
* @param {string} rentalId - Rental ID
* @returns {Object} - Timeline showing what checks are available/completed
*/
static async getConditionCheckTimeline(rentalId) {
const rental = await Rental.findByPk(rentalId);
if (!rental) {
throw new Error("Rental not found");
}
const existingChecks = await ConditionCheck.findAll({
where: { rentalId },
include: [
{
model: User,
as: "submittedByUser",
attributes: ["id", "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 * Get available condition checks for a user
* @param {string} userId - User ID * @param {string} userId - User ID
* @param {Array<string>} rentalIds - Array of rental IDs to check
* @returns {Array} - Array of available condition checks * @returns {Array} - Array of available condition checks
*/ */
static async getAvailableChecks(userId) { static async getAvailableChecks(userId, rentalIds) {
if (!rentalIds || rentalIds.length === 0) {
return [];
}
const now = new Date(); const now = new Date();
const twentyFourHours = 24 * 60 * 60 * 1000; const twentyFourHours = 24 * 60 * 60 * 1000;
// Find rentals where user is owner or renter // Find specified rentals where user is owner or renter
const rentals = await Rental.findAll({ const rentals = await Rental.findAll({
where: { where: {
id: { [Op.in]: rentalIds },
[Op.or]: [{ ownerId: userId }, { renterId: userId }], [Op.or]: [{ ownerId: userId }, { renterId: userId }],
status: { status: {
[Op.in]: ["confirmed", "active", "completed"], [Op.in]: ["confirmed", "active", "completed"],

View File

@@ -1,6 +1,7 @@
const { Rental, Item, ConditionCheck, User } = require("../models"); const { Rental, Item, ConditionCheck, User } = require("../models");
const LateReturnService = require("./lateReturnService"); const LateReturnService = require("./lateReturnService");
const emailServices = require("./email"); const emailServices = require("./email");
const { isActive } = require("../utils/rentalStatus");
class DamageAssessmentService { class DamageAssessmentService {
/** /**
@@ -19,7 +20,7 @@ class DamageAssessmentService {
replacementCost, replacementCost,
proofOfOwnership, proofOfOwnership,
actualReturnDateTime, actualReturnDateTime,
photos = [], imageFilenames = [],
} = damageInfo; } = damageInfo;
const rental = await Rental.findByPk(rentalId, { const rental = await Rental.findByPk(rentalId, {
@@ -34,7 +35,7 @@ class DamageAssessmentService {
throw new Error("Only the item owner can report damage"); 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"); throw new Error("Can only assess damage for active rentals");
} }
@@ -98,7 +99,7 @@ class DamageAssessmentService {
needsReplacement, needsReplacement,
replacementCost: needsReplacement ? parseFloat(replacementCost) : null, replacementCost: needsReplacement ? parseFloat(replacementCost) : null,
proofOfOwnership: proofOfOwnership || [], proofOfOwnership: proofOfOwnership || [],
photos, imageFilenames,
assessedAt: new Date(), assessedAt: new Date(),
assessedBy: userId, assessedBy: userId,
feeCalculation, feeCalculation,

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
const fs = require("fs").promises; const fs = require("fs").promises;
const path = require("path"); const path = require("path");
const logger = require("../../../utils/logger");
const { escapeHtml } = require("./emailUtils");
/** /**
* TemplateManager handles loading, caching, and rendering email templates * TemplateManager handles loading, caching, and rendering email templates
@@ -9,6 +11,14 @@ const path = require("path");
* - Rendering templates with variable substitution * - Rendering templates with variable substitution
* - Providing fallback templates when files can't be loaded * - Providing fallback templates when files can't be loaded
*/ */
// Critical templates that must be preloaded at startup for auth flows
const CRITICAL_TEMPLATES = [
"emailVerificationToUser",
"passwordResetToUser",
"passwordChangedToUser",
"personalInfoChangedToUser",
];
class TemplateManager { class TemplateManager {
constructor() { constructor() {
// Singleton pattern - return existing instance if already created // Singleton pattern - return existing instance if already created
@@ -16,15 +26,76 @@ class TemplateManager {
return TemplateManager.instance; return TemplateManager.instance;
} }
this.templates = new Map(); this.templates = new Map(); // Cached template content
this.templateNames = new Set(); // Discovered template names
this.initialized = false; this.initialized = false;
this.initializationPromise = null; this.initializationPromise = null;
this.templatesDir = path.join(
__dirname,
"..",
"..",
"..",
"templates",
"emails"
);
TemplateManager.instance = this; TemplateManager.instance = this;
} }
/** /**
* Initialize the template manager by loading all email templates * Discover all available templates by scanning the templates directory
* Only reads filenames, not content (for fast startup)
* @returns {Promise<void>}
*/
async discoverTemplates() {
try {
const files = await fs.readdir(this.templatesDir);
for (const file of files) {
if (file.endsWith(".html")) {
this.templateNames.add(file.replace(".html", ""));
}
}
logger.info("Discovered email templates", {
count: this.templateNames.size,
});
} catch (error) {
logger.error("Failed to discover email templates", {
templatesDir: this.templatesDir,
error,
});
throw error;
}
}
/**
* Load a single template from disk (lazy loading)
* @param {string} templateName - Name of the template (without .html extension)
* @returns {Promise<string>} Template content
*/
async loadTemplate(templateName) {
// Return cached template if already loaded
if (this.templates.has(templateName)) {
return this.templates.get(templateName);
}
const templatePath = path.join(this.templatesDir, `${templateName}.html`);
try {
const content = await fs.readFile(templatePath, "utf-8");
this.templates.set(templateName, content);
logger.debug("Loaded template", { templateName });
return content;
} catch (error) {
logger.error("Failed to load template", {
templateName,
templatePath,
error,
});
throw error;
}
}
/**
* Initialize the template manager by discovering templates and preloading critical ones
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async initialize() { async initialize() {
@@ -38,116 +109,35 @@ class TemplateManager {
// Start initialization and store the promise // Start initialization and store the promise
this.initializationPromise = (async () => { this.initializationPromise = (async () => {
await this.loadEmailTemplates(); // Discover all available templates (fast - only reads filenames)
this.initialized = true; await this.discoverTemplates();
console.log("Email Template Manager initialized successfully");
})();
return this.initializationPromise; // Preload critical templates for auth flows
} const missingCritical = [];
for (const templateName of CRITICAL_TEMPLATES) {
/** if (!this.templateNames.has(templateName)) {
* Load all email templates from disk into memory missingCritical.push(templateName);
* @returns {Promise<void>} } else {
*/ await this.loadTemplate(templateName);
async loadEmailTemplates() {
const templatesDir = path.join(__dirname, "..", "..", "..", "templates", "emails");
// Critical templates that must load for the app to function
const criticalTemplates = [
"emailVerificationToUser.html",
"passwordResetToUser.html",
"passwordChangedToUser.html",
"personalInfoChangedToUser.html",
];
try {
const templateFiles = [
"conditionCheckReminderToUser.html",
"rentalConfirmationToUser.html",
"emailVerificationToUser.html",
"passwordResetToUser.html",
"passwordChangedToUser.html",
"personalInfoChangedToUser.html",
"lateReturnToCS.html",
"damageReportToCS.html",
"lostItemToCS.html",
"rentalRequestToOwner.html",
"rentalRequestConfirmationToRenter.html",
"rentalCancellationConfirmationToUser.html",
"rentalCancellationNotificationToUser.html",
"rentalDeclinedToRenter.html",
"rentalApprovalConfirmationToOwner.html",
"rentalCompletionThankYouToRenter.html",
"rentalCompletionCongratsToOwner.html",
"payoutReceivedToOwner.html",
"firstListingCelebrationToOwner.html",
"itemDeletionToOwner.html",
"alphaInvitationToUser.html",
"feedbackConfirmationToUser.html",
"feedbackNotificationToAdmin.html",
"newMessageToUser.html",
"forumCommentToPostAuthor.html",
"forumReplyToCommentAuthor.html",
"forumAnswerAcceptedToCommentAuthor.html",
"forumThreadActivityToParticipant.html",
"forumPostClosed.html",
"forumItemRequestNotification.html",
"forumPostDeletionToAuthor.html",
"forumCommentDeletionToAuthor.html",
];
const failedTemplates = [];
for (const templateFile of templateFiles) {
try {
const templatePath = path.join(templatesDir, templateFile);
const templateContent = await fs.readFile(templatePath, "utf-8");
const templateName = path.basename(templateFile, ".html");
this.templates.set(templateName, templateContent);
console.log(`✓ Loaded template: ${templateName}`);
} catch (error) {
console.error(
`✗ Failed to load template ${templateFile}:`,
error.message
);
console.error(
` Template path: ${path.join(templatesDir, templateFile)}`
);
failedTemplates.push(templateFile);
} }
} }
console.log( if (missingCritical.length > 0) {
`Loaded ${this.templates.size} of ${templateFiles.length} email templates`
);
// Check if critical templates are missing
const missingCriticalTemplates = criticalTemplates.filter(
(template) => !this.templates.has(path.basename(template, ".html"))
);
if (missingCriticalTemplates.length > 0) {
const error = new Error( const error = new Error(
`Critical email templates failed to load: ${missingCriticalTemplates.join(", ")}` `Critical email templates not found: ${missingCritical.join(", ")}`
); );
error.missingTemplates = missingCriticalTemplates; error.missingTemplates = missingCritical;
throw error; throw error;
} }
// Warn if non-critical templates failed this.initialized = true;
if (failedTemplates.length > 0) { logger.info("Email Template Manager initialized successfully", {
console.warn( discovered: this.templateNames.size,
`⚠️ Non-critical templates failed to load: ${failedTemplates.join(", ")}` preloaded: CRITICAL_TEMPLATES.length,
); });
console.warn("These templates will use fallback versions"); })();
}
} catch (error) { return this.initializationPromise;
console.error("Failed to load email templates:", error);
console.error("Templates directory:", templatesDir);
console.error("Error stack:", error.stack);
throw error; // Re-throw to fail server startup
}
} }
/** /**
@@ -159,342 +149,94 @@ class TemplateManager {
async renderTemplate(templateName, variables = {}) { async renderTemplate(templateName, variables = {}) {
// Ensure service is initialized before rendering // Ensure service is initialized before rendering
if (!this.initialized) { if (!this.initialized) {
console.log(`Template manager not initialized yet, initializing now...`); logger.debug("Template manager not initialized yet, initializing now...");
await this.initialize(); await this.initialize();
} }
let template = this.templates.get(templateName); let template;
if (!template) { // Check if template exists in our discovered templates
console.error(`Template not found: ${templateName}`); if (this.templateNames.has(templateName)) {
console.error( // Lazy load the template if not already cached
`Available templates: ${Array.from(this.templates.keys()).join(", ")}` template = await this.loadTemplate(templateName);
);
console.error(`Stack trace:`, new Error().stack);
console.log(`Using fallback template for: ${templateName}`);
template = this.getFallbackTemplate(templateName);
} else { } else {
console.log(`Template found: ${templateName}`); logger.error("Template not found, using fallback", {
templateName,
discoveredTemplates: Array.from(this.templateNames),
});
template = this.getFallbackTemplate(templateName);
} }
let rendered = template; let rendered = template;
try { try {
Object.keys(variables).forEach((key) => { Object.keys(variables).forEach((key) => {
// Variables ending in 'Html' or 'Section' contain trusted HTML content
// (e.g., refundSection, stripeSection, earningsSection) - don't escape these
const isTrustedHtml = key.endsWith("Html") || key.endsWith("Section");
let value = variables[key] || "";
// Escape HTML in user-provided values to prevent XSS
if (!isTrustedHtml && typeof value === "string") {
value = escapeHtml(value);
}
const regex = new RegExp(`{{${key}}}`, "g"); const regex = new RegExp(`{{${key}}}`, "g");
rendered = rendered.replace(regex, variables[key] || ""); rendered = rendered.replace(regex, value);
}); });
} catch (error) { } catch (error) {
console.error(`Error rendering template ${templateName}:`, error); logger.error("Error rendering template", {
console.error(`Stack trace:`, error.stack); templateName,
console.error(`Variables provided:`, Object.keys(variables)); variableKeys: Object.keys(variables),
error,
});
} }
return rendered; return rendered;
} }
/** /**
* Get a fallback template when the HTML file is not available * Get a generic fallback template when the HTML file is not available
* @param {string} templateName - Name of the template * This is used as a last resort when a template cannot be loaded
* @returns {string} Fallback HTML template * @param {string} templateName - Name of the template (for logging)
* @returns {string} Generic fallback HTML template
*/ */
getFallbackTemplate(templateName) { getFallbackTemplate(templateName) {
const baseTemplate = ` logger.warn("Using generic fallback template", { templateName });
return `
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{title}}</title> <title>Village Share</title>
<style> <style>
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5; } body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5; }
.container { max-width: 600px; margin: 0 auto; background-color: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } .container { max-width: 600px; margin: 0 auto; background-color: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.header { text-align: center; border-bottom: 2px solid #e9ecef; padding-bottom: 20px; margin-bottom: 30px; } .header { text-align: center; border-bottom: 2px solid #e9ecef; padding-bottom: 20px; margin-bottom: 30px; }
.logo { font-size: 24px; font-weight: bold; color: #333; } .logo { font-size: 24px; font-weight: bold; color: #333; }
.content { line-height: 1.6; color: #555; } .content { line-height: 1.6; color: #555; }
.button { display: inline-block; background-color: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; margin: 20px 0; }
.footer { margin-top: 30px; padding-top: 20px; border-top: 1px solid #e9ecef; text-align: center; font-size: 12px; color: #6c757d; } .footer { margin-top: 30px; padding-top: 20px; border-top: 1px solid #e9ecef; text-align: center; font-size: 12px; color: #6c757d; }
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
</div> </div>
<div class="content"> <div class="content">
{{content}} <p>Hi {{recipientName}},</p>
<h2>{{title}}</h2>
<p>{{message}}</p>
</div> </div>
<div class="footer"> <div class="footer">
<p>This email was sent from 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>
</div> </div>
</body> </body>
</html> </html>
`; `;
const templates = {
conditionCheckReminderToUser: baseTemplate.replace(
"{{content}}",
`
<h2>{{title}}</h2>
<p>{{message}}</p>
<p><strong>Rental Item:</strong> {{itemName}}</p>
<p><strong>Deadline:</strong> {{deadline}}</p>
<p>Please complete this condition check as soon as possible to ensure proper documentation.</p>
`
),
rentalConfirmationToUser: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{recipientName}},</p>
<h2>{{title}}</h2>
<p>{{message}}</p>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
<p>Thank you for using 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>
`
)
);
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;

View File

@@ -1,5 +1,6 @@
const EmailClient = require("../core/EmailClient"); const EmailClient = require("../core/EmailClient");
const TemplateManager = require("../core/TemplateManager"); const TemplateManager = require("../core/TemplateManager");
const logger = require("../../../utils/logger");
/** /**
* RentalFlowEmailService handles rental lifecycle flow emails * RentalFlowEmailService handles rental lifecycle flow emails
@@ -33,7 +34,7 @@ class RentalFlowEmailService {
]); ]);
this.initialized = true; this.initialized = true;
console.log("Rental Flow Email Service initialized successfully"); logger.info("Rental Flow Email Service initialized successfully");
} }
/** /**
@@ -61,12 +62,13 @@ class RentalFlowEmailService {
} }
try { try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
const approveUrl = `${frontendUrl}/owning?rentalId=${rental.id}`; const approveUrl = `${frontendUrl}/owning?rentalId=${rental.id}`;
const variables = { const variables = {
ownerName: owner.firstName, ownerName: owner.firstName,
renterName: `${renter.firstName} ${renter.lastName}`.trim() || "A renter", renterName:
`${renter.firstName} ${renter.lastName}`.trim() || "A renter",
itemName: rental.item?.name || "your item", itemName: rental.item?.name || "your item",
startDate: rental.startDateTime startDate: rental.startDateTime
? new Date(rental.startDateTime).toLocaleString("en-US", { ? new Date(rental.startDateTime).toLocaleString("en-US", {
@@ -93,16 +95,16 @@ class RentalFlowEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"rentalRequestToOwner", "rentalRequestToOwner",
variables variables,
); );
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
owner.email, owner.email,
`Rental Request for ${rental.item?.name || "Your Item"}`, `Rental Request for ${rental.item?.name || "Your Item"}`,
htmlContent htmlContent,
); );
} catch (error) { } catch (error) {
console.error("Failed to send rental request email:", error); logger.error("Failed to send rental request email", { error });
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
} }
@@ -127,7 +129,7 @@ class RentalFlowEmailService {
} }
try { try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
const viewRentalsUrl = `${frontendUrl}/renting`; const viewRentalsUrl = `${frontendUrl}/renting`;
// Determine payment message based on rental amount // Determine payment message based on rental amount
@@ -160,16 +162,18 @@ class RentalFlowEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"rentalRequestConfirmationToRenter", "rentalRequestConfirmationToRenter",
variables variables,
); );
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
renter.email, renter.email,
`Rental Request Submitted - ${rental.item?.name || "Item"}`, `Rental Request Submitted - ${rental.item?.name || "Item"}`,
htmlContent htmlContent,
); );
} catch (error) { } catch (error) {
console.error("Failed to send rental request confirmation email:", error); logger.error("Failed to send rental request confirmation email", {
error,
});
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
} }
@@ -201,7 +205,7 @@ class RentalFlowEmailService {
} }
try { try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
// Determine if Stripe setup is needed // Determine if Stripe setup is needed
const hasStripeAccount = !!owner.stripeConnectedAccountId; const hasStripeAccount = !!owner.stripeConnectedAccountId;
@@ -226,15 +230,15 @@ class RentalFlowEmailService {
<table class="info-table"> <table class="info-table">
<tr> <tr>
<th>Total Rental Amount</th> <th>Total Rental Amount</th>
<td>\\$${totalAmount.toFixed(2)}</td> <td>$${totalAmount.toFixed(2)}</td>
</tr> </tr>
<tr> <tr>
<th>Community Upkeep Fee (10%)</th> <th>Community Upkeep Fee (10%)</th>
<td>-\\$${platformFee.toFixed(2)}</td> <td>-$${platformFee.toFixed(2)}</td>
</tr> </tr>
<tr> <tr>
<th>Your Payout</th> <th>Your Payout</th>
<td class="highlight">\\$${payoutAmount.toFixed(2)}</td> <td class="highlight">$${payoutAmount.toFixed(2)}</td>
</tr> </tr>
</table> </table>
`; `;
@@ -247,8 +251,8 @@ class RentalFlowEmailService {
stripeSection = ` stripeSection = `
<div class="warning-box"> <div class="warning-box">
<p><strong>⚠️ Action Required: Set Up Your Earnings Account</strong></p> <p><strong>⚠️ Action Required: Set Up Your Earnings Account</strong></p>
<p>To receive your payout of <strong>\\$${payoutAmount.toFixed( <p>To receive your payout of <strong>$${payoutAmount.toFixed(
2 2,
)}</strong> when this rental completes, you need to set up your earnings account.</p> )}</strong> when this rental completes, you need to set up your earnings account.</p>
</div> </div>
<h2>Set Up Earnings to Get Paid</h2> <h2>Set Up Earnings to Get Paid</h2>
@@ -258,7 +262,7 @@ class RentalFlowEmailService {
<li><strong>Automatic payouts</strong> when rentals complete</li> <li><strong>Automatic payouts</strong> when rentals complete</li>
<li><strong>Secure transfers</strong> directly to your bank account</li> <li><strong>Secure transfers</strong> directly to your bank account</li>
<li><strong>Track all earnings</strong> in one dashboard</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> </ul>
<p>Setup only takes about 5 minutes and you only need to do it once.</p> <p>Setup only takes about 5 minutes and you only need to do it once.</p>
</div> </div>
@@ -273,8 +277,8 @@ class RentalFlowEmailService {
stripeSection = ` stripeSection = `
<div class="success-box"> <div class="success-box">
<p><strong>✓ Earnings Account Active</strong></p> <p><strong>✓ Earnings Account Active</strong></p>
<p>Your earnings account is set up. You'll automatically receive \\$${payoutAmount.toFixed( <p>Your earnings account is set up. You'll automatically receive $${payoutAmount.toFixed(
2 2,
)} when this rental completes.</p> )} when this rental completes.</p>
<p><a href="${frontendUrl}/earnings" style="color: #155724; text-decoration: underline;">View your earnings dashboard →</a></p> <p><a href="${frontendUrl}/earnings" style="color: #155724; text-decoration: underline;">View your earnings dashboard →</a></p>
</div> </div>
@@ -311,7 +315,7 @@ class RentalFlowEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"rentalApprovalConfirmationToOwner", "rentalApprovalConfirmationToOwner",
variables variables,
); );
const subject = `Rental Approved - ${rental.item?.name || "Your Item"}`; const subject = `Rental Approved - ${rental.item?.name || "Your Item"}`;
@@ -319,10 +323,12 @@ class RentalFlowEmailService {
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
owner.email, owner.email,
subject, subject,
htmlContent htmlContent,
); );
} catch (error) { } catch (error) {
console.error("Failed to send rental approval confirmation email:", error); logger.error("Failed to send rental approval confirmation email", {
error,
});
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
} }
@@ -349,7 +355,7 @@ class RentalFlowEmailService {
} }
try { try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
const browseItemsUrl = `${frontendUrl}/`; const browseItemsUrl = `${frontendUrl}/`;
// Determine payment message based on rental amount // Determine payment message based on rental amount
@@ -396,16 +402,16 @@ class RentalFlowEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"rentalDeclinedToRenter", "rentalDeclinedToRenter",
variables variables,
); );
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
renter.email, renter.email,
`Rental Request Declined - ${rental.item?.name || "Item"}`, `Rental Request Declined - ${rental.item?.name || "Item"}`,
htmlContent htmlContent,
); );
} catch (error) { } catch (error) {
console.error("Failed to send rental declined email:", error); logger.error("Failed to send rental declined email", { error });
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
} }
@@ -436,7 +442,7 @@ class RentalFlowEmailService {
notification, notification,
rental, rental,
recipientName = null, recipientName = null,
isRenter = false isRenter = false,
) { ) {
if (!this.initialized) { if (!this.initialized) {
await this.initialize(); await this.initialize();
@@ -531,7 +537,7 @@ class RentalFlowEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"rentalConfirmationToUser", "rentalConfirmationToUser",
variables variables,
); );
// Use clear, transactional subject line with item name // Use clear, transactional subject line with item name
@@ -539,7 +545,7 @@ class RentalFlowEmailService {
return await this.emailClient.sendEmail(userEmail, subject, htmlContent); return await this.emailClient.sendEmail(userEmail, subject, htmlContent);
} catch (error) { } catch (error) {
console.error("Failed to send rental confirmation:", error); logger.error("Failed to send rental confirmation", { error });
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
} }
@@ -600,24 +606,24 @@ class RentalFlowEmailService {
ownerNotification, ownerNotification,
rental, rental,
owner.firstName, owner.firstName,
false // isRenter = false for owner false, // isRenter = false for owner
); );
if (ownerResult.success) { if (ownerResult.success) {
console.log( logger.info("Rental confirmation email sent to owner", {
`Rental confirmation email sent to owner: ${owner.email}` email: owner.email,
); });
results.ownerEmailSent = true; results.ownerEmailSent = true;
} else { } else {
console.error( logger.error("Failed to send rental confirmation email to owner", {
`Failed to send rental confirmation email to owner (${owner.email}):`, email: owner.email,
ownerResult.error error: ownerResult.error,
); });
} }
} catch (error) { } catch (error) {
console.error( logger.error("Failed to send rental confirmation email to owner", {
`Failed to send rental confirmation email to owner (${owner.email}):`, email: owner.email,
error.message error,
); });
} }
} }
@@ -629,31 +635,30 @@ class RentalFlowEmailService {
renterNotification, renterNotification,
rental, rental,
renter.firstName, renter.firstName,
true // isRenter = true for renter (enables payment receipt) true, // isRenter = true for renter (enables payment receipt)
); );
if (renterResult.success) { if (renterResult.success) {
console.log( logger.info("Rental confirmation email sent to renter", {
`Rental confirmation email sent to renter: ${renter.email}` email: renter.email,
); });
results.renterEmailSent = true; results.renterEmailSent = true;
} else { } else {
console.error( logger.error("Failed to send rental confirmation email to renter", {
`Failed to send rental confirmation email to renter (${renter.email}):`, email: renter.email,
renterResult.error error: renterResult.error,
); });
} }
} catch (error) { } catch (error) {
console.error( logger.error("Failed to send rental confirmation email to renter", {
`Failed to send rental confirmation email to renter (${renter.email}):`, email: renter.email,
error.message error,
); });
} }
} }
} catch (error) { } catch (error) {
console.error( logger.error("Error fetching user data for rental confirmation emails", {
"Error fetching user data for rental confirmation emails:", error,
error });
);
} }
return results; return results;
@@ -692,7 +697,7 @@ class RentalFlowEmailService {
}; };
try { try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
const browseUrl = `${frontendUrl}/`; const browseUrl = `${frontendUrl}/`;
const cancelledBy = rental.cancelledBy; const cancelledBy = rental.cancelledBy;
@@ -736,7 +741,7 @@ class RentalFlowEmailService {
<div class="info-box"> <div class="info-box">
<p><strong>Full Refund Processed</strong></p> <p><strong>Full Refund Processed</strong></p>
<p>You will receive a full refund of $${refundInfo.amount.toFixed( <p>You will receive a full refund of $${refundInfo.amount.toFixed(
2 2,
)}. The refund will appear in your account within 5-10 business days.</p> )}. The refund will appear in your account within 5-10 business days.</p>
</div> </div>
<div style="text-align: center"> <div style="text-align: center">
@@ -779,7 +784,7 @@ class RentalFlowEmailService {
<div class="refund-amount">$${refundInfo.amount.toFixed(2)}</div> <div class="refund-amount">$${refundInfo.amount.toFixed(2)}</div>
<div class="info-box"> <div class="info-box">
<p><strong>Refund Amount:</strong> $${refundInfo.amount.toFixed( <p><strong>Refund Amount:</strong> $${refundInfo.amount.toFixed(
2 2,
)} (${refundPercentage}% of total)</p> )} (${refundPercentage}% of total)</p>
<p><strong>Reason:</strong> ${refundInfo.reason}</p> <p><strong>Reason:</strong> ${refundInfo.reason}</p>
<p><strong>Processing Time:</strong> Refunds typically appear within 5-10 business days.</p> <p><strong>Processing Time:</strong> Refunds typically appear within 5-10 business days.</p>
@@ -809,26 +814,27 @@ class RentalFlowEmailService {
const confirmationHtml = await this.templateManager.renderTemplate( const confirmationHtml = await this.templateManager.renderTemplate(
"rentalCancellationConfirmationToUser", "rentalCancellationConfirmationToUser",
confirmationVariables confirmationVariables,
); );
const confirmationResult = await this.emailClient.sendEmail( const confirmationResult = await this.emailClient.sendEmail(
confirmationRecipient, confirmationRecipient,
`Cancellation Confirmed - ${itemName}`, `Cancellation Confirmed - ${itemName}`,
confirmationHtml confirmationHtml,
); );
if (confirmationResult.success) { if (confirmationResult.success) {
console.log( logger.info("Cancellation confirmation email sent", {
`Cancellation confirmation email sent to ${cancelledBy}: ${confirmationRecipient}` cancelledBy,
); email: confirmationRecipient,
});
results.confirmationEmailSent = true; results.confirmationEmailSent = true;
} }
} catch (error) { } catch (error) {
console.error( logger.error("Failed to send cancellation confirmation email", {
`Failed to send cancellation confirmation email to ${cancelledBy}:`, cancelledBy,
error.message error,
); });
} }
// Send notification email to other party // Send notification email to other party
@@ -845,31 +851,29 @@ class RentalFlowEmailService {
const notificationHtml = await this.templateManager.renderTemplate( const notificationHtml = await this.templateManager.renderTemplate(
"rentalCancellationNotificationToUser", "rentalCancellationNotificationToUser",
notificationVariables notificationVariables,
); );
const notificationResult = await this.emailClient.sendEmail( const notificationResult = await this.emailClient.sendEmail(
notificationRecipient, notificationRecipient,
`Rental Cancelled - ${itemName}`, `Rental Cancelled - ${itemName}`,
notificationHtml notificationHtml,
); );
if (notificationResult.success) { if (notificationResult.success) {
console.log( logger.info("Cancellation notification email sent", {
`Cancellation notification email sent to ${ recipientType: cancelledBy === "owner" ? "renter" : "owner",
cancelledBy === "owner" ? "renter" : "owner" email: notificationRecipient,
}: ${notificationRecipient}` });
);
results.notificationEmailSent = true; results.notificationEmailSent = true;
} }
} catch (error) { } catch (error) {
console.error( logger.error("Failed to send cancellation notification email", {
`Failed to send cancellation notification email:`, error,
error.message });
);
} }
} catch (error) { } catch (error) {
console.error("Error sending cancellation emails:", error); logger.error("Error sending cancellation emails", { error });
} }
return results; return results;
@@ -904,7 +908,7 @@ class RentalFlowEmailService {
await this.initialize(); await this.initialize();
} }
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
const results = { const results = {
renterEmailSent: false, renterEmailSent: false,
ownerEmailSent: false, ownerEmailSent: false,
@@ -941,7 +945,7 @@ class RentalFlowEmailService {
<h2>Share Your Experience</h2> <h2>Share Your Experience</h2>
<div class="info-box"> <div class="info-box">
<p><strong>Help the community by leaving a review!</strong></p> <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> <ul>
<li>How was the item's condition?</li> <li>How was the item's condition?</li>
<li>Was the owner responsive and helpful?</li> <li>Was the owner responsive and helpful?</li>
@@ -956,7 +960,7 @@ class RentalFlowEmailService {
reviewSection = ` reviewSection = `
<div class="success-box"> <div class="success-box">
<p><strong>✓ Thank You for Your Review!</strong></p> <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> </div>
`; `;
} }
@@ -976,31 +980,33 @@ class RentalFlowEmailService {
const renterHtmlContent = await this.templateManager.renderTemplate( const renterHtmlContent = await this.templateManager.renderTemplate(
"rentalCompletionThankYouToRenter", "rentalCompletionThankYouToRenter",
renterVariables renterVariables,
); );
const renterResult = await this.emailClient.sendEmail( const renterResult = await this.emailClient.sendEmail(
renter.email, renter.email,
`Thank You for Returning "${rental.item?.name || "Item"}" On Time!`, `Thank You for Returning "${rental.item?.name || "Item"}" On Time!`,
renterHtmlContent renterHtmlContent,
); );
if (renterResult.success) { if (renterResult.success) {
console.log( logger.info("Rental completion thank you email sent to renter", {
`Rental completion thank you email sent to renter: ${renter.email}` email: renter.email,
); });
results.renterEmailSent = true; results.renterEmailSent = true;
} else { } else {
console.error( logger.error("Failed to send rental completion email to renter", {
`Failed to send rental completion email to renter (${renter.email}):`, email: renter.email,
renterResult.error error: renterResult.error,
); });
} }
} catch (emailError) { } catch (emailError) {
console.error( logger.error("Failed to send rental completion email to renter", {
`Failed to send rental completion email to renter (${renter.email}):`, error: emailError.message,
emailError.message stack: emailError.stack,
); renterEmail: renter.email,
rentalId: rental.id,
});
} }
// Prepare owner email // Prepare owner email
@@ -1018,19 +1024,19 @@ class RentalFlowEmailService {
<table class="info-table"> <table class="info-table">
<tr> <tr>
<th>Total Rental Amount</th> <th>Total Rental Amount</th>
<td>\\$${totalAmount.toFixed(2)}</td> <td>$${totalAmount.toFixed(2)}</td>
</tr> </tr>
<tr> <tr>
<th>Community Upkeep Fee (10%)</th> <th>Community Upkeep Fee (10%)</th>
<td>-\\$${platformFee.toFixed(2)}</td> <td>-$${platformFee.toFixed(2)}</td>
</tr> </tr>
<tr> <tr>
<th>Your Payout</th> <th>Your Payout</th>
<td class="highlight">\\$${payoutAmount.toFixed(2)}</td> <td class="highlight">$${payoutAmount.toFixed(2)}</td>
</tr> </tr>
</table> </table>
<p style="font-size: 14px; color: #6c757d;"> <p style="font-size: 14px; color: #6c757d;">
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> </p>
`; `;
} }
@@ -1042,8 +1048,8 @@ class RentalFlowEmailService {
stripeSection = ` stripeSection = `
<div class="warning-box"> <div class="warning-box">
<p><strong>⚠️ Action Required: Set Up Your Earnings Account</strong></p> <p><strong>⚠️ Action Required: Set Up Your Earnings Account</strong></p>
<p>To receive your payout of <strong>\\$${payoutAmount.toFixed( <p>To receive your payout of <strong>$${payoutAmount.toFixed(
2 2,
)}</strong>, you need to set up your earnings account.</p> )}</strong>, you need to set up your earnings account.</p>
</div> </div>
<h2>Set Up Earnings to Get Paid</h2> <h2>Set Up Earnings to Get Paid</h2>
@@ -1053,7 +1059,7 @@ class RentalFlowEmailService {
<li><strong>Automatic payouts</strong> when the rental period ends</li> <li><strong>Automatic payouts</strong> when the rental period ends</li>
<li><strong>Secure transfers</strong> directly to your bank account</li> <li><strong>Secure transfers</strong> directly to your bank account</li>
<li><strong>Track all earnings</strong> in one dashboard</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> </ul>
<p>Setup only takes about 5 minutes and you only need to do it once.</p> <p>Setup only takes about 5 minutes and you only need to do it once.</p>
</div> </div>
@@ -1067,10 +1073,11 @@ class RentalFlowEmailService {
} else if (hasStripeAccount && isPaidRental) { } else if (hasStripeAccount && isPaidRental) {
stripeSection = ` stripeSection = `
<div class="success-box"> <div class="success-box">
<p><strong>✓ Earnings Account Active</strong></p> <p><strong>✓ Payout Initiated</strong></p>
<p>Your earnings account is set up. You'll automatically receive \\$${payoutAmount.toFixed( <p>Your earnings of <strong>$${payoutAmount.toFixed(
2 2,
)} when the rental period ends.</p> )}</strong> have been transferred to your Stripe account.</p>
<p style="font-size: 14px; margin-top: 10px;">Funds typically reach your bank within 2-7 business days.</p>
<p><a href="${frontendUrl}/earnings" style="color: #155724; text-decoration: underline;">View your earnings dashboard →</a></p> <p><a href="${frontendUrl}/earnings" style="color: #155724; text-decoration: underline;">View your earnings dashboard →</a></p>
</div> </div>
`; `;
@@ -1093,34 +1100,40 @@ class RentalFlowEmailService {
const ownerHtmlContent = await this.templateManager.renderTemplate( const ownerHtmlContent = await this.templateManager.renderTemplate(
"rentalCompletionCongratsToOwner", "rentalCompletionCongratsToOwner",
ownerVariables ownerVariables,
); );
const ownerResult = await this.emailClient.sendEmail( const ownerResult = await this.emailClient.sendEmail(
owner.email, owner.email,
`Rental Complete - ${rental.item?.name || "Your Item"}`, `Rental Complete - ${rental.item?.name || "Your Item"}`,
ownerHtmlContent ownerHtmlContent,
); );
if (ownerResult.success) { if (ownerResult.success) {
console.log( logger.info("Rental completion congratulations email sent to owner", {
`Rental completion congratulations email sent to owner: ${owner.email}` email: owner.email,
); });
results.ownerEmailSent = true; results.ownerEmailSent = true;
} else { } else {
console.error( logger.error("Failed to send rental completion email to owner", {
`Failed to send rental completion email to owner (${owner.email}):`, email: owner.email,
ownerResult.error error: ownerResult.error,
); });
} }
} catch (emailError) { } catch (emailError) {
console.error( logger.error("Failed to send rental completion email to owner", {
`Failed to send rental completion email to owner (${owner.email}):`, error: emailError.message,
emailError.message stack: emailError.stack,
); ownerEmail: owner.email,
rentalId: rental.id,
});
} }
} catch (error) { } 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; return results;
@@ -1148,7 +1161,7 @@ class RentalFlowEmailService {
} }
try { try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
const earningsDashboardUrl = `${frontendUrl}/earnings`; const earningsDashboardUrl = `${frontendUrl}/earnings`;
// Format currency values // Format currency values
@@ -1180,7 +1193,7 @@ class RentalFlowEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"payoutReceivedToOwner", "payoutReceivedToOwner",
variables variables,
); );
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
@@ -1188,10 +1201,54 @@ class RentalFlowEmailService {
`Earnings Received - $${payoutAmount.toFixed(2)} for ${ `Earnings Received - $${payoutAmount.toFixed(2)} for ${
rental.item?.name || "Your Item" rental.item?.name || "Your Item"
}`, }`,
htmlContent htmlContent,
); );
} catch (error) { } catch (error) {
console.error("Failed to send payout received email:", error); logger.error("Failed to send payout received email", { error });
return { success: false, error: error.message };
}
}
/**
* Send authentication required email to renter when 3DS verification is needed
* This is sent when the owner approves a rental but the renter's bank requires
* additional verification (3D Secure) to complete the payment.
*
* @param {string} email - Renter's email address
* @param {Object} data - Email data
* @param {string} data.renterName - Renter's first name
* @param {string} data.itemName - Name of the item being rented
* @param {string} data.ownerName - Owner's first name
* @param {number} data.amount - Total rental amount
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
*/
async sendAuthenticationRequiredEmail(email, data) {
if (!this.initialized) {
await this.initialize();
}
try {
const { renterName, itemName, ownerName, amount } = data;
const variables = {
renterName: renterName || "there",
itemName: itemName || "the item",
ownerName: ownerName || "The owner",
amount: typeof amount === "number" ? amount.toFixed(2) : "0.00",
};
const htmlContent = await this.templateManager.renderTemplate(
"authenticationRequiredToRenter",
variables,
);
return await this.emailClient.sendEmail(
email,
`Action Required: Complete payment for ${itemName}`,
htmlContent,
);
} catch (error) {
logger.error("Failed to send authentication required email", { error });
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
} }

View File

@@ -1,5 +1,6 @@
const EmailClient = require("../core/EmailClient"); const EmailClient = require("../core/EmailClient");
const TemplateManager = require("../core/TemplateManager"); const TemplateManager = require("../core/TemplateManager");
const logger = require("../../../utils/logger");
/** /**
* RentalReminderEmailService handles rental reminder emails * RentalReminderEmailService handles rental reminder emails
@@ -26,7 +27,7 @@ class RentalReminderEmailService {
]); ]);
this.initialized = true; this.initialized = true;
console.log("Rental Reminder Email Service initialized successfully"); logger.info("Rental Reminder Email Service initialized successfully");
} }
/** /**
@@ -64,11 +65,11 @@ class RentalReminderEmailService {
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
userEmail, userEmail,
`RentAll: ${notification.title}`, `Village Share: ${notification.title}`,
htmlContent htmlContent
); );
} catch (error) { } catch (error) {
console.error("Failed to send condition check reminder:", error); logger.error("Failed to send condition check reminder:", error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
} }

View File

@@ -1,5 +1,6 @@
const EmailClient = require("../core/EmailClient"); const EmailClient = require("../core/EmailClient");
const TemplateManager = require("../core/TemplateManager"); const TemplateManager = require("../core/TemplateManager");
const logger = require("../../../utils/logger");
/** /**
* UserEngagementEmailService handles user engagement emails * UserEngagementEmailService handles user engagement emails
@@ -27,7 +28,7 @@ class UserEngagementEmailService {
]); ]);
this.initialized = true; this.initialized = true;
console.log("User Engagement Email Service initialized successfully"); logger.info("User Engagement Email Service initialized successfully");
} }
/** /**
@@ -46,7 +47,7 @@ class UserEngagementEmailService {
} }
try { try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
const variables = { const variables = {
ownerName: owner.firstName || "there", ownerName: owner.firstName || "there",
@@ -57,18 +58,18 @@ class UserEngagementEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"firstListingCelebrationToOwner", "firstListingCelebrationToOwner",
variables variables,
); );
const subject = `Congratulations! Your first item is live on RentAll`; const subject = `Congratulations! Your first item is live on Village Share`;
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
owner.email, owner.email,
subject, subject,
htmlContent htmlContent,
); );
} catch (error) { } catch (error) {
console.error("Failed to send first listing celebration email:", error); logger.error("Failed to send first listing celebration email", { error });
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
} }
@@ -90,8 +91,8 @@ class UserEngagementEmailService {
} }
try { try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
const supportEmail = process.env.SUPPORT_EMAIL; const supportEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
const variables = { const variables = {
ownerName: owner.firstName || "there", ownerName: owner.firstName || "there",
@@ -103,7 +104,7 @@ class UserEngagementEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"itemDeletionToOwner", "itemDeletionToOwner",
variables variables,
); );
const subject = `Important: Your listing "${item.name}" has been removed`; const subject = `Important: Your listing "${item.name}" has been removed`;
@@ -111,10 +112,64 @@ class UserEngagementEmailService {
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
owner.email, owner.email,
subject, subject,
htmlContent htmlContent,
); );
} catch (error) { } catch (error) {
console.error("Failed to send item deletion notification email:", error); logger.error("Failed to send item deletion notification email", {
error,
});
return { success: false, error: error.message };
}
}
/**
* Send notification when a user's account is banned
* @param {Object} bannedUser - User who was banned
* @param {string} bannedUser.firstName - Banned user's first name
* @param {string} bannedUser.email - Banned user's email
* @param {Object} admin - Admin who performed the ban
* @param {string} admin.firstName - Admin's first name
* @param {string} admin.lastName - Admin's last name
* @param {string} banReason - Reason for the ban
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
*/
async sendUserBannedNotification(bannedUser, admin, banReason) {
if (!this.initialized) {
await this.initialize();
}
try {
const supportEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
const variables = {
userName: bannedUser.firstName || "there",
banReason: banReason,
supportEmail: supportEmail,
};
const htmlContent = await this.templateManager.renderTemplate(
"userBannedNotification",
variables,
);
const subject =
"Important: Your Village Share Account Has Been Suspended";
const result = await this.emailClient.sendEmail(
bannedUser.email,
subject,
htmlContent,
);
if (result.success) {
logger.info("User banned notification email sent", {
email: bannedUser.email,
});
}
return result;
} catch (error) {
logger.error("Failed to send user banned notification email", { error });
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
} }

View File

@@ -7,6 +7,7 @@ const RentalFlowEmailService = require("./domain/RentalFlowEmailService");
const RentalReminderEmailService = require("./domain/RentalReminderEmailService"); const RentalReminderEmailService = require("./domain/RentalReminderEmailService");
const UserEngagementEmailService = require("./domain/UserEngagementEmailService"); const UserEngagementEmailService = require("./domain/UserEngagementEmailService");
const AlphaInvitationEmailService = require("./domain/AlphaInvitationEmailService"); const AlphaInvitationEmailService = require("./domain/AlphaInvitationEmailService");
const PaymentEmailService = require("./domain/PaymentEmailService");
/** /**
* EmailServices aggregates all domain-specific email services * EmailServices aggregates all domain-specific email services
@@ -24,6 +25,7 @@ class EmailServices {
this.rentalReminder = new RentalReminderEmailService(); this.rentalReminder = new RentalReminderEmailService();
this.userEngagement = new UserEngagementEmailService(); this.userEngagement = new UserEngagementEmailService();
this.alphaInvitation = new AlphaInvitationEmailService(); this.alphaInvitation = new AlphaInvitationEmailService();
this.payment = new PaymentEmailService();
this.initialized = false; this.initialized = false;
} }
@@ -45,6 +47,7 @@ class EmailServices {
this.rentalReminder.initialize(), this.rentalReminder.initialize(),
this.userEngagement.initialize(), this.userEngagement.initialize(),
this.alphaInvitation.initialize(), this.alphaInvitation.initialize(),
this.payment.initialize(),
]); ]);
this.initialized = true; this.initialized = true;

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
const { Rental, Item, User } = require("../models"); const { Rental, Item, User } = require("../models");
const emailServices = require("./email"); const emailServices = require("./email");
const { isActive } = require("../utils/rentalStatus");
const logger = require("../utils/logger");
class LateReturnService { class LateReturnService {
/** /**
@@ -71,7 +73,7 @@ class LateReturnService {
throw new Error("Rental not found"); throw new Error("Rental not found");
} }
if (rental.status !== "active") { if (!isActive(rental)) {
throw new Error("Can only process late returns for active rentals"); 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 { return {
rental: updatedRental, rental: updatedRental,
lateCalculation, lateCalculation,

View File

@@ -1,5 +1,6 @@
const { sequelize } = require('../models'); const { sequelize } = require("../models");
const { QueryTypes } = require('sequelize'); const { QueryTypes } = require("sequelize");
const logger = require("../utils/logger");
class LocationService { class LocationService {
/** /**
@@ -13,25 +14,19 @@ class LocationService {
*/ */
async findUsersInRadius(latitude, longitude, radiusMiles = 10) { async findUsersInRadius(latitude, longitude, radiusMiles = 10) {
if (!latitude || !longitude) { if (!latitude || !longitude) {
throw new Error('Latitude and longitude are required'); throw new Error("Latitude and longitude are required");
} }
if (radiusMiles <= 0 || radiusMiles > 100) { 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 { try {
// Haversine formula: // Haversine formula:
// distance = 3959 * acos(cos(radians(lat1)) * cos(radians(lat2)) // distance = 3959 * acos(cos(radians(lat1)) * cos(radians(lat2))
// * cos(radians(lng2) - radians(lng1)) // * cos(radians(lng2) - radians(lng1))
// + sin(radians(lat1)) * sin(radians(lat2))) // + sin(radians(lat1)) * sin(radians(lat2)))
// Note: 3959 is Earth's radius in miles // 3959 is Earth's radius in miles
const query = ` const query = `
SELECT * FROM ( SELECT * FROM (
SELECT SELECT
@@ -62,29 +57,22 @@ class LocationService {
replacements: { replacements: {
lat: parseFloat(latitude), lat: parseFloat(latitude),
lng: parseFloat(longitude), lng: parseFloat(longitude),
radiusMiles: parseFloat(radiusMiles) radiusMiles: parseFloat(radiusMiles),
}, },
type: QueryTypes.SELECT type: QueryTypes.SELECT,
}); });
console.log('Users found in radius:', users.map(u => ({ return users.map((user) => ({
id: u.id,
userLat: u.latitude,
userLng: u.longitude,
distance: parseFloat(u.distance).toFixed(2)
})));
return users.map(user => ({
id: user.id, id: user.id,
email: user.email, email: user.email,
firstName: user.firstName, firstName: user.firstName,
lastName: user.lastName, lastName: user.lastName,
latitude: parseFloat(user.latitude), latitude: parseFloat(user.latitude),
longitude: parseFloat(user.longitude), 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) { } catch (error) {
console.error('Error finding users in radius:', error); logger.error("Error finding users in radius", { error });
throw new Error(`Failed to find users in radius: ${error.message}`); throw new Error(`Failed to find users in radius: ${error.message}`);
} }
} }
@@ -105,8 +93,10 @@ class LocationService {
const a = const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(this.toRadians(lat1)) * Math.cos(this.toRadians(lat2)) * Math.cos(this.toRadians(lat1)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2); 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 c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const distance = R * c; const distance = R * c;

View File

@@ -1,9 +1,91 @@
const { Rental, User, Item } = require("../models"); const { Rental, User, Item } = require("../models");
const StripeService = require("./stripeService"); const StripeService = require("./stripeService");
const emailServices = require("./email"); const emailServices = require("./email");
const logger = require("../utils/logger");
const { Op } = require("sequelize"); const { Op } = require("sequelize");
class PayoutService { 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() { static async getEligiblePayouts() {
try { try {
const eligibleRentals = await Rental.findAll({ const eligibleRentals = await Rental.findAll({
@@ -20,6 +102,7 @@ class PayoutService {
stripeConnectedAccountId: { stripeConnectedAccountId: {
[Op.not]: null, [Op.not]: null,
}, },
stripePayoutsEnabled: true,
}, },
}, },
{ {
@@ -31,7 +114,7 @@ class PayoutService {
return eligibleRentals; return eligibleRentals;
} catch (error) { } catch (error) {
console.error("Error getting eligible payouts:", error); logger.error("Error getting eligible payouts", { error: error.message, stack: error.stack });
throw error; throw error;
} }
} }
@@ -78,15 +161,18 @@ class PayoutService {
// Send payout notification email to owner // Send payout notification email to owner
try { try {
await emailServices.rentalFlow.sendPayoutReceivedEmail(rental.owner, rental); await emailServices.rentalFlow.sendPayoutReceivedEmail(rental.owner, rental);
console.log( logger.info("Payout notification email sent to owner", {
`Payout notification email sent to owner for rental ${rental.id}` rentalId: rental.id,
); ownerId: rental.ownerId
});
} catch (emailError) { } catch (emailError) {
// Log error but don't fail the payout // Log error but don't fail the payout
console.error( logger.error("Failed to send payout notification email", {
`Failed to send payout notification email for rental ${rental.id}:`, error: emailError.message,
emailError.message stack: emailError.stack,
); rentalId: rental.id,
ownerId: rental.ownerId
});
} }
return { return {
@@ -95,7 +181,7 @@ class PayoutService {
amount: rental.payoutAmount, amount: rental.payoutAmount,
}; };
} catch (error) { } 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 // Update status to failed
await rental.update({ await rental.update({
@@ -142,7 +228,7 @@ class PayoutService {
return results; return results;
} catch (error) { } 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; throw error;
} }
} }
@@ -163,6 +249,7 @@ class PayoutService {
stripeConnectedAccountId: { stripeConnectedAccountId: {
[Op.not]: null, [Op.not]: null,
}, },
stripePayoutsEnabled: true,
}, },
}, },
{ {
@@ -205,7 +292,7 @@ class PayoutService {
return results; return results;
} catch (error) { } catch (error) {
console.error("Error retrying failed payouts:", error); logger.error("Error retrying failed payouts", { error: error.message, stack: error.stack });
throw error; throw error;
} }
} }

View File

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

View 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;

View 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;

View File

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

View 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;

View File

@@ -94,6 +94,7 @@ const initializeMessageSocket = (io) => {
socketId: socket.id, socketId: socket.id,
userId, userId,
error: error.message, error: error.message,
stack: error.stack,
}); });
} }
}); });
@@ -124,6 +125,7 @@ const initializeMessageSocket = (io) => {
socketId: socket.id, socketId: socket.id,
userId, userId,
error: error.message, error: error.message,
stack: error.stack,
}); });
} }
}); });
@@ -170,6 +172,7 @@ const initializeMessageSocket = (io) => {
socketId: socket.id, socketId: socket.id,
userId, userId,
error: error.message, error: error.message,
stack: error.stack,
}); });
} }
}); });
@@ -208,6 +211,7 @@ const initializeMessageSocket = (io) => {
socketId: socket.id, socketId: socket.id,
userId, userId,
error: error.message, error: error.message,
stack: error.stack,
}); });
} }
}); });
@@ -243,6 +247,7 @@ const initializeMessageSocket = (io) => {
socketId: socket.id, socketId: socket.id,
userId, userId,
error: error.message, error: error.message,
stack: error.stack,
}); });
} }
}); });
@@ -305,6 +310,7 @@ const emitNewMessage = (io, receiverId, messageData) => {
receiverId, receiverId,
messageId: messageData.id, messageId: messageData.id,
error: error.message, error: error.message,
stack: error.stack,
}); });
} }
}; };
@@ -330,6 +336,7 @@ const emitMessageRead = (io, senderId, readData) => {
senderId, senderId,
messageId: readData.messageId, messageId: readData.messageId,
error: error.message, error: error.message,
stack: error.stack,
}); });
} }
}; };

View File

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

View File

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

View File

@@ -1,283 +1,322 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Your Alpha Access Code - RentAll</title> <title>Your Alpha Access Code - Village Share</title>
<style> <style>
/* Reset styles */ /* Reset styles */
body, table, td, p, a, li, blockquote { body,
-webkit-text-size-adjust: 100%; table,
-ms-text-size-adjust: 100%; td,
} p,
table, td { a,
mso-table-lspace: 0pt; li,
mso-table-rspace: 0pt; blockquote {
} -webkit-text-size-adjust: 100%;
img { -ms-text-size-adjust: 100%;
-ms-interpolation-mode: bicubic; }
} table,
td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
}
/* Base styles */ /* Base styles */
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
width: 100% !important; width: 100% !important;
min-width: 100%; min-width: 100%;
height: 100%; height: 100%;
background-color: #f8f9fa; background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-family:
line-height: 1.6; -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
color: #212529; Cantarell, sans-serif;
} line-height: 1.6;
color: #212529;
}
/* Container */ /* Container */
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Header */
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #e0e7ff;
font-size: 14px;
margin-top: 8px;
}
/* Content */
.content {
padding: 40px 30px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Code box */
.code-box {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px;
padding: 30px;
margin: 30px 0;
text-align: center;
}
.code-label {
color: #e0e7ff;
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 12px;
}
.code {
font-family: "Courier New", Courier, monospace;
font-size: 32px;
font-weight: 700;
color: #ffffff;
letter-spacing: 4px;
margin: 10px 0;
user-select: all;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff !important;
text-decoration: none;
padding: 16px 32px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
text-align: center;
transition: all 0.3s ease;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #667eea;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0 0 10px 0;
color: #004085;
font-size: 14px;
}
.info-box p:last-child {
margin-bottom: 0;
}
.info-box ul {
margin: 10px 0 0 0;
padding-left: 20px;
color: #004085;
font-size: 14px;
}
.info-box li {
margin-bottom: 6px;
}
/* Footer */
.footer {
background-color: #f8f9fa;
padding: 30px;
text-align: center;
border-top: 1px solid #e9ecef;
}
.footer p {
margin: 0 0 10px 0;
font-size: 14px;
color: #6c757d;
}
.footer a {
color: #667eea;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
/* Responsive */
@media only screen and (max-width: 600px) {
.email-container { .email-container {
max-width: 600px; margin: 0;
margin: 0 auto; border-radius: 0;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
} }
/* Header */ .header,
.header { .content,
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); .footer {
padding: 40px 30px; padding: 20px;
text-align: center;
} }
.logo { .logo {
font-size: 32px; font-size: 28px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #e0e7ff;
font-size: 14px;
margin-top: 8px;
}
/* Content */
.content {
padding: 40px 30px;
} }
.content h1 { .content h1 {
font-size: 24px; font-size: 22px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Code box */
.code-box {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px;
padding: 30px;
margin: 30px 0;
text-align: center;
}
.code-label {
color: #e0e7ff;
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 12px;
} }
.code { .code {
font-family: 'Courier New', Courier, monospace; font-size: 24px;
font-size: 32px; letter-spacing: 2px;
font-weight: 700;
color: #ffffff;
letter-spacing: 4px;
margin: 10px 0;
user-select: all;
} }
/* Button */
.button { .button {
display: inline-block; display: block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); width: 100%;
color: #ffffff !important; box-sizing: border-box;
text-decoration: none;
padding: 16px 32px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
text-align: center;
transition: all 0.3s ease;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #667eea;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0 0 10px 0;
color: #004085;
font-size: 14px;
}
.info-box p:last-child {
margin-bottom: 0;
}
.info-box ul {
margin: 10px 0 0 0;
padding-left: 20px;
color: #004085;
font-size: 14px;
}
.info-box li {
margin-bottom: 6px;
}
/* Footer */
.footer {
background-color: #f8f9fa;
padding: 30px;
text-align: center;
border-top: 1px solid #e9ecef;
}
.footer p {
margin: 0 0 10px 0;
font-size: 14px;
color: #6c757d;
}
.footer a {
color: #667eea;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
/* Responsive */
@media only screen and (max-width: 600px) {
.email-container {
margin: 0;
border-radius: 0;
}
.header, .content, .footer {
padding: 20px;
}
.logo {
font-size: 28px;
}
.content h1 {
font-size: 22px;
}
.code {
font-size: 24px;
letter-spacing: 2px;
}
.button {
display: block;
width: 100%;
box-sizing: border-box;
}
} }
}
</style> </style>
</head> </head>
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
<div class="tagline">Alpha Access Invitation</div> <div class="tagline">Alpha Access Invitation</div>
</div>
<div class="content">
<h1>Welcome to Alpha Testing!</h1>
<p>
Congratulations! You've been selected to participate in the exclusive
alpha testing program for Village Share, the community-powered rental
marketplace.
</p>
<p>
Your unique alpha access code is:
<strong style="font-family: monospace">{{code}}</strong>
</p>
<p>To get started:</p>
<div class="info-box">
<p><strong>Steps to Access:</strong></p>
<ul>
<li>
Visit
<a href="{{frontendUrl}}" style="color: #667eea; font-weight: 600"
>{{frontendUrl}}</a
>
</li>
<li>Enter your alpha access code when prompted</li>
<li>
Register with <strong>this email address</strong> ({{email}})
</li>
<li>Start exploring the platform!</li>
</ul>
</div> </div>
<div class="content"> <div style="text-align: center">
<h1>Welcome to Alpha Testing!</h1> <a href="{{frontendUrl}}" class="button"
>Access Village Share Alpha</a
<p>Congratulations! You've been selected to participate in the exclusive alpha testing program for 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> </div>
<div class="footer"> <p><strong>What to expect as an alpha tester:</strong></p>
<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> <div class="info-box">
<p>&copy; 2024 RentAll. All rights reserved.</p> <ul>
<li>Early access to new features before public launch</li>
<li>Opportunity to shape the product with your feedback</li>
<li>Direct communication with the development team</li>
<li>Special recognition as an early supporter</li>
</ul>
</div> </div>
<p><strong>Important notes:</strong></p>
<ul style="color: #6c757d; font-size: 14px">
<li>Your code is tied to this email address only</li>
<li>This is a permanent access code (no expiration)</li>
<li>Please keep your code confidential</li>
<li>We value your feedback - let us know what you think!</li>
</ul>
<p>
We're excited to have you as part of our alpha testing community. Your
feedback will be invaluable in making Village Share the best it can
be.
</p>
<p>
If you have any questions or encounter any issues, please don't
hesitate to reach out to us.
</p>
<p>Happy renting!</p>
</div>
<div class="footer">
<p><strong>Village Share Alpha Testing Program</strong></p>
<p>
Need help? Contact us at
<a href="mailto:community-support@village-share.com"
>community-support@village-share.com</a
>
</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div> </div>
</body> </body>
</html> </html>

View File

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

View File

@@ -1,241 +1,271 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>{{title}}</title> <title>{{title}}</title>
<style> <style>
/* Reset styles */ /* Reset styles */
body, table, td, p, a, li, blockquote { body,
-webkit-text-size-adjust: 100%; table,
-ms-text-size-adjust: 100%; td,
} p,
table, td { a,
mso-table-lspace: 0pt; li,
mso-table-rspace: 0pt; blockquote {
} -webkit-text-size-adjust: 100%;
img { -ms-text-size-adjust: 100%;
-ms-interpolation-mode: bicubic; }
} table,
td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
}
/* Base styles */ /* Base styles */
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
width: 100% !important; width: 100% !important;
min-width: 100%; min-width: 100%;
height: 100%; height: 100%;
background-color: #f8f9fa; background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-family:
line-height: 1.6; -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
color: #212529; Cantarell, sans-serif;
} line-height: 1.6;
color: #212529;
}
/* Container */ /* Container */
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Header */
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #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 { .email-container {
max-width: 600px; margin: 0;
margin: 0 auto; border-radius: 0;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
} }
/* Header */ .header,
.header { .content,
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); .footer {
padding: 40px 30px; padding: 20px;
text-align: center;
} }
.logo { .logo {
font-size: 32px; font-size: 28px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #e9ecef;
font-size: 14px;
margin-top: 8px;
}
/* Content */
.content {
padding: 40px 30px;
} }
.content h1 { .content h1 {
font-size: 24px; font-size: 22px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
} }
.content 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 { .button {
display: inline-block; display: block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); width: 100%;
color: #ffffff !important; box-sizing: border-box;
text-decoration: none;
padding: 16px 32px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
text-align: center;
transition: all 0.3s ease;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
/* Info box */
.info-box {
background-color: #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;
}
} }
}
</style> </style>
</head> </head>
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="header"> <div class="header">
<div class="logo">RentAll</div> <div class="logo">Village Share</div>
<div class="tagline">Your trusted rental marketplace</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>
<div class="content"> <p>
<h1>📸 {{title}}</h1> 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="alert-box">
<p>
<div class="info-box"> <strong>Important:</strong> Please complete this condition check as
<div class="icon">📦</div> soon as possible. Missing this deadline may affect dispute
<p><strong>Rental Item:</strong> {{itemName}}</p> resolution if issues arise.
<p><strong>Deadline:</strong> {{deadline}}</p> </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> </div>
<div class="footer"> <a href="#" class="button">Complete Condition Check</a>
<p>&copy; 2024 RentAll. All rights reserved.</p>
<p>You received this email because you have an active rental on RentAll.</p> <h2>What to photograph:</h2>
<p>If you have any questions, please <a href="mailto:support@rentall.com">contact our support team</a>.</p> <ul>
</div> <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>&copy; 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> </div>
</body> </body>
</html> </html>

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