Compare commits

...

92 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
316 changed files with 83980 additions and 22645 deletions

9
.gitignore vendored
View File

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

1
backend/.gitignore vendored
View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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'
};
// Parse response body for error responses to include error details
if (res.statusCode >= 400) {
let errorDetails = null;
if (body) {
try {
const parsed = typeof body === 'string' ? JSON.parse(body) : body;
// Extract error message, validation errors, or full response
errorDetails = {
error: parsed.error || parsed.message || null,
errors: parsed.errors || null, // validation errors array
details: parsed.details || null
};
// Remove null values
Object.keys(errorDetails).forEach(key => {
if (errorDetails[key] === null) delete errorDetails[key];
});
if (Object.keys(errorDetails).length > 0) {
responseData.errorDetails = errorDetails;
}
} catch (e) {
// Body is not JSON, include as string (truncated)
if (typeof body === 'string' && body.length > 0) {
responseData.errorDetails = { raw: body.substring(0, 500) };
}
}
}
}
if (res.statusCode >= 400 && res.statusCode < 500) {
// Don't log 401s for /users/profile - these are expected auth checks
if (!(res.statusCode === 401 && req.url === '/profile')) {

View File

@@ -33,6 +33,14 @@ const authenticateToken = async (req, res, next) => {
});
}
// Check if user is banned
if (user.isBanned) {
return res.status(403).json({
error: "Your account has been suspended. Please contact support for more information.",
code: "USER_BANNED",
});
}
// Validate JWT version to invalidate old tokens after password change
if (decoded.jwtVersion !== user.jwtVersion) {
return res.status(401).json({
@@ -93,6 +101,12 @@ const optionalAuth = async (req, res, next) => {
return next();
}
// Banned users are treated as unauthenticated for optional auth
if (user.isBanned) {
req.user = null;
return next();
}
// Validate JWT version to invalidate old tokens after password change
if (decoded.jwtVersion !== user.jwtVersion) {
req.user = null;

View File

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

View File

@@ -1,4 +1,5 @@
const rateLimit = require("express-rate-limit");
const logger = require("../utils/logger");
// General rate limiter for Maps API endpoints
const createMapsRateLimiter = (windowMs, max, message) => {
@@ -111,6 +112,21 @@ const uploadPresignLimiter = createUserBasedRateLimiter(
"Too many upload requests. Please slow down."
);
// Helper to create a rate limit handler that logs the event
const createRateLimitHandler = (limiterName) => (req, res, next, options) => {
const reqLogger = logger.withRequestId(req.id);
reqLogger.warn('Rate limit exceeded', {
limiter: limiterName,
ip: req.ip,
userId: req.user?.id || 'anonymous',
method: req.method,
url: req.url,
userAgent: req.get('User-Agent'),
message: options.message?.error || 'Rate limit exceeded'
});
res.status(options.statusCode).json(options.message);
};
// Authentication rate limiters
const authRateLimiters = {
// Login rate limiter - stricter to prevent brute force
@@ -124,6 +140,7 @@ const authRateLimiters = {
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: true, // Don't count successful logins
handler: createRateLimitHandler('login'),
}),
// Registration rate limiter
@@ -136,6 +153,7 @@ const authRateLimiters = {
},
standardHeaders: true,
legacyHeaders: false,
handler: createRateLimitHandler('register'),
}),
// Password reset rate limiter
@@ -148,6 +166,7 @@ const authRateLimiters = {
},
standardHeaders: true,
legacyHeaders: false,
handler: createRateLimitHandler('passwordReset'),
}),
// Alpha code validation rate limiter
@@ -160,6 +179,7 @@ const authRateLimiters = {
},
standardHeaders: true,
legacyHeaders: false,
handler: createRateLimitHandler('alphaCodeValidation'),
}),
// Email verification rate limiter - protect against brute force on 6-digit codes
@@ -172,6 +192,7 @@ const authRateLimiters = {
},
standardHeaders: true,
legacyHeaders: false,
handler: createRateLimitHandler('emailVerification'),
}),
// General API rate limiter
@@ -184,6 +205,58 @@ const authRateLimiters = {
},
standardHeaders: true,
legacyHeaders: false,
handler: createRateLimitHandler('general'),
}),
// Two-Factor Authentication rate limiters
twoFactorVerification: rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // 10 verification attempts per 15 minutes
message: {
error: "Too many verification attempts. Please try again later.",
retryAfter: 900,
},
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: true,
handler: createRateLimitHandler('twoFactorVerification'),
}),
twoFactorSetup: rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5, // 5 setup attempts per hour
message: {
error: "Too many setup attempts. Please try again later.",
retryAfter: 3600,
},
standardHeaders: true,
legacyHeaders: false,
handler: createRateLimitHandler('twoFactorSetup'),
}),
recoveryCode: rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 3, // 3 recovery code attempts per 15 minutes
message: {
error: "Too many recovery code attempts. Please try again later.",
retryAfter: 900,
},
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: false, // Count all attempts for security
handler: createRateLimitHandler('recoveryCode'),
}),
emailOtpSend: rateLimit({
windowMs: 10 * 60 * 1000, // 10 minutes
max: 2, // 2 OTP sends per 10 minutes
message: {
error: "Please wait before requesting another code.",
retryAfter: 600,
},
standardHeaders: true,
legacyHeaders: false,
handler: createRateLimitHandler('emailOtpSend'),
}),
};
@@ -201,6 +274,12 @@ module.exports = {
emailVerificationLimiter: authRateLimiters.emailVerification,
generalLimiter: authRateLimiters.general,
// Two-Factor Authentication rate limiters
twoFactorVerificationLimiter: authRateLimiters.twoFactorVerification,
twoFactorSetupLimiter: authRateLimiters.twoFactorSetup,
recoveryCodeLimiter: authRateLimiters.recoveryCode,
emailOtpSendLimiter: authRateLimiters.emailOtpSend,
// Burst protection
burstProtection,

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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

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

@@ -95,11 +95,11 @@ const Item = sequelize.define("Item", {
},
availableAfter: {
type: DataTypes.STRING,
defaultValue: "09:00",
defaultValue: "00:00",
},
availableBefore: {
type: DataTypes.STRING,
defaultValue: "17:00",
defaultValue: "23:00",
},
specifyTimesPerDay: {
type: DataTypes.BOOLEAN,
@@ -108,13 +108,13 @@ const Item = sequelize.define("Item", {
weeklyTimes: {
type: DataTypes.JSONB,
defaultValue: {
sunday: { availableAfter: "09:00", availableBefore: "17:00" },
monday: { availableAfter: "09:00", availableBefore: "17:00" },
tuesday: { availableAfter: "09:00", availableBefore: "17:00" },
wednesday: { availableAfter: "09:00", availableBefore: "17:00" },
thursday: { availableAfter: "09:00", availableBefore: "17:00" },
friday: { availableAfter: "09:00", availableBefore: "17:00" },
saturday: { availableAfter: "09:00", availableBefore: "17:00" },
sunday: { availableAfter: "00:00", availableBefore: "23:00" },
monday: { availableAfter: "00:00", availableBefore: "23:00" },
tuesday: { availableAfter: "00:00", availableBefore: "23:00" },
wednesday: { availableAfter: "00:00", availableBefore: "23:00" },
thursday: { availableAfter: "00:00", availableBefore: "23:00" },
friday: { availableAfter: "00:00", availableBefore: "23:00" },
saturday: { availableAfter: "00:00", availableBefore: "23:00" },
},
},
ownerId: {

View File

@@ -67,11 +67,11 @@ const Rental = sequelize.define("Rental", {
allowNull: false,
},
paymentStatus: {
type: DataTypes.ENUM("pending", "paid", "refunded", "not_required"),
type: DataTypes.ENUM("pending", "paid", "refunded", "not_required", "requires_action"),
allowNull: false,
},
payoutStatus: {
type: DataTypes.ENUM("pending", "completed", "failed"),
type: DataTypes.ENUM("pending", "completed", "failed", "on_hold"),
allowNull: true,
},
payoutProcessedAt: {
@@ -80,6 +80,66 @@ const Rental = sequelize.define("Rental", {
stripeTransferId: {
type: DataTypes.STRING,
},
// Bank deposit tracking fields (for tracking when Stripe deposits to owner's bank)
bankDepositStatus: {
type: DataTypes.ENUM("pending", "in_transit", "paid", "failed", "canceled"),
allowNull: true,
},
bankDepositAt: {
type: DataTypes.DATE,
},
stripePayoutId: {
type: DataTypes.STRING,
},
bankDepositFailureCode: {
type: DataTypes.STRING,
},
// Dispute tracking fields (for tracking Stripe payment disputes/chargebacks)
// Stripe dispute statuses: https://docs.stripe.com/api/disputes/object#dispute_object-status
stripeDisputeStatus: {
type: DataTypes.ENUM(
"needs_response",
"under_review",
"won",
"lost",
"warning_needs_response",
"warning_under_review",
"warning_closed"
),
allowNull: true,
},
stripeDisputeId: {
type: DataTypes.STRING,
allowNull: true,
},
stripeDisputeReason: {
type: DataTypes.STRING,
allowNull: true,
},
stripeDisputeAmount: {
type: DataTypes.INTEGER,
allowNull: true,
},
stripeDisputeCreatedAt: {
type: DataTypes.DATE,
allowNull: true,
},
stripeDisputeEvidenceDueBy: {
type: DataTypes.DATE,
allowNull: true,
},
stripeDisputeClosedAt: {
type: DataTypes.DATE,
allowNull: true,
},
stripeDisputeLost: {
type: DataTypes.BOOLEAN,
defaultValue: false,
},
stripeDisputeLostAmount: {
type: DataTypes.INTEGER,
allowNull: true,
},
// Refund tracking fields
refundAmount: {
type: DataTypes.DECIMAL(10, 2),
@@ -117,6 +177,21 @@ const Rental = sequelize.define("Rental", {
chargedAt: {
type: DataTypes.DATE,
},
// Payment failure notification tracking
paymentFailedNotifiedAt: {
type: DataTypes.DATE,
},
paymentFailedReason: {
type: DataTypes.TEXT,
},
// Payment method update rate limiting
paymentMethodUpdatedAt: {
type: DataTypes.DATE,
},
paymentMethodUpdateCount: {
type: DataTypes.INTEGER,
defaultValue: 0,
},
deliveryMethod: {
type: DataTypes.ENUM("pickup", "delivery"),
defaultValue: "pickup",

View File

@@ -89,11 +89,11 @@ const User = sequelize.define(
},
defaultAvailableAfter: {
type: DataTypes.STRING,
defaultValue: "09:00",
defaultValue: "00:00",
},
defaultAvailableBefore: {
type: DataTypes.STRING,
defaultValue: "17:00",
defaultValue: "23:00",
},
defaultSpecifyTimesPerDay: {
type: DataTypes.BOOLEAN,
@@ -102,23 +102,46 @@ const User = sequelize.define(
defaultWeeklyTimes: {
type: DataTypes.JSONB,
defaultValue: {
sunday: { availableAfter: "09:00", availableBefore: "17:00" },
monday: { availableAfter: "09:00", availableBefore: "17:00" },
tuesday: { availableAfter: "09:00", availableBefore: "17:00" },
wednesday: { availableAfter: "09:00", availableBefore: "17:00" },
thursday: { availableAfter: "09:00", availableBefore: "17:00" },
friday: { availableAfter: "09:00", availableBefore: "17:00" },
saturday: { availableAfter: "09:00", availableBefore: "17:00" },
sunday: { availableAfter: "00:00", availableBefore: "23:00" },
monday: { availableAfter: "00:00", availableBefore: "23:00" },
tuesday: { availableAfter: "00:00", availableBefore: "23:00" },
wednesday: { availableAfter: "00:00", availableBefore: "23:00" },
thursday: { availableAfter: "00:00", availableBefore: "23:00" },
friday: { availableAfter: "00:00", availableBefore: "23:00" },
saturday: { availableAfter: "00:00", availableBefore: "23:00" },
},
},
stripeConnectedAccountId: {
type: DataTypes.STRING,
allowNull: true,
},
stripePayoutsEnabled: {
type: DataTypes.BOOLEAN,
defaultValue: false,
allowNull: true,
},
stripeCustomerId: {
type: DataTypes.STRING,
allowNull: true,
},
stripeRequirementsCurrentlyDue: {
type: DataTypes.JSON,
defaultValue: [],
allowNull: true,
},
stripeRequirementsPastDue: {
type: DataTypes.JSON,
defaultValue: [],
allowNull: true,
},
stripeDisabledReason: {
type: DataTypes.STRING,
allowNull: true,
},
stripeRequirementsLastUpdated: {
type: DataTypes.DATE,
allowNull: true,
},
loginAttempts: {
type: DataTypes.INTEGER,
defaultValue: 0,
@@ -137,6 +160,23 @@ const User = sequelize.define(
defaultValue: "user",
allowNull: false,
},
isBanned: {
type: DataTypes.BOOLEAN,
defaultValue: false,
allowNull: false,
},
bannedAt: {
type: DataTypes.DATE,
allowNull: true,
},
bannedBy: {
type: DataTypes.UUID,
allowNull: true,
},
banReason: {
type: DataTypes.TEXT,
allowNull: true,
},
itemRequestNotificationRadius: {
type: DataTypes.INTEGER,
defaultValue: 10,
@@ -151,6 +191,66 @@ const User = sequelize.define(
defaultValue: 0,
allowNull: true,
},
// Two-Factor Authentication fields
twoFactorEnabled: {
type: DataTypes.BOOLEAN,
defaultValue: false,
allowNull: false,
},
twoFactorMethod: {
type: DataTypes.ENUM("totp", "email"),
allowNull: true,
},
totpSecret: {
type: DataTypes.STRING,
allowNull: true,
},
totpSecretIv: {
type: DataTypes.STRING,
allowNull: true,
},
// Email OTP fields (backup method)
emailOtpCode: {
type: DataTypes.STRING,
allowNull: true,
},
emailOtpExpiry: {
type: DataTypes.DATE,
allowNull: true,
},
emailOtpAttempts: {
type: DataTypes.INTEGER,
defaultValue: 0,
allowNull: false,
},
// Recovery codes
recoveryCodesHash: {
type: DataTypes.TEXT,
allowNull: true,
},
recoveryCodesGeneratedAt: {
type: DataTypes.DATE,
allowNull: true,
},
// Step-up session tracking
twoFactorVerifiedAt: {
type: DataTypes.DATE,
allowNull: true,
},
// Temporary secret during setup
twoFactorSetupPendingSecret: {
type: DataTypes.STRING,
allowNull: true,
},
twoFactorSetupPendingSecretIv: {
type: DataTypes.STRING,
allowNull: true,
},
// TOTP replay protection
recentTotpCodes: {
type: DataTypes.TEXT,
allowNull: true,
},
},
{
hooks: {
@@ -165,7 +265,7 @@ const User = sequelize.define(
}
},
},
}
},
);
User.prototype.comparePassword = async function (password) {
@@ -176,7 +276,7 @@ User.prototype.comparePassword = async function (password) {
};
// Account lockout constants
const MAX_LOGIN_ATTEMPTS = 5;
const MAX_LOGIN_ATTEMPTS = 10;
const LOCK_TIME = 2 * 60 * 60 * 1000; // 2 hours
// Check if account is locked
@@ -338,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;

View File

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

3124
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -28,8 +28,7 @@ const router = express.Router();
const googleClient = new OAuth2Client(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
process.env.GOOGLE_REDIRECT_URI ||
"http://localhost:3000/auth/google/callback"
process.env.GOOGLE_REDIRECT_URI,
);
// Get CSRF token endpoint
@@ -120,13 +119,14 @@ router.post(
try {
await emailServices.auth.sendVerificationEmail(
user,
user.verificationToken
user.verificationToken,
);
verificationEmailSent = true;
} catch (emailError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Failed to send verification email", {
error: emailError.message,
stack: emailError.stack,
userId: user.id,
email: user.email,
});
@@ -136,28 +136,26 @@ router.post(
const token = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: "15m" } // Short-lived access token
{ expiresIn: "15m" }, // Short-lived access token
);
const refreshToken = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: "7d" }
{ expiresIn: "7d" },
);
// Set tokens as httpOnly cookies
res.cookie("accessToken", token, {
httpOnly: true,
secure:
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
sameSite: "strict",
maxAge: 15 * 60 * 1000, // 15 minutes
});
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure:
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
sameSite: "strict",
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
@@ -189,7 +187,7 @@ router.post(
});
res.status(500).json({ error: "Registration failed. Please try again." });
}
}
},
);
router.post(
@@ -206,8 +204,7 @@ router.post(
if (!user) {
return res.status(401).json({
error:
"Unable to log in. Please check your email and password, or create an account.",
error: "Please check your email and password, or create an account.",
});
}
@@ -215,7 +212,16 @@ router.post(
if (user.isLocked()) {
return res.status(423).json({
error:
"Account is temporarily locked due to too many failed login attempts. Please try again later.",
"Account is temporarily locked due to too many failed login attempts. Please try again in 2 hours.",
});
}
// Check if user is banned
if (user.isBanned) {
return res.status(403).json({
error:
"Your account has been suspended. Please contact support for more information.",
code: "USER_BANNED",
});
}
@@ -226,8 +232,7 @@ router.post(
// Increment login attempts
await user.incLoginAttempts();
return res.status(401).json({
error:
"Unable to log in. Please check your email and password, or create an account.",
error: "Please check your email and password, or create an account.",
});
}
@@ -237,28 +242,26 @@ router.post(
const token = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: "15m" } // Short-lived access token
{ expiresIn: "15m" }, // Short-lived access token
);
const refreshToken = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: "7d" }
{ expiresIn: "7d" },
);
// Set tokens as httpOnly cookies
res.cookie("accessToken", token, {
httpOnly: true,
secure:
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
sameSite: "strict",
maxAge: 15 * 60 * 1000, // 15 minutes
});
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure:
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
sameSite: "strict",
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
@@ -289,7 +292,7 @@ router.post(
});
res.status(500).json({ error: "Login failed. Please try again." });
}
}
},
);
router.post(
@@ -311,9 +314,7 @@ router.post(
// Exchange authorization code for tokens
const { tokens } = await googleClient.getToken({
code,
redirect_uri:
process.env.GOOGLE_REDIRECT_URI ||
"http://localhost:3000/auth/google/callback",
redirect_uri: process.env.GOOGLE_REDIRECT_URI,
});
// Verify the ID token from the token response
@@ -407,32 +408,39 @@ router.post(
}
}
// Check if user is banned
if (user.isBanned) {
return res.status(403).json({
error:
"Your account has been suspended. Please contact support for more information.",
code: "USER_BANNED",
});
}
// Generate JWT tokens
const token = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: "15m" }
{ expiresIn: "15m" },
);
const refreshToken = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: "7d" }
{ expiresIn: "7d" },
);
// Set tokens as httpOnly cookies
res.cookie("accessToken", token, {
httpOnly: true,
secure:
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
sameSite: "strict",
maxAge: 15 * 60 * 1000,
});
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure:
process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
sameSite: "strict",
maxAge: 7 * 24 * 60 * 60 * 1000,
});
@@ -479,7 +487,7 @@ router.post(
.status(500)
.json({ error: "Google authentication failed. Please try again." });
}
}
},
);
// Email verification endpoint
@@ -596,7 +604,7 @@ router.post(
error: "Email verification failed. Please try again.",
});
}
}
},
);
// Resend verification email endpoint
@@ -641,12 +649,13 @@ router.post(
try {
await emailServices.auth.sendVerificationEmail(
user,
user.verificationToken
user.verificationToken,
);
} catch (emailError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Failed to resend verification email", {
error: emailError.message,
stack: emailError.stack,
userId: user.id,
email: user.email,
});
@@ -681,7 +690,7 @@ router.post(
error: "Failed to resend verification email. Please try again.",
});
}
}
},
);
// Refresh token endpoint
@@ -714,17 +723,26 @@ router.post("/refresh", async (req, res) => {
});
}
// Check if user is banned (defense-in-depth, jwtVersion should already catch this)
if (user.isBanned) {
return res.status(403).json({
error:
"Your account has been suspended. Please contact support for more information.",
code: "USER_BANNED",
});
}
// Generate new access token
const newAccessToken = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: "15m" }
{ expiresIn: "15m" },
);
// Set new access token cookie
res.cookie("accessToken", newAccessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "qa",
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
sameSite: "strict",
maxAge: 15 * 60 * 1000,
});
@@ -821,6 +839,7 @@ router.post(
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Failed to send password reset email", {
error: emailError.message,
stack: emailError.stack,
userId: user.id,
email: user.email,
});
@@ -832,7 +851,7 @@ router.post(
"Password reset requested for non-existent or OAuth user",
{
email: email,
}
},
);
}
@@ -852,7 +871,7 @@ router.post(
error: "Failed to process password reset request. Please try again.",
});
}
}
},
);
// Verify reset token endpoint (optional - for frontend UX)
@@ -906,7 +925,7 @@ router.post(
error: "Failed to verify reset token. Please try again.",
});
}
}
},
);
// Reset password endpoint
@@ -962,6 +981,7 @@ router.post(
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Failed to send password changed notification", {
error: emailError.message,
stack: emailError.stack,
userId: user.id,
email: user.email,
});
@@ -988,7 +1008,7 @@ router.post(
error: "Failed to reset password. Please try again.",
});
}
}
},
);
module.exports = router;

View File

@@ -7,6 +7,49 @@ const { IMAGE_LIMITS } = require("../config/imageLimits");
const router = express.Router();
// Get condition checks for multiple rentals in a single request (batch)
router.get("/batch", authenticateToken, async (req, res) => {
try {
const { rentalIds } = req.query;
if (!rentalIds) {
return res.json({
success: true,
conditionChecks: [],
});
}
const ids = rentalIds.split(",").filter((id) => id.trim());
if (ids.length === 0) {
return res.json({
success: true,
conditionChecks: [],
});
}
const conditionChecks =
await ConditionCheckService.getConditionChecksForRentals(ids);
res.json({
success: true,
conditionChecks,
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error fetching batch condition checks", {
error: error.message,
stack: error.stack,
rentalIds: req.query.rentalIds,
});
res.status(500).json({
success: false,
error: "Failed to fetch condition checks",
});
}
});
// Submit a condition check
router.post("/:rentalId", authenticateToken, async (req, res) => {
try {
@@ -20,9 +63,13 @@ router.post("/:rentalId", authenticateToken, async (req, res) => {
: [];
// Validate S3 keys format and folder
const keyValidation = validateS3Keys(imageFilenamesArray, "condition-checks", {
maxKeys: IMAGE_LIMITS.conditionChecks,
});
const keyValidation = validateS3Keys(
imageFilenamesArray,
"condition-checks",
{
maxKeys: IMAGE_LIMITS.conditionChecks,
}
);
if (!keyValidation.valid) {
return res.status(400).json({
success: false,
@@ -57,6 +104,7 @@ router.post("/:rentalId", authenticateToken, async (req, res) => {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error submitting condition check", {
error: error.message,
stack: error.stack,
rentalId: req.params.rentalId,
userId: req.user?.id,
});
@@ -68,67 +116,16 @@ router.post("/:rentalId", authenticateToken, async (req, res) => {
}
});
// Get condition checks for a rental
router.get("/:rentalId", authenticateToken, async (req, res) => {
try {
const { rentalId } = req.params;
const conditionChecks = await ConditionCheckService.getConditionChecks(
rentalId
);
res.json({
success: true,
conditionChecks,
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error fetching condition checks", {
error: error.message,
rentalId: req.params.rentalId,
});
res.status(500).json({
success: false,
error: "Failed to fetch condition checks",
});
}
});
// Get condition check timeline for a rental
router.get("/:rentalId/timeline", authenticateToken, async (req, res) => {
try {
const { rentalId } = req.params;
const timeline = await ConditionCheckService.getConditionCheckTimeline(
rentalId
);
res.json({
success: true,
timeline,
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error fetching condition check timeline", {
error: error.message,
rentalId: req.params.rentalId,
});
res.status(500).json({
success: false,
error: error.message,
});
}
});
// Get available condition checks for current user
router.get("/", authenticateToken, async (req, res) => {
try {
const userId = req.user.id;
const { rentalIds } = req.query;
const ids = rentalIds ? rentalIds.split(",").filter((id) => id.trim()) : [];
const availableChecks = await ConditionCheckService.getAvailableChecks(
userId
userId,
ids
);
res.json({
@@ -139,6 +136,7 @@ router.get("/", authenticateToken, async (req, res) => {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error fetching available checks", {
error: error.message,
stack: error.stack,
userId: req.user?.id,
});

View File

@@ -33,6 +33,7 @@ router.post('/', authenticateToken, sanitizeInput, validateFeedback, async (req,
} catch (emailError) {
reqLogger.error("Failed to send feedback confirmation email", {
error: emailError.message,
stack: emailError.stack,
userId: req.user.id,
feedbackId: feedback.id
});
@@ -45,6 +46,7 @@ router.post('/', authenticateToken, sanitizeInput, validateFeedback, async (req,
} catch (emailError) {
reqLogger.error("Failed to send feedback notification to admin", {
error: emailError.message,
stack: emailError.stack,
userId: req.user.id,
feedbackId: feedback.id
});

View File

@@ -2,6 +2,7 @@ const express = require('express');
const { Op } = require('sequelize');
const { ForumPost, ForumComment, PostTag, User } = require('../models');
const { authenticateToken, requireAdmin, optionalAuth } = require('../middleware/auth');
const { validateCoordinatesBody, handleValidationErrors } = require('../middleware/validation');
const logger = require('../utils/logger');
const emailServices = require('../services/email');
const googleMapsService = require('../services/googleMapsService');
@@ -239,7 +240,7 @@ router.get('/posts/:id', optionalAuth, async (req, res, next) => {
});
// POST /api/forum/posts - Create new post
router.post('/posts', authenticateToken, async (req, res, next) => {
router.post('/posts', authenticateToken, ...validateCoordinatesBody, handleValidationErrors, async (req, res, next) => {
try {
// Require email verification
if (!req.user.isVerified) {
@@ -311,6 +312,7 @@ router.post('/posts', authenticateToken, async (req, res, next) => {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Geocoding failed for item request", {
error: error.message,
stack: error.stack,
zipCode
});
// Continue without coordinates - post will still be created
@@ -450,6 +452,7 @@ router.post('/posts', authenticateToken, async (req, res, next) => {
} catch (emailError) {
logger.error("Failed to send item request notification", {
error: emailError.message,
stack: emailError.stack,
recipientId: user.id,
postId: post.id
});
@@ -727,7 +730,7 @@ router.patch('/posts/:id/status', authenticateToken, async (req, res, next) => {
stack: emailError.stack,
postId: req.params.id
});
console.error("Email notification error:", emailError);
logger.error("Email notification error", { error: emailError });
}
})();
}
@@ -907,7 +910,7 @@ router.patch('/posts/:id/accept-answer', authenticateToken, async (req, res, nex
commentId: commentId,
postId: req.params.id
});
console.error("Email notification error:", emailError);
logger.error("Email notification error", { error: emailError });
}
})();
}
@@ -1107,7 +1110,7 @@ router.post('/posts/:id/comments', authenticateToken, async (req, res, next) =>
commentId: comment.id,
postId: req.params.id
});
console.error("Email notification error:", emailError);
logger.error("Email notification error", { error: emailError });
}
})();
@@ -1383,7 +1386,12 @@ router.delete('/admin/posts/:id', authenticateToken, requireAdmin, async (req, r
}
} catch (emailError) {
// Log but don't fail the deletion
console.error('Failed to send forum post deletion notification email:', emailError.message);
logger.error('Failed to send forum post deletion notification email', {
error: emailError.message,
stack: emailError.stack,
postId: post.id,
authorId: post.authorId
});
}
})();
@@ -1511,7 +1519,12 @@ router.delete('/admin/comments/:id', authenticateToken, requireAdmin, async (req
}
} catch (emailError) {
// Log but don't fail the deletion
console.error('Failed to send forum comment deletion notification email:', emailError.message);
logger.error('Failed to send forum comment deletion notification email', {
error: emailError.message,
stack: emailError.stack,
commentId: comment.id,
authorId: comment.authorId
});
}
})();
@@ -1677,7 +1690,7 @@ router.patch('/admin/posts/:id/close', authenticateToken, requireAdmin, async (r
stack: emailError.stack,
postId: req.params.id
});
console.error("Email notification error:", emailError);
logger.error("Email notification error", { error: emailError });
}
})();

View File

@@ -109,7 +109,7 @@ router.get("/ready", async (req, res) => {
timestamp: new Date().toISOString(),
});
} catch (error) {
logger.error("Readiness check failed", { error: error.message });
logger.error("Readiness check failed", { error: error.message, stack: error.stack });
res.status(503).json({
status: "not_ready",
timestamp: new Date().toISOString(),

View File

@@ -1,7 +1,8 @@
const express = require("express");
const { Op } = require("sequelize");
const { Item, User, Rental } = require("../models"); // Import from models/index.js to get models with associations
const { Op, Sequelize } = require("sequelize");
const { Item, User, Rental, sequelize } = require("../models"); // Import from models/index.js to get models with associations
const { authenticateToken, requireVerifiedEmail, requireAdmin, optionalAuth } = require("../middleware/auth");
const { validateCoordinatesQuery, validateCoordinatesBody, handleValidationErrors } = require("../middleware/validation");
const logger = require("../utils/logger");
const { validateS3Keys } = require("../utils/s3KeyValidator");
const { IMAGE_LIMITS } = require("../config/imageLimits");
@@ -53,7 +54,7 @@ function extractAllowedFields(body) {
return result;
}
router.get("/", async (req, res, next) => {
router.get("/", validateCoordinatesQuery, async (req, res, next) => {
try {
const {
minPrice,
@@ -61,6 +62,9 @@ router.get("/", async (req, res, next) => {
city,
zipCode,
search,
lat,
lng,
radius = 25,
page = 1,
limit = 20,
} = req.query;
@@ -74,8 +78,50 @@ router.get("/", async (req, res, next) => {
if (minPrice) where.pricePerDay[Op.gte] = minPrice;
if (maxPrice) where.pricePerDay[Op.lte] = maxPrice;
}
if (city) where.city = { [Op.iLike]: `%${city}%` };
if (zipCode) where.zipCode = { [Op.iLike]: `%${zipCode}%` };
// Location filtering: Radius search OR city/ZIP fallback
if (lat && lng) {
// Parse and validate coordinates
const latNum = parseFloat(lat);
const lngNum = parseFloat(lng);
const radiusNum = parseFloat(radius);
if (!isNaN(latNum) && !isNaN(lngNum) && !isNaN(radiusNum)) {
// Bounding box pre-filter (fast, uses indexes)
// ~69 miles per degree latitude, longitude varies by latitude
const latDelta = radiusNum / 69;
const lngDelta = radiusNum / (69 * Math.cos(latNum * Math.PI / 180));
where.latitude = {
[Op.and]: [
{ [Op.gte]: latNum - latDelta },
{ [Op.lte]: latNum + latDelta },
{ [Op.ne]: null }
]
};
where.longitude = {
[Op.and]: [
{ [Op.gte]: lngNum - lngDelta },
{ [Op.lte]: lngNum + lngDelta },
{ [Op.ne]: null }
]
};
// Haversine formula for exact distance (applied after bounding box)
// 3959 = Earth's radius in miles
where[Op.and] = sequelize.literal(`
(3959 * acos(
cos(radians(${latNum})) * cos(radians("Item"."latitude")) *
cos(radians("Item"."longitude") - radians(${lngNum})) +
sin(radians(${latNum})) * sin(radians("Item"."latitude"))
)) <= ${radiusNum}
`);
}
} else {
// Fallback to city/ZIP string matching
if (city) where.city = { [Op.iLike]: `%${city}%` };
if (zipCode) where.zipCode = { [Op.iLike]: `%${zipCode}%` };
}
if (search) {
where[Op.or] = [
{ name: { [Op.iLike]: `%${search}%` } },
@@ -92,6 +138,10 @@ router.get("/", async (req, res, next) => {
model: User,
as: "owner",
attributes: ["id", "firstName", "lastName", "imageFilename"],
where: {
isBanned: { [Op.ne]: true }
},
required: true,
},
],
limit: parseInt(limit),
@@ -113,7 +163,7 @@ router.get("/", async (req, res, next) => {
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Items search completed", {
filters: { minPrice, maxPrice, city, zipCode, search },
filters: { minPrice, maxPrice, city, zipCode, search, lat, lng, radius },
resultsCount: count,
page: parseInt(page),
limit: parseInt(limit)
@@ -278,27 +328,51 @@ router.get("/:id", optionalAuth, async (req, res, next) => {
}
});
router.post("/", authenticateToken, requireVerifiedEmail, async (req, res, next) => {
router.post("/", authenticateToken, requireVerifiedEmail, ...validateCoordinatesBody, handleValidationErrors, async (req, res, next) => {
try {
// Extract only allowed fields (prevents mass assignment)
const allowedData = extractAllowedFields(req.body);
// Validate imageFilenames if provided
if (allowedData.imageFilenames) {
const imageFilenames = Array.isArray(allowedData.imageFilenames)
? allowedData.imageFilenames
: [];
// Validate imageFilenames - at least one image is required
const imageFilenames = Array.isArray(allowedData.imageFilenames)
? allowedData.imageFilenames
: [];
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;
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({
...allowedData,
ownerId: req.user.id,
@@ -327,10 +401,17 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res, next)
itemWithOwner.owner,
itemWithOwner
);
console.log(`First listing celebration email sent to owner ${req.user.id}`);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("First listing celebration email sent", { ownerId: req.user.id });
} catch (emailError) {
// Log but don't fail the item creation
console.error('Failed to send first listing celebration email:', emailError.message);
const reqLogger = logger.withRequestId(req.id);
reqLogger.error('Failed to send first listing celebration email', {
error: emailError.message,
stack: emailError.stack,
ownerId: req.user.id,
itemId: item.id
});
}
}
@@ -355,7 +436,7 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res, next)
}
});
router.put("/:id", authenticateToken, async (req, res, next) => {
router.put("/:id", authenticateToken, ...validateCoordinatesBody, handleValidationErrors, async (req, res, next) => {
try {
const item = await Item.findByPk(req.params.id);
@@ -376,6 +457,13 @@ router.put("/:id", authenticateToken, async (req, res, next) => {
? 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({
@@ -386,6 +474,26 @@ router.put("/:id", authenticateToken, async (req, res, next) => {
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, {
@@ -526,10 +634,15 @@ router.delete("/admin/:id", authenticateToken, requireAdmin, async (req, res, ne
item,
reason.trim()
);
console.log(`Item deletion notification email sent to owner ${item.ownerId}`);
logger.info("Item deletion notification email sent", { ownerId: item.ownerId, itemId: item.id });
} catch (emailError) {
// Log but don't fail the deletion
console.error('Failed to send item deletion notification email:', emailError.message);
logger.error('Failed to send item deletion notification email', {
error: emailError.message,
stack: emailError.stack,
ownerId: item.ownerId,
itemId: item.id
});
}
const reqLogger = logger.withRequestId(req.id);

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ 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;
@@ -63,6 +64,7 @@ router.post(
/**
* 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",
@@ -96,13 +98,17 @@ router.post(
}
}
// 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
f.fileSize,
sharedBaseKey
)
)
);
@@ -111,9 +117,10 @@ router.post(
userId: req.user.id,
uploadType,
count: results.length,
baseKey: sharedBaseKey,
});
res.json({ uploads: results });
res.json({ uploads: results, baseKey: sharedBaseKey });
} catch (error) {
if (error.message.includes("Invalid")) {
return res.status(400).json({ error: error.message });

View File

@@ -1,8 +1,12 @@
const express = require('express');
const { User, UserAddress } = require('../models'); // Import from models/index.js to get models with associations
const { authenticateToken } = require('../middleware/auth');
const { authenticateToken, optionalAuth, requireAdmin } = require('../middleware/auth');
const { validateCoordinatesBody, validatePasswordChange, handleValidationErrors, sanitizeInput } = require('../middleware/validation');
const { requireStepUpAuth } = require('../middleware/stepUpAuth');
const { csrfProtection } = require('../middleware/csrf');
const logger = require('../utils/logger');
const userService = require('../services/UserService');
const emailServices = require('../services/email');
const { validateS3Keys } = require('../utils/s3KeyValidator');
const { IMAGE_LIMITS } = require('../config/imageLimits');
const router = express.Router();
@@ -109,7 +113,7 @@ router.get('/addresses', authenticateToken, async (req, res, next) => {
}
});
router.post('/addresses', authenticateToken, async (req, res, next) => {
router.post('/addresses', authenticateToken, ...validateCoordinatesBody, handleValidationErrors, async (req, res, next) => {
try {
// Extract only allowed fields (prevents mass assignment)
const allowedData = extractAllowedAddressFields(req.body);
@@ -128,7 +132,7 @@ router.post('/addresses', authenticateToken, async (req, res, next) => {
}
});
router.put('/addresses/:id', authenticateToken, async (req, res, next) => {
router.put('/addresses/:id', authenticateToken, ...validateCoordinatesBody, handleValidationErrors, async (req, res, next) => {
try {
// Extract only allowed fields (prevents mass assignment)
const allowedData = extractAllowedAddressFields(req.body);
@@ -210,10 +214,20 @@ router.put('/availability', authenticateToken, async (req, res, next) => {
}
});
router.get('/:id', async (req, res, next) => {
router.get('/:id', optionalAuth, async (req, res, next) => {
try {
const isAdmin = req.user?.role === 'admin';
// Base attributes to exclude
const excludedAttributes = ['password', 'email', 'phone', 'address', 'verificationToken', 'passwordResetToken'];
// If not admin, also exclude ban-related fields
if (!isAdmin) {
excludedAttributes.push('isBanned', 'bannedAt', 'bannedBy', 'banReason');
}
const user = await User.findByPk(req.params.id, {
attributes: { exclude: ['password', 'email', 'phone', 'address'] }
attributes: { exclude: excludedAttributes }
});
if (!user) {
@@ -222,7 +236,8 @@ router.get('/:id', async (req, res, next) => {
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Public user profile fetched", {
requestedUserId: req.params.id
requestedUserId: req.params.id,
viewerIsAdmin: isAdmin
});
res.json(user);
@@ -258,7 +273,192 @@ router.put('/profile', authenticateToken, async (req, res, next) => {
res.json(updatedUser);
} catch (error) {
console.error('Profile update error:', error);
logger.error('Profile update error', { error });
next(error);
}
});
// Admin: Ban a user
router.post('/admin/:id/ban', authenticateToken, requireAdmin, async (req, res, next) => {
try {
const { reason } = req.body;
const targetUserId = req.params.id;
// Validate reason is provided
if (!reason || !reason.trim()) {
return res.status(400).json({ error: "Ban reason is required" });
}
// Prevent banning yourself
if (targetUserId === req.user.id) {
return res.status(400).json({ error: "You cannot ban yourself" });
}
const targetUser = await User.findByPk(targetUserId);
if (!targetUser) {
return res.status(404).json({ error: "User not found" });
}
// Prevent banning other admins
if (targetUser.role === 'admin') {
return res.status(403).json({ error: "Cannot ban admin users" });
}
// Check if already banned
if (targetUser.isBanned) {
return res.status(400).json({ error: "User is already banned" });
}
// Ban the user (this also invalidates sessions via jwtVersion increment)
await targetUser.banUser(req.user.id, reason.trim());
// Send ban notification email
try {
const emailServices = require("../services/email");
await emailServices.userEngagement.sendUserBannedNotification(
targetUser,
req.user,
reason.trim()
);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("User ban notification email sent", {
bannedUserId: targetUserId,
adminId: req.user.id
});
} catch (emailError) {
// Log but don't fail the ban operation
const reqLogger = logger.withRequestId(req.id);
reqLogger.error('Failed to send user ban notification email', {
error: emailError.message,
stack: emailError.stack,
bannedUserId: targetUserId,
adminId: req.user.id
});
}
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("User banned by admin", {
targetUserId,
adminId: req.user.id,
reason: reason.trim()
});
// Return updated user data (excluding sensitive fields)
const updatedUser = await User.findByPk(targetUserId, {
attributes: { exclude: ['password', 'verificationToken', 'passwordResetToken'] }
});
res.json({
message: "User has been banned successfully",
user: updatedUser
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Admin ban user failed", {
error: error.message,
stack: error.stack,
targetUserId: req.params.id,
adminId: req.user.id
});
next(error);
}
});
// Change password (requires step-up auth if 2FA is enabled)
router.put('/password', authenticateToken, csrfProtection, requireStepUpAuth('password_change'), sanitizeInput, validatePasswordChange, async (req, res, next) => {
try {
const { currentPassword, newPassword } = req.body;
const user = await User.findByPk(req.user.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Google OAuth users can't change password
if (user.authProvider === 'google' && !user.password) {
return res.status(400).json({
error: 'Cannot change password for accounts linked with Google'
});
}
// Verify current password
const isValid = await user.comparePassword(currentPassword);
if (!isValid) {
return res.status(400).json({ error: 'Current password is incorrect' });
}
// Update password (this increments jwtVersion to invalidate other sessions)
await user.resetPassword(newPassword);
// Send password changed notification
try {
await emailServices.auth.sendPasswordChangedEmail(user);
} catch (emailError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error('Failed to send password changed email', {
error: emailError.message,
userId: req.user.id
});
}
const reqLogger = logger.withRequestId(req.id);
reqLogger.info('Password changed successfully', { userId: req.user.id });
res.json({ message: 'Password changed successfully' });
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error('Password change failed', {
error: error.message,
stack: error.stack,
userId: req.user.id
});
next(error);
}
});
// Admin: Unban a user
router.post('/admin/:id/unban', authenticateToken, requireAdmin, async (req, res, next) => {
try {
const targetUserId = req.params.id;
const targetUser = await User.findByPk(targetUserId);
if (!targetUser) {
return res.status(404).json({ error: "User not found" });
}
// Check if user is actually banned
if (!targetUser.isBanned) {
return res.status(400).json({ error: "User is not banned" });
}
// Unban the user
await targetUser.unbanUser();
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("User unbanned by admin", {
targetUserId,
adminId: req.user.id
});
// Return updated user data (excluding sensitive fields)
const updatedUser = await User.findByPk(targetUserId, {
attributes: { exclude: ['password', 'verificationToken', 'passwordResetToken'] }
});
res.json({
message: "User has been unbanned successfully",
user: updatedUser
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Admin unban user failed", {
error: error.message,
stack: error.stack,
targetUserId: req.params.id,
adminId: req.user.id
});
next(error);
}
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
const { ConditionCheck, Rental, User } = require("../models");
const { Op } = require("sequelize");
const { isActive } = require("../utils/rentalStatus");
class ConditionCheckService {
/**
@@ -70,7 +71,7 @@ class ConditionCheckService {
canSubmit =
now >= timeWindow.start &&
now <= timeWindow.end &&
rental.status === "active";
isActive(rental);
break;
case "rental_end_renter":
@@ -80,7 +81,7 @@ class ConditionCheckService {
canSubmit =
now >= timeWindow.start &&
now <= timeWindow.end &&
rental.status === "active";
isActive(rental);
break;
case "post_rental_owner":
@@ -155,13 +156,21 @@ class ConditionCheckService {
}
/**
* Get all condition checks for a rental
* @param {string} rentalId - Rental ID
* Get all condition checks for multiple rentals (batch)
* @param {Array<string>} rentalIds - Array of Rental IDs
* @returns {Array} - Array of condition checks with user info
*/
static async getConditionChecks(rentalId) {
static async getConditionChecksForRentals(rentalIds) {
if (!rentalIds || rentalIds.length === 0) {
return [];
}
const checks = await ConditionCheck.findAll({
where: { rentalId },
where: {
rentalId: {
[Op.in]: rentalIds,
},
},
include: [
{
model: User,
@@ -175,119 +184,24 @@ class ConditionCheckService {
return checks;
}
/**
* Get condition check timeline for a rental
* @param {string} rentalId - Rental ID
* @returns {Object} - Timeline showing what checks are available/completed
*/
static async getConditionCheckTimeline(rentalId) {
const rental = await Rental.findByPk(rentalId);
if (!rental) {
throw new Error("Rental not found");
}
const existingChecks = await ConditionCheck.findAll({
where: { rentalId },
include: [
{
model: User,
as: "submittedByUser",
attributes: ["id", "firstName", "lastName"],
},
],
});
const checkTypes = [
"pre_rental_owner",
"rental_start_renter",
"rental_end_renter",
"post_rental_owner",
];
const timeline = {};
for (const checkType of checkTypes) {
const existingCheck = existingChecks.find(
(check) => check.checkType === checkType
);
if (existingCheck) {
timeline[checkType] = {
status: "completed",
submittedAt: existingCheck.submittedAt,
submittedBy: existingCheck.submittedBy,
photoCount: existingCheck.imageFilenames.length,
hasNotes: !!existingCheck.notes,
};
} else {
// Calculate if this check type is available
const now = new Date();
const startDate = new Date(rental.startDateTime);
const endDate = new Date(rental.endDateTime);
const twentyFourHours = 24 * 60 * 60 * 1000;
let timeWindow = {};
let status = "not_available";
switch (checkType) {
case "pre_rental_owner":
timeWindow.start = new Date(startDate.getTime() - twentyFourHours);
timeWindow.end = startDate;
break;
case "rental_start_renter":
timeWindow.start = startDate;
timeWindow.end = new Date(startDate.getTime() + twentyFourHours);
break;
case "rental_end_renter":
timeWindow.start = new Date(endDate.getTime() - twentyFourHours);
timeWindow.end = endDate;
break;
case "post_rental_owner":
timeWindow.start = endDate;
timeWindow.end = new Date(endDate.getTime() + twentyFourHours);
break;
}
if (now >= timeWindow.start && now <= timeWindow.end) {
status = "available";
} else if (now < timeWindow.start) {
status = "pending";
} else {
status = "expired";
}
timeline[checkType] = {
status,
timeWindow,
availableFrom: timeWindow.start,
availableUntil: timeWindow.end,
};
}
}
return {
rental: {
id: rental.id,
startDateTime: rental.startDateTime,
endDateTime: rental.endDateTime,
status: rental.status,
},
timeline,
};
}
/**
* Get available condition checks for a user
* @param {string} userId - User ID
* @param {Array<string>} rentalIds - Array of rental IDs to check
* @returns {Array} - Array of available condition checks
*/
static async getAvailableChecks(userId) {
static async getAvailableChecks(userId, rentalIds) {
if (!rentalIds || rentalIds.length === 0) {
return [];
}
const now = new Date();
const twentyFourHours = 24 * 60 * 60 * 1000;
// Find rentals where user is owner or renter
// Find specified rentals where user is owner or renter
const rentals = await Rental.findAll({
where: {
id: { [Op.in]: rentalIds },
[Op.or]: [{ ownerId: userId }, { renterId: userId }],
status: {
[Op.in]: ["confirmed", "active", "completed"],

View File

@@ -1,6 +1,7 @@
const { Rental, Item, ConditionCheck, User } = require("../models");
const LateReturnService = require("./lateReturnService");
const emailServices = require("./email");
const { isActive } = require("../utils/rentalStatus");
class DamageAssessmentService {
/**
@@ -34,7 +35,7 @@ class DamageAssessmentService {
throw new Error("Only the item owner can report damage");
}
if (rental.status !== "active") {
if (!isActive(rental)) {
throw new Error("Can only assess damage for active rentals");
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
const { Rental, Item, User } = require("../models");
const emailServices = require("./email");
const { isActive } = require("../utils/rentalStatus");
const logger = require("../utils/logger");
class LateReturnService {
/**
@@ -71,7 +73,7 @@ class LateReturnService {
throw new Error("Rental not found");
}
if (rental.status !== "active") {
if (!isActive(rental)) {
throw new Error("Can only process late returns for active rentals");
}
@@ -99,6 +101,18 @@ class LateReturnService {
);
}
// Trigger immediate payout if rental is verified to be actually completed not late
if (!lateCalculation.isLate) {
// Import here to avoid circular dependency
const PayoutService = require("./payoutService");
PayoutService.triggerPayoutOnCompletion(rentalId).catch((err) => {
logger.error("Error triggering payout on late return processing", {
rentalId,
error: err.message,
});
});
}
return {
rental: updatedRental,
lateCalculation,

View File

@@ -1,5 +1,6 @@
const { sequelize } = require("../models");
const { QueryTypes } = require("sequelize");
const logger = require("../utils/logger");
class LocationService {
/**
@@ -25,7 +26,7 @@ class LocationService {
// distance = 3959 * acos(cos(radians(lat1)) * cos(radians(lat2))
// * cos(radians(lng2) - radians(lng1))
// + sin(radians(lat1)) * sin(radians(lat2)))
// Note: 3959 is Earth's radius in miles
// 3959 is Earth's radius in miles
const query = `
SELECT * FROM (
SELECT
@@ -71,7 +72,7 @@ class LocationService {
distance: parseFloat(user.distance).toFixed(2), // Round to 2 decimal places
}));
} catch (error) {
console.error("Error finding users in radius:", error);
logger.error("Error finding users in radius", { error });
throw new Error(`Failed to find users in radius: ${error.message}`);
}
}

View File

@@ -1,9 +1,91 @@
const { Rental, User, Item } = require("../models");
const StripeService = require("./stripeService");
const emailServices = require("./email");
const logger = require("../utils/logger");
const { Op } = require("sequelize");
class PayoutService {
/**
* Attempt to process payout for a single rental immediately after completion.
* Checks if owner's Stripe account has payouts enabled before attempting.
* @param {string} rentalId - The rental ID to process
* @returns {Object} - { attempted, success, reason, transferId, amount }
*/
static async triggerPayoutOnCompletion(rentalId) {
try {
const rental = await Rental.findByPk(rentalId, {
include: [
{
model: User,
as: "owner",
attributes: ["id", "email", "firstName", "lastName", "stripeConnectedAccountId", "stripePayoutsEnabled"],
},
{ model: Item, as: "item" },
],
});
if (!rental) {
logger.warn("Rental not found for payout trigger", { rentalId });
return { attempted: false, success: false, reason: "rental_not_found" };
}
// Check eligibility conditions
if (rental.paymentStatus !== "paid") {
logger.info("Payout skipped: payment not paid", { rentalId, paymentStatus: rental.paymentStatus });
return { attempted: false, success: false, reason: "payment_not_paid" };
}
if (rental.payoutStatus !== "pending") {
logger.info("Payout skipped: payout not pending", { rentalId, payoutStatus: rental.payoutStatus });
return { attempted: false, success: false, reason: "payout_not_pending" };
}
if (!rental.owner?.stripeConnectedAccountId) {
logger.info("Payout skipped: owner has no Stripe account", { rentalId, ownerId: rental.ownerId });
return { attempted: false, success: false, reason: "no_stripe_account" };
}
// Check if owner has payouts enabled (onboarding complete)
if (!rental.owner.stripePayoutsEnabled) {
logger.info("Payout deferred: owner payouts not enabled, will process when onboarding completes", {
rentalId,
ownerId: rental.ownerId,
});
return { attempted: false, success: false, reason: "payouts_not_enabled" };
}
// Attempt the payout
const result = await this.processRentalPayout(rental);
logger.info("Payout triggered successfully on completion", {
rentalId,
transferId: result.transferId,
amount: result.amount,
});
return {
attempted: true,
success: true,
transferId: result.transferId,
amount: result.amount,
};
} catch (error) {
logger.error("Error triggering payout on completion", {
error: error.message,
stack: error.stack,
rentalId,
});
// Payout marked as failed by processRentalPayout, will be retried by daily retry job
return {
attempted: true,
success: false,
reason: "payout_failed",
error: error.message,
};
}
}
static async getEligiblePayouts() {
try {
const eligibleRentals = await Rental.findAll({
@@ -20,6 +102,7 @@ class PayoutService {
stripeConnectedAccountId: {
[Op.not]: null,
},
stripePayoutsEnabled: true,
},
},
{
@@ -31,7 +114,7 @@ class PayoutService {
return eligibleRentals;
} catch (error) {
console.error("Error getting eligible payouts:", error);
logger.error("Error getting eligible payouts", { error: error.message, stack: error.stack });
throw error;
}
}
@@ -78,15 +161,18 @@ class PayoutService {
// Send payout notification email to owner
try {
await emailServices.rentalFlow.sendPayoutReceivedEmail(rental.owner, rental);
console.log(
`Payout notification email sent to owner for rental ${rental.id}`
);
logger.info("Payout notification email sent to owner", {
rentalId: rental.id,
ownerId: rental.ownerId
});
} catch (emailError) {
// Log error but don't fail the payout
console.error(
`Failed to send payout notification email for rental ${rental.id}:`,
emailError.message
);
logger.error("Failed to send payout notification email", {
error: emailError.message,
stack: emailError.stack,
rentalId: rental.id,
ownerId: rental.ownerId
});
}
return {
@@ -95,7 +181,7 @@ class PayoutService {
amount: rental.payoutAmount,
};
} catch (error) {
console.error(`Error processing payout for rental ${rental.id}:`, error);
logger.error("Error processing payout for rental", { error: error.message, stack: error.stack, rentalId: rental.id });
// Update status to failed
await rental.update({
@@ -142,7 +228,7 @@ class PayoutService {
return results;
} catch (error) {
console.error("Error processing all eligible payouts:", error);
logger.error("Error processing all eligible payouts", { error: error.message, stack: error.stack });
throw error;
}
}
@@ -163,6 +249,7 @@ class PayoutService {
stripeConnectedAccountId: {
[Op.not]: null,
},
stripePayoutsEnabled: true,
},
},
{
@@ -205,7 +292,7 @@ class PayoutService {
return results;
} catch (error) {
console.error("Error retrying failed payouts:", error);
logger.error("Error retrying failed payouts", { error: error.message, stack: error.stack });
throw error;
}
}

View File

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

View File

@@ -6,6 +6,28 @@ const { Op } = require("sequelize");
* 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"
@@ -50,14 +72,16 @@ class S3OwnershipService {
/**
* Verify message image access - user must be sender OR receiver
* @param {string} key - S3 key
* @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: key,
imageFilename: baseKey,
[Op.or]: [{ senderId: userId }, { receiverId: userId }],
},
});
@@ -69,14 +93,16 @@ class S3OwnershipService {
/**
* Verify condition check image access - user must be rental owner OR renter
* @param {string} key - S3 key
* @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]: [key] },
imageFilenames: { [Op.contains]: [baseKey] },
},
include: [
{

View File

@@ -101,20 +101,30 @@ class S3Service {
region: this.region,
});
} catch (error) {
logger.error("Failed to initialize S3 Service", { error: error.message });
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)
* @returns {Promise<{uploadUrl: string, key: string, publicUrl: string, expiresAt: Date}>}
* @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) {
async getPresignedUploadUrl(uploadType, contentType, fileName, fileSize, baseKey = null) {
if (!this.enabled) {
throw new Error("S3 storage is not enabled");
}
@@ -135,13 +145,33 @@ class S3Service {
);
}
// Extract known variant suffix from fileName if present (e.g., "photo_th.jpg" -> "_th")
const ext = path.extname(fileName) || this.getExtFromMime(contentType);
const key = `${config.folder}/${uuidv4()}${ext}`;
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: key,
Key: uploadKey,
ContentType: contentType,
ContentLength: fileSize, // Enforce exact file size
CacheControl: `${cacheDirective}, max-age=${config.cacheMaxAge}`,
@@ -153,9 +183,10 @@ class S3Service {
return {
uploadUrl,
key,
key: finalKey, // Frontend stores this in database
stagingKey: useStaging ? uploadKey : null, // Actual upload location (if staging enabled)
publicUrl: config.public
? `https://${this.bucket}.s3.${this.region}.amazonaws.com/${key}`
? `https://${this.bucket}.s3.${this.region}.amazonaws.com/${finalKey}`
: null,
expiresAt: new Date(Date.now() + PRESIGN_EXPIRY * 1000),
};

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@
<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>Verify Your Email - Village Share</title>
<title>Verify Your Email</title>
<style>
/* Reset styles */
body,

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
@@ -34,8 +34,9 @@
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
@@ -246,8 +247,8 @@
<p>
<strong>Didn't change your password?</strong> If you did not make
this change, your account may be compromised. Please contact our
support team immediately at support@villageshare.app to secure your
account.
support team immediately at community-support@village-share.com to
secure your account.
</p>
</div>
@@ -255,7 +256,7 @@
<p>
<strong>Security reminder:</strong> Keep your password secure and
never share it with anyone. We recommend using a strong, unique
password and enabling two-factor authentication when available.
password and enabling multi-factor authentication when available.
</p>
</div>

View File

@@ -0,0 +1,308 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Payment Issue - Village Share</title>
<style>
/* Reset styles */
body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
}
/* Base styles */
body {
margin: 0;
padding: 0;
width: 100% !important;
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
/* Container */
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Header */
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #e9ecef;
font-size: 14px;
margin-top: 8px;
}
/* Content */
.content {
padding: 40px 30px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content h2 {
font-size: 20px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff !important;
text-decoration: none;
padding: 16px 32px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
text-align: center;
transition: all 0.3s ease;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
/* Warning box */
.warning-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.warning-box p {
margin: 0 0 10px 0;
color: #856404;
}
.warning-box p:last-child {
margin-bottom: 0;
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0 0 10px 0;
color: #004085;
}
.info-box p:last-child {
margin-bottom: 0;
}
/* Info table */
.info-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
background-color: #f8f9fa;
border-radius: 6px;
overflow: hidden;
}
.info-table th,
.info-table td {
padding: 15px;
text-align: left;
border-bottom: 1px solid #dee2e6;
}
.info-table th {
background-color: #e9ecef;
font-weight: 600;
color: #495057;
}
.info-table td {
color: #6c757d;
}
.info-table tr:last-child td {
border-bottom: none;
}
/* Footer */
.footer {
background-color: #f8f9fa;
padding: 30px;
text-align: center;
border-top: 1px solid #e9ecef;
}
.footer p {
margin: 0 0 10px 0;
font-size: 14px;
color: #6c757d;
}
.footer a {
color: #667eea;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
/* Responsive */
@media only screen and (max-width: 600px) {
.email-container {
margin: 0;
border-radius: 0;
}
.header,
.content,
.footer {
padding: 20px;
}
.logo {
font-size: 28px;
}
.content h1 {
font-size: 22px;
}
.button {
display: block;
width: 100%;
box-sizing: border-box;
}
.info-table th,
.info-table td {
padding: 10px;
font-size: 14px;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Action Required: Payment Issue</div>
</div>
<div class="content">
<p>Hi {{renterFirstName}},</p>
<div class="warning-box">
<p><strong>Payment Issue with Your Rental Request</strong></p>
<p>
The owner tried to approve your rental for
<strong>{{itemName}}</strong>, but there was an issue processing
your payment.
</p>
</div>
<h2>What Happened</h2>
<p>{{declineReason}}</p>
<div class="info-box">
<p><strong>What You Need To Do</strong></p>
<p>To update your payment method:</p>
<ol style="margin: 10px 0; padding-left: 20px; color: #004085">
<li>
Go directly to <strong>village-share.com</strong> in your browser
</li>
<li>Log in to your account</li>
<li>
Navigate to your rentals and find your pending request for
<strong>{{itemName}}</strong>
</li>
<li>Click "Update Payment Method" to enter new payment details</li>
</ol>
</div>
<p>
Once you update your payment method, the owner will be notified and
can try to approve your rental again.
</p>
<p>
If you have any questions or need assistance, please don't hesitate to
contact our support team.
</p>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>
This is a notification about your rental request. You received this
message because the owner tried to approve your rental but there was a
payment issue.
</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,257 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Payment Method Updated - Village Share</title>
<style>
/* Reset styles */
body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
}
/* Base styles */
body {
margin: 0;
padding: 0;
width: 100% !important;
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
/* Container */
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Header */
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #e9ecef;
font-size: 14px;
margin-top: 8px;
}
/* Content */
.content {
padding: 40px 30px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content h2 {
font-size: 20px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff !important;
text-decoration: none;
padding: 16px 32px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
text-align: center;
transition: all 0.3s ease;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
/* Success box */
.success-box {
background-color: #d4edda;
border-left: 4px solid #28a745;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.success-box p {
margin: 0 0 10px 0;
color: #155724;
}
.success-box p:last-child {
margin-bottom: 0;
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0 0 10px 0;
color: #004085;
}
.info-box p:last-child {
margin-bottom: 0;
}
/* Footer */
.footer {
background-color: #f8f9fa;
padding: 30px;
text-align: center;
border-top: 1px solid #e9ecef;
}
.footer p {
margin: 0 0 10px 0;
font-size: 14px;
color: #6c757d;
}
.footer a {
color: #667eea;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
/* Responsive */
@media only screen and (max-width: 600px) {
.email-container {
margin: 0;
border-radius: 0;
}
.header,
.content,
.footer {
padding: 20px;
}
.logo {
font-size: 28px;
}
.content h1 {
font-size: 22px;
}
.button {
display: block;
width: 100%;
box-sizing: border-box;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Payment Update</div>
</div>
<div class="content">
<p>Hi {{ownerFirstName}},</p>
<div class="success-box">
<p><strong>Payment Method Updated</strong></p>
<p>
The renter has updated their payment method for the rental of
<strong>{{itemName}}</strong>.
</p>
</div>
<div class="info-box">
<p><strong>Ready to Approve</strong></p>
<p>
You can now try approving the rental request again. The renter's new
payment method will be charged when you approve.
</p>
</div>
<div style="text-align: center">
<a href="{{approvalUrl}}" class="button">Review & Approve Rental</a>
</div>
<p>
If you have any questions or need assistance, please don't hesitate
to contact our support team.
</p>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>
This is a notification about a rental request for your item.
You received this message because the renter updated their payment method.
</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

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

View File

@@ -381,11 +381,27 @@
</tr>
</table>
<h2>Payout Timeline</h2>
<table class="info-table">
<tr>
<th style="color: #28a745;">✓ Rental Completed</th>
<td>Done</td>
</tr>
<tr>
<th style="color: #28a745;">✓ Transfer Initiated</th>
<td>Today</td>
</tr>
<tr>
<th style="color: #0066cc;">○ Funds in Your Bank</th>
<td>2-7 business days</td>
</tr>
</table>
<div class="info-box">
<p><strong>When will I receive the funds?</strong></p>
<p>
Funds are typically available in your bank account within
<strong>2-3 business days</strong> from the transfer date.
<strong>2-7 business days</strong> from the transfer date, depending on your bank and Stripe's payout schedule.
</p>
<p>
You can track this transfer in your Stripe Dashboard using the

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