Compare commits

...

123 Commits

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

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

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

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: {
@@ -34,7 +34,6 @@ const dbConfig = {
// Configuration for Sequelize CLI (supports multiple environments)
// All environments use the same configuration from environment variables
const cliConfig = {
development: dbConfig,
dev: dbConfig,
test: dbConfig,
qa: dbConfig,
@@ -53,7 +52,7 @@ const sequelize = new Sequelize(
dialect: dbConfig.dialect,
logging: dbConfig.logging,
pool: dbConfig.pool,
}
},
);
// Export the sequelize instance as default (for backward compatibility)

View File

@@ -0,0 +1,14 @@
/**
* Image upload limits configuration
* Keep in sync with frontend/src/config/imageLimits.ts
*/
const IMAGE_LIMITS = {
items: 10,
forum: 10,
conditionChecks: 10,
damageReports: 10,
profile: 1,
messages: 1,
};
module.exports = { IMAGE_LIMITS };

View File

@@ -1,18 +1,40 @@
module.exports = {
testEnvironment: 'node',
projects: [
{
displayName: 'unit',
testEnvironment: 'node',
testMatch: ['**/tests/unit/**/*.test.js'],
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
testTimeout: 10000,
transformIgnorePatterns: [
'node_modules/(?!(@scure|@otplib|otplib|@noble)/)'
],
},
{
displayName: 'integration',
testEnvironment: 'node',
testMatch: ['**/tests/integration/**/*.test.js'],
setupFilesAfterEnv: ['<rootDir>/tests/integration-setup.js'],
testTimeout: 30000,
transformIgnorePatterns: [
'node_modules/(?!(@scure|@otplib|otplib|@noble)/)'
],
},
],
// Run tests sequentially to avoid module cache conflicts between unit and integration tests
maxWorkers: 1,
coverageDirectory: 'coverage',
collectCoverageFrom: [
'**/*.js',
'!**/node_modules/**',
'!**/coverage/**',
'!**/tests/**',
'!jest.config.js'
'!**/migrations/**',
'!**/scripts/**',
'!jest.config.js',
'!babel.config.js',
],
coverageReporters: ['text', 'lcov', 'html'],
testMatch: ['**/tests/**/*.test.js'],
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
forceExit: true,
testTimeout: 10000,
coverageThreshold: {
global: {
lines: 80,

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

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

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,12 +79,13 @@ const getCSRFToken = (req, res) => {
res.cookie("csrf-token", token, {
httpOnly: true,
secure: process.env.NODE_ENV !== "dev",
secure: ["production", "prod", "qa"].includes(process.env.NODE_ENV),
sameSite: "strict",
maxAge: 60 * 60 * 1000,
});
res.json({ csrfToken: token });
res.set("X-CSRF-Token", token);
res.status(204).send();
};
module.exports = {

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) => {
@@ -104,6 +105,28 @@ const burstProtection = createUserBasedRateLimiter(
"Too many requests in a short period. Please slow down."
);
// Upload presign rate limiter - 30 requests per minute
const uploadPresignLimiter = createUserBasedRateLimiter(
60 * 1000, // 1 minute window
30, // 30 presign requests per minute per user
"Too many upload requests. Please slow down."
);
// Helper to create a rate limit handler that logs the event
const createRateLimitHandler = (limiterName) => (req, res, next, options) => {
const reqLogger = logger.withRequestId(req.id);
reqLogger.warn('Rate limit exceeded', {
limiter: limiterName,
ip: req.ip,
userId: req.user?.id || 'anonymous',
method: req.method,
url: req.url,
userAgent: req.get('User-Agent'),
message: options.message?.error || 'Rate limit exceeded'
});
res.status(options.statusCode).json(options.message);
};
// Authentication rate limiters
const authRateLimiters = {
// Login rate limiter - stricter to prevent brute force
@@ -117,6 +140,7 @@ const authRateLimiters = {
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: true, // Don't count successful logins
handler: createRateLimitHandler('login'),
}),
// Registration rate limiter
@@ -129,6 +153,7 @@ const authRateLimiters = {
},
standardHeaders: true,
legacyHeaders: false,
handler: createRateLimitHandler('register'),
}),
// Password reset rate limiter
@@ -141,6 +166,7 @@ const authRateLimiters = {
},
standardHeaders: true,
legacyHeaders: false,
handler: createRateLimitHandler('passwordReset'),
}),
// Alpha code validation rate limiter
@@ -153,6 +179,20 @@ const authRateLimiters = {
},
standardHeaders: true,
legacyHeaders: false,
handler: createRateLimitHandler('alphaCodeValidation'),
}),
// Email verification rate limiter - protect against brute force on 6-digit codes
emailVerification: rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // 10 verification attempts per 15 minutes per IP
message: {
error: "Too many verification attempts. Please try again later.",
retryAfter: 900,
},
standardHeaders: true,
legacyHeaders: false,
handler: createRateLimitHandler('emailVerification'),
}),
// General API rate limiter
@@ -165,6 +205,58 @@ const authRateLimiters = {
},
standardHeaders: true,
legacyHeaders: false,
handler: createRateLimitHandler('general'),
}),
// Two-Factor Authentication rate limiters
twoFactorVerification: rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // 10 verification attempts per 15 minutes
message: {
error: "Too many verification attempts. Please try again later.",
retryAfter: 900,
},
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: true,
handler: createRateLimitHandler('twoFactorVerification'),
}),
twoFactorSetup: rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5, // 5 setup attempts per hour
message: {
error: "Too many setup attempts. Please try again later.",
retryAfter: 3600,
},
standardHeaders: true,
legacyHeaders: false,
handler: createRateLimitHandler('twoFactorSetup'),
}),
recoveryCode: rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 3, // 3 recovery code attempts per 15 minutes
message: {
error: "Too many recovery code attempts. Please try again later.",
retryAfter: 900,
},
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: false, // Count all attempts for security
handler: createRateLimitHandler('recoveryCode'),
}),
emailOtpSend: rateLimit({
windowMs: 10 * 60 * 1000, // 10 minutes
max: 2, // 2 OTP sends per 10 minutes
message: {
error: "Please wait before requesting another code.",
retryAfter: 600,
},
standardHeaders: true,
legacyHeaders: false,
handler: createRateLimitHandler('emailOtpSend'),
}),
};
@@ -179,11 +271,21 @@ module.exports = {
registerLimiter: authRateLimiters.register,
passwordResetLimiter: authRateLimiters.passwordReset,
alphaCodeValidationLimiter: authRateLimiters.alphaCodeValidation,
emailVerificationLimiter: authRateLimiters.emailVerification,
generalLimiter: authRateLimiters.general,
// Two-Factor Authentication rate limiters
twoFactorVerificationLimiter: authRateLimiters.twoFactorVerification,
twoFactorSetupLimiter: authRateLimiters.twoFactorSetup,
recoveryCodeLimiter: authRateLimiters.recoveryCode,
emailOtpSendLimiter: authRateLimiters.emailOtpSend,
// Burst protection
burstProtection,
// Upload rate limiter
uploadPresignLimiter,
// Utility functions
createMapsRateLimiter,
createUserBasedRateLimiter,

View File

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

View File

@@ -1,94 +0,0 @@
const multer = require('multer');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
// Configure storage for profile images
const profileImageStorage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, path.join(__dirname, '../uploads/profiles'));
},
filename: function (req, file, cb) {
// Generate unique filename: uuid + original extension
const uniqueId = uuidv4();
const ext = path.extname(file.originalname);
cb(null, `${uniqueId}${ext}`);
}
});
// File filter to accept only images
const imageFileFilter = (req, file, cb) => {
// Accept images only
const allowedMimes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
if (allowedMimes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Invalid file type. Only JPEG, PNG, GIF and WebP images are allowed.'), false);
}
};
// Create multer upload middleware for profile images
const uploadProfileImage = multer({
storage: profileImageStorage,
fileFilter: imageFileFilter,
limits: {
fileSize: 5 * 1024 * 1024 // 5MB limit
}
}).single('profileImage');
// Configure storage for message images
const messageImageStorage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, path.join(__dirname, '../uploads/messages'));
},
filename: function (req, file, cb) {
// Generate unique filename: uuid + original extension
const uniqueId = uuidv4();
const ext = path.extname(file.originalname);
cb(null, `${uniqueId}${ext}`);
}
});
// Create multer upload middleware for message images
const uploadMessageImage = multer({
storage: messageImageStorage,
fileFilter: imageFileFilter,
limits: {
fileSize: 5 * 1024 * 1024 // 5MB limit
}
}).single('image');
// Configure storage for forum images
const forumImageStorage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, path.join(__dirname, '../uploads/forum'));
},
filename: function (req, file, cb) {
const uniqueId = uuidv4();
const ext = path.extname(file.originalname);
cb(null, `${uniqueId}${ext}`);
}
});
// Factory function to create forum image upload middleware
const createForumImageUpload = (maxFiles) => {
return multer({
storage: forumImageStorage,
fileFilter: imageFileFilter,
limits: {
fileSize: 5 * 1024 * 1024 // 5MB limit per file
}
}).array('images', maxFiles);
};
// Create multer upload middleware for forum post images (up to 5 images)
const uploadForumPostImages = createForumImageUpload(5);
// Create multer upload middleware for forum comment images (up to 3 images)
const uploadForumCommentImages = createForumImageUpload(3);
module.exports = {
uploadProfileImage,
uploadMessageImage,
uploadForumPostImages,
uploadForumCommentImages
};

View File

@@ -1,4 +1,4 @@
const { body, validationResult } = require("express-validator");
const { body, query, validationResult } = require("express-validator");
const DOMPurify = require("dompurify");
const { 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

@@ -0,0 +1,157 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable("Users", {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
email: {
type: Sequelize.STRING,
unique: true,
allowNull: false,
},
password: {
type: Sequelize.STRING,
allowNull: true,
},
firstName: {
type: Sequelize.STRING,
allowNull: false,
},
lastName: {
type: Sequelize.STRING,
allowNull: false,
},
phone: {
type: Sequelize.STRING,
allowNull: true,
},
authProvider: {
type: Sequelize.ENUM("local", "google"),
defaultValue: "local",
},
providerId: {
type: Sequelize.STRING,
allowNull: true,
},
address1: {
type: Sequelize.STRING,
},
address2: {
type: Sequelize.STRING,
},
city: {
type: Sequelize.STRING,
},
state: {
type: Sequelize.STRING,
},
zipCode: {
type: Sequelize.STRING,
},
country: {
type: Sequelize.STRING,
},
profileImage: {
type: Sequelize.STRING,
},
isVerified: {
type: Sequelize.BOOLEAN,
defaultValue: false,
},
verificationToken: {
type: Sequelize.STRING,
allowNull: true,
},
verificationTokenExpiry: {
type: Sequelize.DATE,
allowNull: true,
},
verifiedAt: {
type: Sequelize.DATE,
allowNull: true,
},
passwordResetToken: {
type: Sequelize.STRING,
allowNull: true,
},
passwordResetTokenExpiry: {
type: Sequelize.DATE,
allowNull: true,
},
defaultAvailableAfter: {
type: Sequelize.STRING,
defaultValue: "09:00",
},
defaultAvailableBefore: {
type: Sequelize.STRING,
defaultValue: "17:00",
},
defaultSpecifyTimesPerDay: {
type: Sequelize.BOOLEAN,
defaultValue: false,
},
defaultWeeklyTimes: {
type: Sequelize.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" },
},
},
stripeConnectedAccountId: {
type: Sequelize.STRING,
allowNull: true,
},
stripeCustomerId: {
type: Sequelize.STRING,
allowNull: true,
},
loginAttempts: {
type: Sequelize.INTEGER,
defaultValue: 0,
},
lockUntil: {
type: Sequelize.DATE,
allowNull: true,
},
jwtVersion: {
type: Sequelize.INTEGER,
defaultValue: 0,
allowNull: false,
},
role: {
type: Sequelize.ENUM("user", "admin"),
defaultValue: "user",
allowNull: false,
},
itemRequestNotificationRadius: {
type: Sequelize.INTEGER,
defaultValue: 10,
allowNull: true,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
// Add indexes
await queryInterface.addIndex("Users", ["email"], { unique: true });
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable("Users");
},
};

View File

@@ -0,0 +1,60 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable("AlphaInvitations", {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
code: {
type: Sequelize.STRING,
unique: true,
allowNull: false,
},
email: {
type: Sequelize.STRING,
unique: true,
allowNull: false,
},
status: {
type: Sequelize.ENUM("pending", "active", "revoked"),
defaultValue: "pending",
},
usedBy: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: "Users",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "SET NULL",
},
usedAt: {
type: Sequelize.DATE,
allowNull: true,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
// Add indexes
await queryInterface.addIndex("AlphaInvitations", ["code"], {
unique: true,
});
await queryInterface.addIndex("AlphaInvitations", ["email"]);
await queryInterface.addIndex("AlphaInvitations", ["status"]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable("AlphaInvitations");
},
};

View File

@@ -0,0 +1,168 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable("Items", {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
name: {
type: Sequelize.STRING,
allowNull: false,
},
description: {
type: Sequelize.TEXT,
allowNull: true,
},
pickUpAvailable: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
},
localDeliveryAvailable: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
},
localDeliveryRadius: {
type: Sequelize.INTEGER,
},
shippingAvailable: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
},
inPlaceUseAvailable: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
},
pricePerHour: {
type: Sequelize.DECIMAL(10, 2),
},
pricePerDay: {
type: Sequelize.DECIMAL(10, 2),
},
pricePerWeek: {
type: Sequelize.DECIMAL(10, 2),
},
pricePerMonth: {
type: Sequelize.DECIMAL(10, 2),
},
replacementCost: {
type: Sequelize.DECIMAL(10, 2),
allowNull: false,
},
address1: {
type: Sequelize.STRING,
},
address2: {
type: Sequelize.STRING,
},
city: {
type: Sequelize.STRING,
},
state: {
type: Sequelize.STRING,
},
zipCode: {
type: Sequelize.STRING,
},
country: {
type: Sequelize.STRING,
},
latitude: {
type: Sequelize.DECIMAL(10, 8),
},
longitude: {
type: Sequelize.DECIMAL(11, 8),
},
images: {
type: Sequelize.ARRAY(Sequelize.STRING),
defaultValue: [],
},
isAvailable: {
type: Sequelize.BOOLEAN,
defaultValue: true,
},
rules: {
type: Sequelize.TEXT,
},
availableAfter: {
type: Sequelize.STRING,
defaultValue: "09:00",
},
availableBefore: {
type: Sequelize.STRING,
defaultValue: "17:00",
},
specifyTimesPerDay: {
type: Sequelize.BOOLEAN,
defaultValue: false,
},
weeklyTimes: {
type: Sequelize.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" },
},
},
ownerId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "Users",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "CASCADE",
},
isDeleted: {
type: Sequelize.BOOLEAN,
defaultValue: false,
},
deletedBy: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: "Users",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "SET NULL",
},
deletedAt: {
type: Sequelize.DATE,
allowNull: true,
},
deletionReason: {
type: Sequelize.TEXT,
allowNull: true,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
// Add indexes
await queryInterface.addIndex("Items", ["ownerId"]);
await queryInterface.addIndex("Items", ["isAvailable"]);
await queryInterface.addIndex("Items", ["isDeleted"]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable("Items");
},
};

View File

@@ -0,0 +1,72 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable("UserAddresses", {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
userId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "Users",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "CASCADE",
},
address1: {
type: Sequelize.STRING,
allowNull: false,
},
address2: {
type: Sequelize.STRING,
},
city: {
type: Sequelize.STRING,
allowNull: false,
},
state: {
type: Sequelize.STRING,
allowNull: false,
},
zipCode: {
type: Sequelize.STRING,
allowNull: false,
},
country: {
type: Sequelize.STRING,
allowNull: false,
defaultValue: "US",
},
latitude: {
type: Sequelize.DECIMAL(10, 8),
},
longitude: {
type: Sequelize.DECIMAL(11, 8),
},
isPrimary: {
type: Sequelize.BOOLEAN,
defaultValue: false,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
// Add indexes
await queryInterface.addIndex("UserAddresses", ["userId"]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable("UserAddresses");
},
};

View File

@@ -0,0 +1,210 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable("Rentals", {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
itemId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "Items",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "CASCADE",
},
renterId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "Users",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "CASCADE",
},
ownerId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "Users",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "CASCADE",
},
startDateTime: {
type: Sequelize.DATE,
allowNull: false,
},
endDateTime: {
type: Sequelize.DATE,
allowNull: false,
},
totalAmount: {
type: Sequelize.DECIMAL(10, 2),
allowNull: false,
},
platformFee: {
type: Sequelize.DECIMAL(10, 2),
allowNull: false,
},
payoutAmount: {
type: Sequelize.DECIMAL(10, 2),
allowNull: false,
},
status: {
type: Sequelize.ENUM(
"pending",
"confirmed",
"declined",
"active",
"completed",
"cancelled",
"returned_late",
"returned_late_and_damaged",
"damaged",
"lost"
),
allowNull: false,
},
paymentStatus: {
type: Sequelize.ENUM("pending", "paid", "refunded", "not_required"),
allowNull: false,
},
payoutStatus: {
type: Sequelize.ENUM("pending", "completed", "failed"),
allowNull: true,
},
payoutProcessedAt: {
type: Sequelize.DATE,
},
stripeTransferId: {
type: Sequelize.STRING,
},
refundAmount: {
type: Sequelize.DECIMAL(10, 2),
},
refundProcessedAt: {
type: Sequelize.DATE,
},
refundReason: {
type: Sequelize.TEXT,
},
stripeRefundId: {
type: Sequelize.STRING,
},
cancelledBy: {
type: Sequelize.ENUM("renter", "owner"),
},
cancelledAt: {
type: Sequelize.DATE,
},
declineReason: {
type: Sequelize.TEXT,
},
stripePaymentMethodId: {
type: Sequelize.STRING,
},
stripePaymentIntentId: {
type: Sequelize.STRING,
},
paymentMethodBrand: {
type: Sequelize.STRING,
},
paymentMethodLast4: {
type: Sequelize.STRING,
},
chargedAt: {
type: Sequelize.DATE,
},
deliveryMethod: {
type: Sequelize.ENUM("pickup", "delivery"),
defaultValue: "pickup",
},
deliveryAddress: {
type: Sequelize.TEXT,
},
intendedUse: {
type: Sequelize.TEXT,
},
itemRating: {
type: Sequelize.INTEGER,
},
itemReview: {
type: Sequelize.TEXT,
},
itemReviewSubmittedAt: {
type: Sequelize.DATE,
},
itemReviewVisible: {
type: Sequelize.BOOLEAN,
defaultValue: false,
},
renterRating: {
type: Sequelize.INTEGER,
},
renterReview: {
type: Sequelize.TEXT,
},
renterReviewSubmittedAt: {
type: Sequelize.DATE,
},
renterReviewVisible: {
type: Sequelize.BOOLEAN,
defaultValue: false,
},
itemPrivateMessage: {
type: Sequelize.TEXT,
},
renterPrivateMessage: {
type: Sequelize.TEXT,
},
actualReturnDateTime: {
type: Sequelize.DATE,
},
lateFees: {
type: Sequelize.DECIMAL(10, 2),
defaultValue: 0.0,
},
damageFees: {
type: Sequelize.DECIMAL(10, 2),
defaultValue: 0.0,
},
replacementFees: {
type: Sequelize.DECIMAL(10, 2),
defaultValue: 0.0,
},
itemLostReportedAt: {
type: Sequelize.DATE,
},
damageAssessment: {
type: Sequelize.JSONB,
defaultValue: {},
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
// Add indexes
await queryInterface.addIndex("Rentals", ["itemId"]);
await queryInterface.addIndex("Rentals", ["renterId"]);
await queryInterface.addIndex("Rentals", ["ownerId"]);
await queryInterface.addIndex("Rentals", ["status"]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable("Rentals");
},
};

View File

@@ -0,0 +1,70 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable("ConditionChecks", {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
rentalId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "Rentals",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "CASCADE",
},
submittedBy: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "Users",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "CASCADE",
},
checkType: {
type: Sequelize.ENUM(
"pre_rental_owner",
"rental_start_renter",
"rental_end_renter",
"post_rental_owner"
),
allowNull: false,
},
photos: {
type: Sequelize.ARRAY(Sequelize.STRING),
defaultValue: [],
},
notes: {
type: Sequelize.TEXT,
},
submittedAt: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.NOW,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
// Add indexes
await queryInterface.addIndex("ConditionChecks", ["rentalId"]);
await queryInterface.addIndex("ConditionChecks", ["submittedBy"]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable("ConditionChecks");
},
};

View File

@@ -0,0 +1,61 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable("Messages", {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
senderId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "Users",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "CASCADE",
},
receiverId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "Users",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "CASCADE",
},
content: {
type: Sequelize.TEXT,
allowNull: false,
},
isRead: {
type: Sequelize.BOOLEAN,
defaultValue: false,
},
imagePath: {
type: Sequelize.STRING,
allowNull: true,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
// Add indexes
await queryInterface.addIndex("Messages", ["senderId"]);
await queryInterface.addIndex("Messages", ["receiverId"]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable("Messages");
},
};

View File

@@ -0,0 +1,129 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable("ForumPosts", {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
authorId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "Users",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "CASCADE",
},
title: {
type: Sequelize.STRING,
allowNull: false,
},
content: {
type: Sequelize.TEXT,
allowNull: false,
},
category: {
type: Sequelize.ENUM(
"item_request",
"technical_support",
"community_resources",
"general_discussion"
),
allowNull: false,
defaultValue: "general_discussion",
},
status: {
type: Sequelize.ENUM("open", "answered", "closed"),
defaultValue: "open",
},
viewCount: {
type: Sequelize.INTEGER,
defaultValue: 0,
},
commentCount: {
type: Sequelize.INTEGER,
defaultValue: 0,
},
zipCode: {
type: Sequelize.STRING,
},
latitude: {
type: Sequelize.DECIMAL(10, 8),
},
longitude: {
type: Sequelize.DECIMAL(11, 8),
},
acceptedAnswerId: {
type: Sequelize.UUID,
allowNull: true,
},
images: {
type: Sequelize.ARRAY(Sequelize.TEXT),
allowNull: true,
defaultValue: [],
},
isPinned: {
type: Sequelize.BOOLEAN,
defaultValue: false,
},
isDeleted: {
type: Sequelize.BOOLEAN,
defaultValue: false,
},
deletedBy: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: "Users",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "SET NULL",
},
deletedAt: {
type: Sequelize.DATE,
allowNull: true,
},
deletionReason: {
type: Sequelize.TEXT,
allowNull: true,
},
closedBy: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: "Users",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "SET NULL",
},
closedAt: {
type: Sequelize.DATE,
allowNull: true,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
// Add indexes
await queryInterface.addIndex("ForumPosts", ["authorId"]);
await queryInterface.addIndex("ForumPosts", ["category"]);
await queryInterface.addIndex("ForumPosts", ["status"]);
await queryInterface.addIndex("ForumPosts", ["isDeleted"]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable("ForumPosts");
},
};

View File

@@ -0,0 +1,92 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable("ForumComments", {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
postId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "ForumPosts",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "CASCADE",
},
authorId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "Users",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "CASCADE",
},
parentCommentId: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: "ForumComments",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "SET NULL",
},
content: {
type: Sequelize.TEXT,
allowNull: false,
},
images: {
type: Sequelize.ARRAY(Sequelize.TEXT),
allowNull: true,
defaultValue: [],
},
isDeleted: {
type: Sequelize.BOOLEAN,
defaultValue: false,
},
deletedBy: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: "Users",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "SET NULL",
},
deletedAt: {
type: Sequelize.DATE,
allowNull: true,
},
deletionReason: {
type: Sequelize.TEXT,
allowNull: true,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
// Add indexes
await queryInterface.addIndex("ForumComments", ["postId"]);
await queryInterface.addIndex("ForumComments", ["authorId"]);
await queryInterface.addIndex("ForumComments", ["parentCommentId"]);
await queryInterface.addIndex("ForumComments", ["isDeleted"]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable("ForumComments");
},
};

View File

@@ -0,0 +1,25 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
// Add foreign key constraint for acceptedAnswerId
await queryInterface.addConstraint("ForumPosts", {
fields: ["acceptedAnswerId"],
type: "foreign key",
name: "ForumPosts_acceptedAnswerId_fkey",
references: {
table: "ForumComments",
field: "id",
},
onDelete: "SET NULL",
onUpdate: "CASCADE",
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeConstraint(
"ForumPosts",
"ForumPosts_acceptedAnswerId_fkey"
);
},
};

View File

@@ -0,0 +1,43 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable("PostTags", {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
postId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "ForumPosts",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "CASCADE",
},
tagName: {
type: Sequelize.STRING,
allowNull: false,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
// Add indexes
await queryInterface.addIndex("PostTags", ["postId"]);
await queryInterface.addIndex("PostTags", ["tagName"]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable("PostTags");
},
};

View File

@@ -0,0 +1,50 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable("Feedbacks", {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
userId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "Users",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "CASCADE",
},
feedbackText: {
type: Sequelize.TEXT,
allowNull: false,
},
userAgent: {
type: Sequelize.STRING,
allowNull: true,
},
url: {
type: Sequelize.STRING(500),
allowNull: true,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
// Add indexes
await queryInterface.addIndex("Feedbacks", ["userId"]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable("Feedbacks");
},
};

View File

@@ -0,0 +1,19 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
// Change images column from VARCHAR(255)[] to TEXT[] to support longer URLs
await queryInterface.changeColumn("Items", "images", {
type: Sequelize.ARRAY(Sequelize.TEXT),
defaultValue: [],
});
},
down: async (queryInterface, Sequelize) => {
// Revert to original VARCHAR(255)[]
await queryInterface.changeColumn("Items", "images", {
type: Sequelize.ARRAY(Sequelize.STRING),
defaultValue: [],
});
},
};

View File

@@ -0,0 +1,39 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
// Change image/photo URL fields from VARCHAR(255) to TEXT to support longer URLs
await Promise.all([
queryInterface.changeColumn("Users", "profileImage", {
type: Sequelize.TEXT,
allowNull: true,
}),
queryInterface.changeColumn("Messages", "imagePath", {
type: Sequelize.TEXT,
allowNull: true,
}),
queryInterface.changeColumn("ConditionChecks", "photos", {
type: Sequelize.ARRAY(Sequelize.TEXT),
defaultValue: [],
}),
]);
},
down: async (queryInterface, Sequelize) => {
// Revert to original VARCHAR(255)
await Promise.all([
queryInterface.changeColumn("Users", "profileImage", {
type: Sequelize.STRING,
allowNull: true,
}),
queryInterface.changeColumn("Messages", "imagePath", {
type: Sequelize.STRING,
allowNull: true,
}),
queryInterface.changeColumn("ConditionChecks", "photos", {
type: Sequelize.ARRAY(Sequelize.STRING),
defaultValue: [],
}),
]);
},
};

View File

@@ -0,0 +1,24 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
// Rename image fields to consistent naming convention
// Using TEXT type for all to support long URLs/paths
await queryInterface.renameColumn("Items", "images", "imageFilenames");
await queryInterface.renameColumn("Users", "profileImage", "imageFilename");
await queryInterface.renameColumn("Messages", "imagePath", "imageFilename");
await queryInterface.renameColumn("ConditionChecks", "photos", "imageFilenames");
await queryInterface.renameColumn("ForumPosts", "images", "imageFilenames");
await queryInterface.renameColumn("ForumComments", "images", "imageFilenames");
},
down: async (queryInterface, Sequelize) => {
// Revert to original column names
await queryInterface.renameColumn("Items", "imageFilenames", "images");
await queryInterface.renameColumn("Users", "imageFilename", "profileImage");
await queryInterface.renameColumn("Messages", "imageFilename", "imagePath");
await queryInterface.renameColumn("ConditionChecks", "imageFilenames", "photos");
await queryInterface.renameColumn("ForumPosts", "imageFilenames", "images");
await queryInterface.renameColumn("ForumComments", "imageFilenames", "images");
},
};

View File

@@ -0,0 +1,15 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn("Users", "verificationAttempts", {
type: Sequelize.INTEGER,
defaultValue: 0,
allowNull: true,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn("Users", "verificationAttempts");
},
};

View File

@@ -0,0 +1,21 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.changeColumn("Messages", "content", {
type: Sequelize.TEXT,
allowNull: true,
});
},
down: async (queryInterface, Sequelize) => {
// First update any null content to empty string before reverting
await queryInterface.sequelize.query(
`UPDATE "Messages" SET content = '' WHERE content IS NULL`
);
await queryInterface.changeColumn("Messages", "content", {
type: Sequelize.TEXT,
allowNull: false,
});
},
};

View File

@@ -0,0 +1,20 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
// Add index on latitude and longitude columns for faster geospatial queries
// This improves performance of the bounding box pre-filter used in radius searches
await queryInterface.addIndex('Items', ['latitude', 'longitude'], {
name: 'idx_items_lat_lng',
where: {
latitude: { [Sequelize.Op.ne]: null },
longitude: { [Sequelize.Op.ne]: null }
}
});
},
async down(queryInterface, Sequelize) {
await queryInterface.removeIndex('Items', 'idx_items_lat_lng');
}
};

View File

@@ -0,0 +1,15 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn("Users", "stripePayoutsEnabled", {
type: Sequelize.BOOLEAN,
defaultValue: false,
allowNull: true,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn("Users", "stripePayoutsEnabled");
},
};

View File

@@ -0,0 +1,42 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
// Add bankDepositStatus enum column
await queryInterface.addColumn("Rentals", "bankDepositStatus", {
type: Sequelize.ENUM("pending", "in_transit", "paid", "failed", "canceled"),
allowNull: true,
defaultValue: null,
});
// Add bankDepositAt timestamp
await queryInterface.addColumn("Rentals", "bankDepositAt", {
type: Sequelize.DATE,
allowNull: true,
});
// Add stripePayoutId to track which Stripe payout included this transfer
await queryInterface.addColumn("Rentals", "stripePayoutId", {
type: Sequelize.STRING,
allowNull: true,
});
// Add bankDepositFailureCode for failed deposits
await queryInterface.addColumn("Rentals", "bankDepositFailureCode", {
type: Sequelize.STRING,
allowNull: true,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn("Rentals", "bankDepositFailureCode");
await queryInterface.removeColumn("Rentals", "stripePayoutId");
await queryInterface.removeColumn("Rentals", "bankDepositAt");
await queryInterface.removeColumn("Rentals", "bankDepositStatus");
// Drop the enum type (PostgreSQL specific)
await queryInterface.sequelize.query(
'DROP TYPE IF EXISTS "enum_Rentals_bankDepositStatus";'
);
},
};

View File

@@ -0,0 +1,30 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
// Add paymentFailedNotifiedAt - tracks when owner notified renter about failed payment
await queryInterface.addColumn("Rentals", "paymentFailedNotifiedAt", {
type: Sequelize.DATE,
allowNull: true,
});
// Add paymentMethodUpdatedAt - tracks last payment method update for rate limiting
await queryInterface.addColumn("Rentals", "paymentMethodUpdatedAt", {
type: Sequelize.DATE,
allowNull: true,
});
// Add paymentMethodUpdateCount - count of updates within time window for rate limiting
await queryInterface.addColumn("Rentals", "paymentMethodUpdateCount", {
type: Sequelize.INTEGER,
allowNull: true,
defaultValue: 0,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn("Rentals", "paymentMethodUpdateCount");
await queryInterface.removeColumn("Rentals", "paymentMethodUpdatedAt");
await queryInterface.removeColumn("Rentals", "paymentFailedNotifiedAt");
},
};

View File

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

View File

@@ -0,0 +1,41 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
// isBanned - boolean flag indicating if user is banned
await queryInterface.addColumn("Users", "isBanned", {
type: Sequelize.BOOLEAN,
defaultValue: false,
allowNull: false,
});
// bannedAt - timestamp when ban was applied
await queryInterface.addColumn("Users", "bannedAt", {
type: Sequelize.DATE,
allowNull: true,
});
// bannedBy - UUID of admin who applied the ban
await queryInterface.addColumn("Users", "bannedBy", {
type: Sequelize.UUID,
allowNull: true,
references: {
model: "Users",
key: "id",
},
});
// banReason - reason provided by admin for the ban
await queryInterface.addColumn("Users", "banReason", {
type: Sequelize.TEXT,
allowNull: true,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn("Users", "banReason");
await queryInterface.removeColumn("Users", "bannedBy");
await queryInterface.removeColumn("Users", "bannedAt");
await queryInterface.removeColumn("Users", "isBanned");
},
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -46,10 +46,6 @@ const AlphaInvitation = sequelize.define(
defaultValue: "pending",
allowNull: false,
},
notes: {
type: DataTypes.TEXT,
allowNull: true,
},
},
{
indexes: [

View File

@@ -24,8 +24,8 @@ const ConditionCheck = sequelize.define("ConditionCheck", {
),
allowNull: false,
},
photos: {
type: DataTypes.ARRAY(DataTypes.STRING),
imageFilenames: {
type: DataTypes.ARRAY(DataTypes.TEXT),
defaultValue: [],
},
notes: {
@@ -44,10 +44,6 @@ const ConditionCheck = sequelize.define("ConditionCheck", {
allowNull: false,
defaultValue: DataTypes.NOW,
},
metadata: {
type: DataTypes.JSONB,
defaultValue: {},
},
});
module.exports = ConditionCheck;

View File

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

View File

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

View File

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

View File

@@ -82,40 +82,24 @@ const Item = sequelize.define("Item", {
longitude: {
type: DataTypes.DECIMAL(11, 8),
},
images: {
type: DataTypes.ARRAY(DataTypes.STRING),
imageFilenames: {
type: DataTypes.ARRAY(DataTypes.TEXT),
defaultValue: [],
},
availability: {
isAvailable: {
type: DataTypes.BOOLEAN,
defaultValue: true,
},
specifications: {
type: DataTypes.JSONB,
defaultValue: {},
},
rules: {
type: DataTypes.TEXT,
},
minimumRentalDays: {
type: DataTypes.INTEGER,
defaultValue: 1,
},
maximumRentalDays: {
type: DataTypes.INTEGER,
},
needsTraining: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
availableAfter: {
type: DataTypes.STRING,
defaultValue: "09:00",
defaultValue: "00:00",
},
availableBefore: {
type: DataTypes.STRING,
defaultValue: "17:00",
defaultValue: "23:00",
},
specifyTimesPerDay: {
type: DataTypes.BOOLEAN,
@@ -124,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

@@ -23,32 +23,28 @@ const Message = sequelize.define('Message', {
key: 'id'
}
},
subject: {
type: DataTypes.STRING,
allowNull: false
},
content: {
type: DataTypes.TEXT,
allowNull: false
allowNull: true
},
isRead: {
type: DataTypes.BOOLEAN,
defaultValue: false
},
parentMessageId: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'Messages',
key: 'id'
}
},
imagePath: {
type: DataTypes.STRING,
imageFilename: {
type: DataTypes.TEXT,
allowNull: true
}
}, {
timestamps: true
timestamps: true,
validate: {
contentOrImage() {
const hasContent = this.content && this.content.trim().length > 0;
if (!hasContent && !this.imageFilename) {
throw new Error('Message must have content or an image');
}
}
}
});
module.exports = Message;

View File

@@ -64,15 +64,15 @@ const Rental = sequelize.define("Rental", {
"damaged",
"lost"
),
defaultValue: "pending",
allowNull: false,
},
paymentStatus: {
type: DataTypes.ENUM("pending", "paid", "refunded", "not_required"),
defaultValue: "pending",
type: DataTypes.ENUM("pending", "paid", "refunded", "not_required", "requires_action"),
allowNull: false,
},
payoutStatus: {
type: DataTypes.ENUM("pending", "processing", "completed", "failed"),
defaultValue: "pending",
type: DataTypes.ENUM("pending", "completed", "failed", "on_hold"),
allowNull: true,
},
payoutProcessedAt: {
type: DataTypes.DATE,
@@ -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",
@@ -124,7 +199,7 @@ const Rental = sequelize.define("Rental", {
deliveryAddress: {
type: DataTypes.TEXT,
},
notes: {
intendedUse: {
type: DataTypes.TEXT,
},
// Renter's review of the item (existing fields renamed for clarity)

View File

@@ -60,8 +60,8 @@ const User = sequelize.define(
country: {
type: DataTypes.STRING,
},
profileImage: {
type: DataTypes.STRING,
imageFilename: {
type: DataTypes.TEXT,
},
isVerified: {
type: DataTypes.BOOLEAN,
@@ -89,11 +89,11 @@ const User = sequelize.define(
},
defaultAvailableAfter: {
type: DataTypes.STRING,
defaultValue: "09:00",
defaultValue: "00:00",
},
defaultAvailableBefore: {
type: DataTypes.STRING,
defaultValue: "17:00",
defaultValue: "23:00",
},
defaultSpecifyTimesPerDay: {
type: DataTypes.BOOLEAN,
@@ -102,23 +102,46 @@ const User = sequelize.define(
defaultWeeklyTimes: {
type: DataTypes.JSONB,
defaultValue: {
sunday: { availableAfter: "09:00", availableBefore: "17:00" },
monday: { availableAfter: "09:00", availableBefore: "17:00" },
tuesday: { availableAfter: "09:00", availableBefore: "17:00" },
wednesday: { availableAfter: "09:00", availableBefore: "17:00" },
thursday: { availableAfter: "09:00", availableBefore: "17:00" },
friday: { availableAfter: "09:00", availableBefore: "17:00" },
saturday: { availableAfter: "09:00", availableBefore: "17:00" },
sunday: { availableAfter: "00:00", availableBefore: "23:00" },
monday: { availableAfter: "00:00", availableBefore: "23:00" },
tuesday: { availableAfter: "00:00", availableBefore: "23:00" },
wednesday: { availableAfter: "00:00", availableBefore: "23:00" },
thursday: { availableAfter: "00:00", availableBefore: "23:00" },
friday: { availableAfter: "00:00", availableBefore: "23:00" },
saturday: { availableAfter: "00:00", availableBefore: "23:00" },
},
},
stripeConnectedAccountId: {
type: DataTypes.STRING,
allowNull: true,
},
stripePayoutsEnabled: {
type: DataTypes.BOOLEAN,
defaultValue: false,
allowNull: true,
},
stripeCustomerId: {
type: DataTypes.STRING,
allowNull: true,
},
stripeRequirementsCurrentlyDue: {
type: DataTypes.JSON,
defaultValue: [],
allowNull: true,
},
stripeRequirementsPastDue: {
type: DataTypes.JSON,
defaultValue: [],
allowNull: true,
},
stripeDisabledReason: {
type: DataTypes.STRING,
allowNull: true,
},
stripeRequirementsLastUpdated: {
type: DataTypes.DATE,
allowNull: true,
},
loginAttempts: {
type: DataTypes.INTEGER,
defaultValue: 0,
@@ -137,6 +160,23 @@ const User = sequelize.define(
defaultValue: "user",
allowNull: false,
},
isBanned: {
type: DataTypes.BOOLEAN,
defaultValue: false,
allowNull: false,
},
bannedAt: {
type: DataTypes.DATE,
allowNull: true,
},
bannedBy: {
type: DataTypes.UUID,
allowNull: true,
},
banReason: {
type: DataTypes.TEXT,
allowNull: true,
},
itemRequestNotificationRadius: {
type: DataTypes.INTEGER,
defaultValue: 10,
@@ -146,6 +186,71 @@ const User = sequelize.define(
max: 100,
},
},
verificationAttempts: {
type: DataTypes.INTEGER,
defaultValue: 0,
allowNull: true,
},
// Two-Factor Authentication fields
twoFactorEnabled: {
type: DataTypes.BOOLEAN,
defaultValue: false,
allowNull: false,
},
twoFactorMethod: {
type: DataTypes.ENUM("totp", "email"),
allowNull: true,
},
totpSecret: {
type: DataTypes.STRING,
allowNull: true,
},
totpSecretIv: {
type: DataTypes.STRING,
allowNull: true,
},
// Email OTP fields (backup method)
emailOtpCode: {
type: DataTypes.STRING,
allowNull: true,
},
emailOtpExpiry: {
type: DataTypes.DATE,
allowNull: true,
},
emailOtpAttempts: {
type: DataTypes.INTEGER,
defaultValue: 0,
allowNull: false,
},
// Recovery codes
recoveryCodesHash: {
type: DataTypes.TEXT,
allowNull: true,
},
recoveryCodesGeneratedAt: {
type: DataTypes.DATE,
allowNull: true,
},
// Step-up session tracking
twoFactorVerifiedAt: {
type: DataTypes.DATE,
allowNull: true,
},
// Temporary secret during setup
twoFactorSetupPendingSecret: {
type: DataTypes.STRING,
allowNull: true,
},
twoFactorSetupPendingSecretIv: {
type: DataTypes.STRING,
allowNull: true,
},
// TOTP replay protection
recentTotpCodes: {
type: DataTypes.TEXT,
allowNull: true,
},
},
{
hooks: {
@@ -160,7 +265,7 @@ const User = sequelize.define(
}
},
},
}
},
);
User.prototype.comparePassword = async function (password) {
@@ -171,7 +276,7 @@ User.prototype.comparePassword = async function (password) {
};
// Account lockout constants
const MAX_LOGIN_ATTEMPTS = 5;
const MAX_LOGIN_ATTEMPTS = 10;
const LOCK_TIME = 2 * 60 * 60 * 1000; // 2 hours
// Check if account is locked
@@ -208,31 +313,64 @@ User.prototype.resetLoginAttempts = async function () {
};
// Email verification methods
// Maximum verification attempts before requiring a new code
const MAX_VERIFICATION_ATTEMPTS = 5;
User.prototype.generateVerificationToken = async function () {
const crypto = require("crypto");
const token = crypto.randomBytes(32).toString("hex");
// Generate 6-digit numeric code (100000-999999)
const code = crypto.randomInt(100000, 999999).toString();
const expiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
return this.update({
verificationToken: token,
verificationToken: code,
verificationTokenExpiry: expiry,
verificationAttempts: 0, // Reset attempts on new code
});
};
User.prototype.isVerificationTokenValid = function (token) {
const crypto = require("crypto");
if (!this.verificationToken || !this.verificationTokenExpiry) {
return false;
}
if (this.verificationToken !== token) {
return false;
}
// Check if token is expired
if (new Date() > new Date(this.verificationTokenExpiry)) {
return false;
}
return true;
// Validate 6-digit format
if (!/^\d{6}$/.test(token)) {
return false;
}
// Use timing-safe comparison to prevent timing attacks
try {
const inputBuffer = Buffer.from(token);
const storedBuffer = Buffer.from(this.verificationToken);
if (inputBuffer.length !== storedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(inputBuffer, storedBuffer);
} catch {
return false;
}
};
// Check if too many verification attempts
User.prototype.isVerificationLocked = function () {
return (this.verificationAttempts || 0) >= MAX_VERIFICATION_ATTEMPTS;
};
// Increment verification attempts
User.prototype.incrementVerificationAttempts = async function () {
const newAttempts = (this.verificationAttempts || 0) + 1;
await this.update({ verificationAttempts: newAttempts });
return newAttempts;
};
User.prototype.verifyEmail = async function () {
@@ -241,6 +379,7 @@ User.prototype.verifyEmail = async function () {
verifiedAt: new Date(),
verificationToken: null,
verificationTokenExpiry: null,
verificationAttempts: 0,
});
};
@@ -299,4 +438,278 @@ User.prototype.resetPassword = async function (newPassword) {
});
};
// Ban user method - sets ban fields and invalidates all sessions
User.prototype.banUser = async function (adminId, reason) {
return this.update({
isBanned: true,
bannedAt: new Date(),
bannedBy: adminId,
banReason: reason,
// Increment JWT version to immediately invalidate all sessions
jwtVersion: this.jwtVersion + 1,
});
};
// Unban user method - clears ban fields
User.prototype.unbanUser = async function () {
return this.update({
isBanned: false,
bannedAt: null,
bannedBy: null,
banReason: null,
// We don't increment jwtVersion on unban - user will need to log in fresh
});
};
// Two-Factor Authentication methods
const TwoFactorService = require("../services/TwoFactorService");
// Store pending TOTP secret during setup
User.prototype.storePendingTotpSecret = async function (
encryptedSecret,
encryptedSecretIv,
) {
return this.update({
twoFactorSetupPendingSecret: encryptedSecret,
twoFactorSetupPendingSecretIv: encryptedSecretIv,
});
};
// Enable TOTP 2FA after verification
User.prototype.enableTotp = async function (recoveryCodes) {
const hashedCodes = await Promise.all(
recoveryCodes.map((code) => bcrypt.hash(code, 12)),
);
// Store in structured format
const recoveryData = {
version: 1,
codes: hashedCodes.map((hash) => ({
hash,
used: false,
})),
};
return this.update({
twoFactorEnabled: true,
twoFactorMethod: "totp",
totpSecret: this.twoFactorSetupPendingSecret,
totpSecretIv: this.twoFactorSetupPendingSecretIv,
twoFactorSetupPendingSecret: null,
twoFactorSetupPendingSecretIv: null,
recoveryCodesHash: JSON.stringify(recoveryData),
recoveryCodesGeneratedAt: new Date(),
twoFactorVerifiedAt: new Date(), // Consider setup as verification
});
};
// Enable Email 2FA
User.prototype.enableEmailTwoFactor = async function (recoveryCodes) {
const hashedCodes = await Promise.all(
recoveryCodes.map((code) => bcrypt.hash(code, 12)),
);
// Store in structured format
const recoveryData = {
version: 1,
codes: hashedCodes.map((hash) => ({
hash,
used: false,
})),
};
return this.update({
twoFactorEnabled: true,
twoFactorMethod: "email",
recoveryCodesHash: JSON.stringify(recoveryData),
recoveryCodesGeneratedAt: new Date(),
twoFactorVerifiedAt: new Date(),
});
};
// Disable 2FA
User.prototype.disableTwoFactor = async function () {
return this.update({
twoFactorEnabled: false,
twoFactorMethod: null,
totpSecret: null,
totpSecretIv: null,
emailOtpCode: null,
emailOtpExpiry: null,
emailOtpAttempts: 0,
recoveryCodesHash: null,
recoveryCodesGeneratedAt: null,
twoFactorVerifiedAt: null,
twoFactorSetupPendingSecret: null,
twoFactorSetupPendingSecretIv: null,
});
};
// Generate and store email OTP
User.prototype.generateEmailOtp = async function () {
const { code, hashedCode, expiry } = TwoFactorService.generateEmailOtp();
await this.update({
emailOtpCode: hashedCode,
emailOtpExpiry: expiry,
emailOtpAttempts: 0,
});
return code; // Return plain code for sending via email
};
// Verify email OTP
User.prototype.verifyEmailOtp = function (inputCode) {
return TwoFactorService.verifyEmailOtp(
inputCode,
this.emailOtpCode,
this.emailOtpExpiry,
);
};
// Increment email OTP attempts
User.prototype.incrementEmailOtpAttempts = async function () {
const newAttempts = (this.emailOtpAttempts || 0) + 1;
await this.update({ emailOtpAttempts: newAttempts });
return newAttempts;
};
// Check if email OTP is locked
User.prototype.isEmailOtpLocked = function () {
return TwoFactorService.isEmailOtpLocked(this.emailOtpAttempts || 0);
};
// Clear email OTP after successful verification
User.prototype.clearEmailOtp = async function () {
return this.update({
emailOtpCode: null,
emailOtpExpiry: null,
emailOtpAttempts: 0,
});
};
// Check if a TOTP code was recently used (replay protection)
User.prototype.hasUsedTotpCode = function (code) {
const crypto = require("crypto");
const recentCodes = JSON.parse(this.recentTotpCodes || "[]");
const codeHash = crypto.createHash("sha256").update(code).digest("hex");
return recentCodes.includes(codeHash);
};
// Mark a TOTP code as used (replay protection)
User.prototype.markTotpCodeUsed = async function (code) {
const crypto = require("crypto");
const recentCodes = JSON.parse(this.recentTotpCodes || "[]");
const codeHash = crypto.createHash("sha256").update(code).digest("hex");
recentCodes.unshift(codeHash);
// Keep only last 5 codes (covers about 2.5 minutes of 30-second windows)
await this.update({
recentTotpCodes: JSON.stringify(recentCodes.slice(0, 5)),
});
};
// Verify TOTP code with replay protection
User.prototype.verifyTotpCode = function (code) {
if (!this.totpSecret || !this.totpSecretIv) {
return false;
}
// Check for replay attack
if (this.hasUsedTotpCode(code)) {
return false;
}
return TwoFactorService.verifyTotpCode(
this.totpSecret,
this.totpSecretIv,
code,
);
};
// Verify pending TOTP code (during setup)
User.prototype.verifyPendingTotpCode = function (code) {
if (
!this.twoFactorSetupPendingSecret ||
!this.twoFactorSetupPendingSecretIv
) {
return false;
}
return TwoFactorService.verifyTotpCode(
this.twoFactorSetupPendingSecret,
this.twoFactorSetupPendingSecretIv,
code,
);
};
// Use a recovery code
User.prototype.useRecoveryCode = async function (inputCode) {
if (!this.recoveryCodesHash) {
return { valid: false };
}
const recoveryData = JSON.parse(this.recoveryCodesHash);
const { valid, index } = await TwoFactorService.verifyRecoveryCode(
inputCode,
recoveryData,
);
if (valid) {
// Handle both old and new format
if (recoveryData.version) {
// New structured format - mark as used with timestamp
recoveryData.codes[index].used = true;
recoveryData.codes[index].usedAt = new Date().toISOString();
} else {
// Legacy format - set to null
recoveryData[index] = null;
}
await this.update({
recoveryCodesHash: JSON.stringify(recoveryData),
twoFactorVerifiedAt: new Date(),
});
}
return {
valid,
remainingCodes:
TwoFactorService.getRemainingRecoveryCodesCount(recoveryData),
};
};
// Get remaining recovery codes count
User.prototype.getRemainingRecoveryCodes = function () {
if (!this.recoveryCodesHash) {
return 0;
}
const recoveryData = JSON.parse(this.recoveryCodesHash);
return TwoFactorService.getRemainingRecoveryCodesCount(recoveryData);
};
// Regenerate recovery codes
User.prototype.regenerateRecoveryCodes = async function () {
const { codes, hashedCodes } = await TwoFactorService.generateRecoveryCodes();
// Store in structured format
const recoveryData = {
version: 1,
codes: hashedCodes.map((hash) => ({
hash,
used: false,
})),
};
await this.update({
recoveryCodesHash: JSON.stringify(recoveryData),
recoveryCodesGeneratedAt: new Date(),
});
return codes; // Return plain codes for display to user
};
// Update step-up verification timestamp
User.prototype.updateStepUpSession = async function () {
return this.update({
twoFactorVerifiedAt: new Date(),
});
};
module.exports = User;

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" });
@@ -27,11 +28,6 @@ User.hasMany(Message, { as: "sentMessages", foreignKey: "senderId" });
User.hasMany(Message, { as: "receivedMessages", foreignKey: "receiverId" });
Message.belongsTo(User, { as: "sender", foreignKey: "senderId" });
Message.belongsTo(User, { as: "receiver", foreignKey: "receiverId" });
Message.hasMany(Message, { as: "replies", foreignKey: "parentMessageId" });
Message.belongsTo(Message, {
as: "parentMessage",
foreignKey: "parentMessageId",
});
// Forum associations
User.hasMany(ForumPost, { as: "forumPosts", foreignKey: "authorId" });
@@ -96,4 +92,5 @@ module.exports = {
ConditionCheck,
AlphaInvitation,
Feedback,
ImageMetadata,
};

4783
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

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

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

View File

@@ -1,94 +1,35 @@
const express = require("express");
const multer = require("multer");
const { authenticateToken } = require("../middleware/auth");
const ConditionCheckService = require("../services/conditionCheckService");
const logger = require("../utils/logger");
const { validateS3Keys } = require("../utils/s3KeyValidator");
const { IMAGE_LIMITS } = require("../config/imageLimits");
const router = express.Router();
// Configure multer for photo uploads
const upload = multer({
dest: "uploads/condition-checks/",
limits: {
fileSize: 10 * 1024 * 1024, // 10MB limit
files: 20, // Maximum 20 files
},
fileFilter: (req, file, cb) => {
// Accept only image files
if (file.mimetype.startsWith("image/")) {
cb(null, true);
} else {
cb(new Error("Only image files are allowed"), false);
}
},
});
// Submit a condition check
router.post(
"/:rentalId",
authenticateToken,
upload.array("photos"),
async (req, res) => {
try {
const { rentalId } = req.params;
const { checkType, notes } = req.body;
const userId = req.user.id;
// Get uploaded file paths
const photos = req.files ? req.files.map((file) => file.path) : [];
// Extract metadata from request
const metadata = {
userAgent: req.get("User-Agent"),
ipAddress: req.ip,
deviceType: req.get("X-Device-Type") || "web",
};
const conditionCheck = await ConditionCheckService.submitConditionCheck(
rentalId,
checkType,
userId,
photos,
notes,
metadata
);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Condition check submitted", {
rentalId,
checkType,
userId,
photoCount: photos.length,
});
res.status(201).json({
success: true,
conditionCheck,
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error submitting condition check", {
error: error.message,
rentalId: req.params.rentalId,
userId: req.user?.id,
});
res.status(400).json({
success: false,
error: error.message,
});
}
}
);
// Get condition checks for a rental
router.get("/:rentalId", authenticateToken, async (req, res) => {
// Get condition checks for multiple rentals in a single request (batch)
router.get("/batch", authenticateToken, async (req, res) => {
try {
const { rentalId } = req.params;
const { rentalIds } = req.query;
const conditionChecks = await ConditionCheckService.getConditionChecks(
rentalId
);
if (!rentalIds) {
return res.json({
success: true,
conditionChecks: [],
});
}
const ids = rentalIds.split(",").filter((id) => id.trim());
if (ids.length === 0) {
return res.json({
success: true,
conditionChecks: [],
});
}
const conditionChecks =
await ConditionCheckService.getConditionChecksForRentals(ids);
res.json({
success: true,
@@ -96,9 +37,10 @@ router.get("/:rentalId", authenticateToken, async (req, res) => {
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error fetching condition checks", {
reqLogger.error("Error fetching batch condition checks", {
error: error.message,
rentalId: req.params.rentalId,
stack: error.stack,
rentalIds: req.query.rentalIds,
});
res.status(500).json({
@@ -108,27 +50,66 @@ router.get("/:rentalId", authenticateToken, async (req, res) => {
}
});
// Get condition check timeline for a rental
router.get("/:rentalId/timeline", authenticateToken, async (req, res) => {
// Submit a condition check
router.post("/:rentalId", authenticateToken, async (req, res) => {
try {
const { rentalId } = req.params;
const { checkType, notes, imageFilenames: rawImageFilenames } = req.body;
const userId = req.user.id;
const timeline = await ConditionCheckService.getConditionCheckTimeline(
rentalId
// Ensure imageFilenames is an array (S3 keys)
const imageFilenamesArray = Array.isArray(rawImageFilenames)
? rawImageFilenames
: [];
// Validate S3 keys format and folder
const keyValidation = validateS3Keys(
imageFilenamesArray,
"condition-checks",
{
maxKeys: IMAGE_LIMITS.conditionChecks,
}
);
if (!keyValidation.valid) {
return res.status(400).json({
success: false,
error: keyValidation.error,
details: keyValidation.invalidKeys,
});
}
const imageFilenames = imageFilenamesArray;
const conditionCheck = await ConditionCheckService.submitConditionCheck(
rentalId,
checkType,
userId,
imageFilenames,
notes
);
res.json({
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Condition check submitted", {
rentalId,
checkType,
userId,
photoCount: imageFilenames.length,
});
res.status(201).json({
success: true,
timeline,
conditionCheck,
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error fetching condition check timeline", {
reqLogger.error("Error submitting condition check", {
error: error.message,
stack: error.stack,
rentalId: req.params.rentalId,
userId: req.user?.id,
});
res.status(500).json({
res.status(400).json({
success: false,
error: error.message,
});
@@ -139,9 +120,12 @@ router.get("/:rentalId/timeline", authenticateToken, async (req, res) => {
router.get("/", authenticateToken, async (req, res) => {
try {
const userId = req.user.id;
const { rentalIds } = req.query;
const ids = rentalIds ? rentalIds.split(",").filter((id) => id.trim()) : [];
const availableChecks = await ConditionCheckService.getAvailableChecks(
userId
userId,
ids
);
res.json({
@@ -152,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

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

View File

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

121
backend/routes/health.js Normal file
View File

@@ -0,0 +1,121 @@
const express = require("express");
const router = express.Router();
const { sequelize } = require("../models");
const s3Service = require("../services/s3Service");
const logger = require("../utils/logger");
/**
* Health check endpoint for load balancers and monitoring
* GET /health
*
* Returns:
* - 200: All services healthy
* - 503: One or more services unhealthy
*/
router.get("/", async (req, res) => {
const startTime = Date.now();
const checks = {
database: { status: "unknown", latency: null },
s3: { status: "unknown", latency: null },
};
let allHealthy = true;
// Database health check
try {
const dbStart = Date.now();
await sequelize.authenticate();
checks.database = {
status: "healthy",
latency: Date.now() - dbStart,
};
} catch (error) {
allHealthy = false;
checks.database = {
status: "unhealthy",
error: error.message,
latency: Date.now() - startTime,
};
logger.error("Health check: Database connection failed", {
error: error.message,
});
}
// S3 health check (if enabled)
if (s3Service.isEnabled()) {
try {
const s3Start = Date.now();
// S3 is considered healthy if it's properly initialized
// A more thorough check could list bucket contents, but that adds latency
checks.s3 = {
status: "healthy",
latency: Date.now() - s3Start,
bucket: process.env.S3_BUCKET,
};
} catch (error) {
allHealthy = false;
checks.s3 = {
status: "unhealthy",
error: error.message,
latency: Date.now() - startTime,
};
logger.error("Health check: S3 check failed", {
error: error.message,
});
}
} else {
checks.s3 = {
status: "disabled",
latency: 0,
};
}
// Log unhealthy states
if (!allHealthy) {
logger.warn("Health check failed", { checks });
}
res.status(allHealthy ? 200 : 503).json({
status: allHealthy ? "healthy" : "unhealthy",
});
});
/**
* Liveness probe - simple check that the process is running
* GET /health/live
*
* Used by Kubernetes/ECS for liveness probes
* Returns 200 if the process is alive
*/
router.get("/live", (req, res) => {
res.status(200).json({
status: "alive",
timestamp: new Date().toISOString(),
});
});
/**
* Readiness probe - check if the service is ready to accept traffic
* GET /health/ready
*
* Used by load balancers to determine if instance should receive traffic
* Checks critical dependencies (database)
*/
router.get("/ready", async (req, res) => {
try {
await sequelize.authenticate();
res.status(200).json({
status: "ready",
timestamp: new Date().toISOString(),
});
} catch (error) {
logger.error("Readiness check failed", { error: error.message, stack: error.stack });
res.status(503).json({
status: "not_ready",
timestamp: new Date().toISOString(),
error: "Database connection failed",
});
}
});
module.exports = router;

View File

@@ -1,11 +1,60 @@
const express = require("express");
const { Op } = require("sequelize");
const { Item, User, Rental } = require("../models"); // Import from models/index.js to get models with associations
const { Op, Sequelize } = require("sequelize");
const { Item, User, Rental, sequelize } = require("../models"); // Import from models/index.js to get models with associations
const { authenticateToken, requireVerifiedEmail, requireAdmin, optionalAuth } = require("../middleware/auth");
const { validateCoordinatesQuery, validateCoordinatesBody, handleValidationErrors } = require("../middleware/validation");
const logger = require("../utils/logger");
const { validateS3Keys } = require("../utils/s3KeyValidator");
const { IMAGE_LIMITS } = require("../config/imageLimits");
const router = express.Router();
router.get("/", async (req, res) => {
// Allowed fields for item create/update (prevents mass assignment)
const ALLOWED_ITEM_FIELDS = [
'name',
'description',
'pickUpAvailable',
'localDeliveryAvailable',
'localDeliveryRadius',
'shippingAvailable',
'inPlaceUseAvailable',
'pricePerHour',
'pricePerDay',
'pricePerWeek',
'pricePerMonth',
'replacementCost',
'address1',
'address2',
'city',
'state',
'zipCode',
'country',
'latitude',
'longitude',
'imageFilenames',
'isAvailable',
'rules',
'availableAfter',
'availableBefore',
'specifyTimesPerDay',
'weeklyTimes',
];
/**
* Extract only allowed fields from request body
* @param {Object} body - Request body
* @returns {Object} - Object with only allowed fields
*/
function extractAllowedFields(body) {
const result = {};
for (const field of ALLOWED_ITEM_FIELDS) {
if (body[field] !== undefined) {
result[field] = body[field];
}
}
return result;
}
router.get("/", validateCoordinatesQuery, async (req, res, next) => {
try {
const {
minPrice,
@@ -13,6 +62,9 @@ router.get("/", async (req, res) => {
city,
zipCode,
search,
lat,
lng,
radius = 25,
page = 1,
limit = 20,
} = req.query;
@@ -26,8 +78,50 @@ router.get("/", async (req, res) => {
if (minPrice) where.pricePerDay[Op.gte] = minPrice;
if (maxPrice) where.pricePerDay[Op.lte] = maxPrice;
}
if (city) where.city = { [Op.iLike]: `%${city}%` };
if (zipCode) where.zipCode = { [Op.iLike]: `%${zipCode}%` };
// Location filtering: Radius search OR city/ZIP fallback
if (lat && lng) {
// Parse and validate coordinates
const latNum = parseFloat(lat);
const lngNum = parseFloat(lng);
const radiusNum = parseFloat(radius);
if (!isNaN(latNum) && !isNaN(lngNum) && !isNaN(radiusNum)) {
// Bounding box pre-filter (fast, uses indexes)
// ~69 miles per degree latitude, longitude varies by latitude
const latDelta = radiusNum / 69;
const lngDelta = radiusNum / (69 * Math.cos(latNum * Math.PI / 180));
where.latitude = {
[Op.and]: [
{ [Op.gte]: latNum - latDelta },
{ [Op.lte]: latNum + latDelta },
{ [Op.ne]: null }
]
};
where.longitude = {
[Op.and]: [
{ [Op.gte]: lngNum - lngDelta },
{ [Op.lte]: lngNum + lngDelta },
{ [Op.ne]: null }
]
};
// Haversine formula for exact distance (applied after bounding box)
// 3959 = Earth's radius in miles
where[Op.and] = sequelize.literal(`
(3959 * acos(
cos(radians(${latNum})) * cos(radians("Item"."latitude")) *
cos(radians("Item"."longitude") - radians(${lngNum})) +
sin(radians(${latNum})) * sin(radians("Item"."latitude"))
)) <= ${radiusNum}
`);
}
} else {
// Fallback to city/ZIP string matching
if (city) where.city = { [Op.iLike]: `%${city}%` };
if (zipCode) where.zipCode = { [Op.iLike]: `%${zipCode}%` };
}
if (search) {
where[Op.or] = [
{ name: { [Op.iLike]: `%${search}%` } },
@@ -43,7 +137,11 @@ router.get("/", async (req, res) => {
{
model: User,
as: "owner",
attributes: ["id", "firstName", "lastName"],
attributes: ["id", "firstName", "lastName", "imageFilename"],
where: {
isBanned: { [Op.ne]: true }
},
required: true,
},
],
limit: parseInt(limit),
@@ -65,7 +163,7 @@ router.get("/", async (req, res) => {
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Items search completed", {
filters: { minPrice, maxPrice, city, zipCode, search },
filters: { minPrice, maxPrice, city, zipCode, search, lat, lng, radius },
resultsCount: count,
page: parseInt(page),
limit: parseInt(limit)
@@ -84,11 +182,11 @@ router.get("/", async (req, res) => {
stack: error.stack,
query: req.query
});
res.status(500).json({ error: error.message });
next(error);
}
});
router.get("/recommendations", authenticateToken, async (req, res) => {
router.get("/recommendations", authenticateToken, async (req, res, next) => {
try {
const userRentals = await Rental.findAll({
where: { renterId: req.user.id },
@@ -98,7 +196,7 @@ router.get("/recommendations", authenticateToken, async (req, res) => {
// For now, just return random available items as recommendations
const recommendations = await Item.findAll({
where: {
availability: true,
isAvailable: true,
isDeleted: false,
},
limit: 10,
@@ -119,15 +217,15 @@ router.get("/recommendations", authenticateToken, async (req, res) => {
stack: error.stack,
userId: req.user.id
});
res.status(500).json({ error: error.message });
next(error);
}
});
// Public endpoint to get reviews for a specific item (must come before /:id route)
router.get('/:id/reviews', async (req, res) => {
router.get('/:id/reviews', async (req, res, next) => {
try {
const { Rental, User } = require('../models');
const reviews = await Rental.findAll({
where: {
itemId: req.params.id,
@@ -137,10 +235,10 @@ router.get('/:id/reviews', async (req, res) => {
itemReviewVisible: true
},
include: [
{
model: User,
as: 'renter',
attributes: ['id', 'firstName', 'lastName']
{
model: User,
as: 'renter',
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
}
],
order: [['createdAt', 'DESC']]
@@ -169,18 +267,18 @@ router.get('/:id/reviews', async (req, res) => {
stack: error.stack,
itemId: req.params.id
});
res.status(500).json({ error: error.message });
next(error);
}
});
router.get("/:id", optionalAuth, async (req, res) => {
router.get("/:id", optionalAuth, async (req, res, next) => {
try {
const item = await Item.findByPk(req.params.id, {
include: [
{
model: User,
as: "owner",
attributes: ["id", "firstName", "lastName"],
attributes: ["id", "firstName", "lastName", "imageFilename"],
},
{
model: User,
@@ -226,14 +324,57 @@ router.get("/:id", optionalAuth, async (req, res) => {
stack: error.stack,
itemId: req.params.id
});
res.status(500).json({ error: error.message });
next(error);
}
});
router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
router.post("/", authenticateToken, requireVerifiedEmail, ...validateCoordinatesBody, handleValidationErrors, async (req, res, next) => {
try {
// Extract only allowed fields (prevents mass assignment)
const allowedData = extractAllowedFields(req.body);
// Validate imageFilenames - at least one image is required
const imageFilenames = Array.isArray(allowedData.imageFilenames)
? allowedData.imageFilenames
: [];
if (imageFilenames.length === 0) {
return res.status(400).json({
error: "At least one image is required to create a listing"
});
}
// Validate required fields
if (!allowedData.name || !allowedData.name.trim()) {
return res.status(400).json({ error: "Item name is required" });
}
if (!allowedData.address1 || !allowedData.address1.trim()) {
return res.status(400).json({ error: "Address is required" });
}
if (!allowedData.city || !allowedData.city.trim()) {
return res.status(400).json({ error: "City is required" });
}
if (!allowedData.state || !allowedData.state.trim()) {
return res.status(400).json({ error: "State is required" });
}
if (!allowedData.zipCode || !allowedData.zipCode.trim()) {
return res.status(400).json({ error: "ZIP code is required" });
}
if (!allowedData.replacementCost || Number(allowedData.replacementCost) <= 0) {
return res.status(400).json({ error: "Replacement cost is required" });
}
const keyValidation = validateS3Keys(imageFilenames, 'items', { maxKeys: IMAGE_LIMITS.items });
if (!keyValidation.valid) {
return res.status(400).json({
error: keyValidation.error,
details: keyValidation.invalidKeys
});
}
allowedData.imageFilenames = imageFilenames;
const item = await Item.create({
...req.body,
...allowedData,
ownerId: req.user.id,
});
@@ -260,10 +401,17 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
itemWithOwner.owner,
itemWithOwner
);
console.log(`First listing celebration email sent to owner ${req.user.id}`);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("First listing celebration email sent", { ownerId: req.user.id });
} catch (emailError) {
// Log but don't fail the item creation
console.error('Failed to send first listing celebration email:', emailError.message);
const reqLogger = logger.withRequestId(req.id);
reqLogger.error('Failed to send first listing celebration email', {
error: emailError.message,
stack: emailError.stack,
ownerId: req.user.id,
itemId: item.id
});
}
}
@@ -284,11 +432,11 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
ownerId: req.user.id,
itemData: logger.sanitize(req.body)
});
res.status(500).json({ error: error.message });
next(error);
}
});
router.put("/:id", authenticateToken, async (req, res) => {
router.put("/:id", authenticateToken, ...validateCoordinatesBody, handleValidationErrors, async (req, res, next) => {
try {
const item = await Item.findByPk(req.params.id);
@@ -300,7 +448,53 @@ router.put("/:id", authenticateToken, async (req, res) => {
return res.status(403).json({ error: "Unauthorized" });
}
await item.update(req.body);
// Extract only allowed fields (prevents mass assignment)
const allowedData = extractAllowedFields(req.body);
// Validate imageFilenames if provided
if (allowedData.imageFilenames !== undefined) {
const imageFilenames = Array.isArray(allowedData.imageFilenames)
? allowedData.imageFilenames
: [];
// Require at least one image
if (imageFilenames.length === 0) {
return res.status(400).json({
error: "At least one image is required for a listing"
});
}
const keyValidation = validateS3Keys(imageFilenames, 'items', { maxKeys: IMAGE_LIMITS.items });
if (!keyValidation.valid) {
return res.status(400).json({
error: keyValidation.error,
details: keyValidation.invalidKeys
});
}
allowedData.imageFilenames = imageFilenames;
}
// Validate required fields if they are being updated
if (allowedData.name !== undefined && (!allowedData.name || !allowedData.name.trim())) {
return res.status(400).json({ error: "Item name is required" });
}
if (allowedData.address1 !== undefined && (!allowedData.address1 || !allowedData.address1.trim())) {
return res.status(400).json({ error: "Address is required" });
}
if (allowedData.city !== undefined && (!allowedData.city || !allowedData.city.trim())) {
return res.status(400).json({ error: "City is required" });
}
if (allowedData.state !== undefined && (!allowedData.state || !allowedData.state.trim())) {
return res.status(400).json({ error: "State is required" });
}
if (allowedData.zipCode !== undefined && (!allowedData.zipCode || !allowedData.zipCode.trim())) {
return res.status(400).json({ error: "ZIP code is required" });
}
if (allowedData.replacementCost !== undefined && (!allowedData.replacementCost || Number(allowedData.replacementCost) <= 0)) {
return res.status(400).json({ error: "Replacement cost is required" });
}
await item.update(allowedData);
const updatedItem = await Item.findByPk(item.id, {
include: [
@@ -327,11 +521,11 @@ router.put("/:id", authenticateToken, async (req, res) => {
itemId: req.params.id,
ownerId: req.user.id
});
res.status(500).json({ error: error.message });
next(error);
}
});
router.delete("/:id", authenticateToken, async (req, res) => {
router.delete("/:id", authenticateToken, async (req, res, next) => {
try {
const item = await Item.findByPk(req.params.id);
@@ -360,12 +554,12 @@ router.delete("/:id", authenticateToken, async (req, res) => {
itemId: req.params.id,
ownerId: req.user.id
});
res.status(500).json({ error: error.message });
next(error);
}
});
// Admin endpoints
router.delete("/admin/:id", authenticateToken, requireAdmin, async (req, res) => {
router.delete("/admin/:id", authenticateToken, requireAdmin, async (req, res, next) => {
try {
const { reason } = req.body;
@@ -440,10 +634,15 @@ router.delete("/admin/:id", authenticateToken, requireAdmin, async (req, res) =>
item,
reason.trim()
);
console.log(`Item deletion notification email sent to owner ${item.ownerId}`);
logger.info("Item deletion notification email sent", { ownerId: item.ownerId, itemId: item.id });
} catch (emailError) {
// Log but don't fail the deletion
console.error('Failed to send item deletion notification email:', emailError.message);
logger.error('Failed to send item deletion notification email', {
error: emailError.message,
stack: emailError.stack,
ownerId: item.ownerId,
itemId: item.id
});
}
const reqLogger = logger.withRequestId(req.id);
@@ -463,11 +662,11 @@ router.delete("/admin/:id", authenticateToken, requireAdmin, async (req, res) =>
itemId: req.params.id,
adminId: req.user.id
});
res.status(500).json({ error: error.message });
next(error);
}
});
router.patch("/admin/:id/restore", authenticateToken, requireAdmin, async (req, res) => {
router.patch("/admin/:id/restore", authenticateToken, requireAdmin, async (req, res, next) => {
try {
const item = await Item.findByPk(req.params.id);
@@ -513,7 +712,7 @@ router.patch("/admin/:id/restore", authenticateToken, requireAdmin, async (req,
itemId: req.params.id,
adminId: req.user.id
});
res.status(500).json({ error: error.message });
next(error);
}
});

View File

@@ -1,18 +1,16 @@
const express = require('express');
const helmet = require('helmet');
const { Message, User } = require('../models');
const { authenticateToken } = require('../middleware/auth');
const { uploadMessageImage } = require('../middleware/upload');
const logger = require('../utils/logger');
const { emitNewMessage, emitMessageRead } = require('../sockets/messageSocket');
const { Op } = require('sequelize');
const emailServices = require('../services/email');
const fs = require('fs');
const path = require('path');
const { validateS3Keys } = require('../utils/s3KeyValidator');
const { IMAGE_LIMITS } = require('../config/imageLimits');
const router = express.Router();
// Get all messages for the current user (inbox)
router.get('/', authenticateToken, async (req, res) => {
router.get('/', authenticateToken, async (req, res, next) => {
try {
const messages = await Message.findAll({
where: { receiverId: req.user.id },
@@ -20,7 +18,7 @@ router.get('/', authenticateToken, async (req, res) => {
{
model: User,
as: 'sender',
attributes: ['id', 'firstName', 'lastName', 'profileImage']
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
}
],
order: [['createdAt', 'DESC']]
@@ -40,12 +38,12 @@ router.get('/', authenticateToken, async (req, res) => {
stack: error.stack,
userId: req.user.id
});
res.status(500).json({ error: error.message });
next(error);
}
});
// Get conversations grouped by user pairs
router.get('/conversations', authenticateToken, async (req, res) => {
router.get('/conversations', authenticateToken, async (req, res, next) => {
try {
const userId = req.user.id;
@@ -61,12 +59,12 @@ router.get('/conversations', authenticateToken, async (req, res) => {
{
model: User,
as: 'sender',
attributes: ['id', 'firstName', 'lastName', 'profileImage']
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
},
{
model: User,
as: 'receiver',
attributes: ['id', 'firstName', 'lastName', 'profileImage']
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
}
],
order: [['createdAt', 'DESC']]
@@ -134,12 +132,12 @@ router.get('/conversations', authenticateToken, async (req, res) => {
stack: error.stack,
userId: req.user.id
});
res.status(500).json({ error: error.message });
next(error);
}
});
// Get sent messages
router.get('/sent', authenticateToken, async (req, res) => {
router.get('/sent', authenticateToken, async (req, res, next) => {
try {
const messages = await Message.findAll({
where: { senderId: req.user.id },
@@ -147,7 +145,7 @@ router.get('/sent', authenticateToken, async (req, res) => {
{
model: User,
as: 'receiver',
attributes: ['id', 'firstName', 'lastName', 'profileImage']
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
}
],
order: [['createdAt', 'DESC']]
@@ -167,15 +165,15 @@ router.get('/sent', authenticateToken, async (req, res) => {
stack: error.stack,
userId: req.user.id
});
res.status(500).json({ error: error.message });
next(error);
}
});
// Get a single message with replies
router.get('/:id', authenticateToken, async (req, res) => {
// Get a single message
router.get('/:id', authenticateToken, async (req, res, next) => {
try {
const message = await Message.findOne({
where: {
where: {
id: req.params.id,
[require('sequelize').Op.or]: [
{ senderId: req.user.id },
@@ -186,21 +184,12 @@ router.get('/:id', authenticateToken, async (req, res) => {
{
model: User,
as: 'sender',
attributes: ['id', 'firstName', 'lastName', 'profileImage']
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
},
{
model: User,
as: 'receiver',
attributes: ['id', 'firstName', 'lastName', 'profileImage']
},
{
model: Message,
as: 'replies',
include: [{
model: User,
as: 'sender',
attributes: ['id', 'firstName', 'lastName', 'profileImage']
}]
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
}
]
});
@@ -241,14 +230,25 @@ router.get('/:id', authenticateToken, async (req, res) => {
userId: req.user.id,
messageId: req.params.id
});
res.status(500).json({ error: error.message });
next(error);
}
});
// Send a new message
router.post('/', authenticateToken, uploadMessageImage, async (req, res) => {
router.post('/', authenticateToken, async (req, res, next) => {
try {
const { receiverId, subject, content, parentMessageId } = req.body;
const { receiverId, content, imageFilename } = req.body;
// Validate imageFilename if provided
if (imageFilename) {
const keyValidation = validateS3Keys([imageFilename], 'messages', { maxKeys: IMAGE_LIMITS.messages });
if (!keyValidation.valid) {
return res.status(400).json({
error: keyValidation.error,
details: keyValidation.invalidKeys
});
}
}
// Check if receiver exists
const receiver = await User.findByPk(receiverId);
@@ -261,23 +261,18 @@ router.post('/', authenticateToken, uploadMessageImage, async (req, res) => {
return res.status(400).json({ error: 'Cannot send messages to yourself' });
}
// Extract image filename if uploaded
const imagePath = req.file ? req.file.filename : null;
const message = await Message.create({
senderId: req.user.id,
receiverId,
subject,
content,
parentMessageId,
imagePath
imageFilename: imageFilename || null
});
const messageWithSender = await Message.findByPk(message.id, {
include: [{
model: User,
as: 'sender',
attributes: ['id', 'firstName', 'lastName', 'profileImage']
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
}]
});
@@ -299,6 +294,7 @@ router.post('/', authenticateToken, uploadMessageImage, async (req, res) => {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Failed to send message notification email", {
error: emailError.message,
stack: emailError.stack,
messageId: message.id,
receiverId: receiverId
});
@@ -308,8 +304,7 @@ router.post('/', authenticateToken, uploadMessageImage, async (req, res) => {
reqLogger.info("Message sent", {
senderId: req.user.id,
receiverId: receiverId,
messageId: message.id,
isReply: !!parentMessageId
messageId: message.id
});
res.status(201).json(messageWithSender);
@@ -319,14 +314,14 @@ router.post('/', authenticateToken, uploadMessageImage, async (req, res) => {
error: error.message,
stack: error.stack,
senderId: req.user.id,
receiverId: req.body.receiverId
receiverId: req.body?.receiverId
});
res.status(500).json({ error: error.message });
next(error);
}
});
// Mark message as read
router.put('/:id/read', authenticateToken, async (req, res) => {
router.put('/:id/read', authenticateToken, async (req, res, next) => {
try {
const message = await Message.findOne({
where: {
@@ -366,12 +361,12 @@ router.put('/:id/read', authenticateToken, async (req, res) => {
userId: req.user.id,
messageId: req.params.id
});
res.status(500).json({ error: error.message });
next(error);
}
});
// Get unread message count
router.get('/unread/count', authenticateToken, async (req, res) => {
router.get('/unread/count', authenticateToken, async (req, res, next) => {
try {
const count = await Message.count({
where: {
@@ -393,54 +388,7 @@ router.get('/unread/count', authenticateToken, async (req, res) => {
stack: error.stack,
userId: req.user.id
});
res.status(500).json({ error: error.message });
}
});
// Get message image (authorized)
router.get('/images/:filename',
authenticateToken,
// Override Helmet's CORP header for cross-origin image loading
helmet.crossOriginResourcePolicy({ policy: "cross-origin" }),
async (req, res) => {
try {
const { filename } = req.params;
// Verify user is sender or receiver of a message with this image
const message = await Message.findOne({
where: {
imagePath: filename,
[Op.or]: [
{ senderId: req.user.id },
{ receiverId: req.user.id }
]
}
});
if (!message) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.warn('Unauthorized image access attempt', {
userId: req.user.id,
filename
});
return res.status(403).json({ error: 'Access denied' });
}
// Serve the image
const filePath = path.join(__dirname, '../uploads/messages', filename);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'Image not found' });
}
res.sendFile(filePath);
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error('Image serve failed', {
error: error.message,
filename: req.params.filename
});
res.status(500).json({ error: 'Failed to load image' });
next(error);
}
});

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,119 @@
const express = require("express");
const StripeWebhookService = require("../services/stripeWebhookService");
const DisputeService = require("../services/disputeService");
const logger = require("../utils/logger");
const router = express.Router();
const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET;
/**
* POST /stripe/webhooks
* Stripe webhook endpoint - receives events from Stripe.
* Must use raw body for signature verification.
*/
router.post("/", async (req, res) => {
const signature = req.headers["stripe-signature"];
if (!signature) {
logger.warn("Webhook request missing stripe-signature header");
return res.status(400).json({ error: "Missing signature" });
}
if (!WEBHOOK_SECRET) {
logger.error("STRIPE_WEBHOOK_SECRET not configured");
return res.status(500).json({ error: "Webhook not configured" });
}
let event;
try {
// Use rawBody stored by bodyParser in server.js
event = StripeWebhookService.constructEvent(
req.rawBody,
signature,
WEBHOOK_SECRET
);
} catch (err) {
logger.error("Webhook signature verification failed", {
error: err.message,
});
return res.status(400).json({ error: "Invalid signature" });
}
// Log event receipt for debugging
// For Connect account events, event.account contains the connected account ID
logger.info("Stripe webhook received", {
eventId: event.id,
eventType: event.type,
connectedAccount: event.account || null,
});
try {
switch (event.type) {
case "account.updated":
await StripeWebhookService.handleAccountUpdated(event.data.object);
break;
case "payout.paid":
// Payout to connected account's bank succeeded
await StripeWebhookService.handlePayoutPaid(
event.data.object,
event.account
);
break;
case "payout.failed":
// Payout to connected account's bank failed
await StripeWebhookService.handlePayoutFailed(
event.data.object,
event.account
);
break;
case "payout.canceled":
// Payout was canceled before being deposited
await StripeWebhookService.handlePayoutCanceled(
event.data.object,
event.account
);
break;
case "account.application.deauthorized":
// Owner disconnected their Stripe account from our platform
await StripeWebhookService.handleAccountDeauthorized(event.account);
break;
case "charge.dispute.created":
// Renter disputed a charge with their bank
await DisputeService.handleDisputeCreated(event.data.object);
break;
case "charge.dispute.closed":
case "charge.dispute.funds_reinstated":
case "charge.dispute.funds_withdrawn":
// Dispute was resolved (won, lost, or warning closed)
await DisputeService.handleDisputeClosed(event.data.object);
break;
default:
logger.info("Unhandled webhook event type", { type: event.type });
}
// Always return 200 to acknowledge receipt
res.json({ received: true, eventId: event.id });
} catch (error) {
logger.error("Error processing webhook", {
eventId: event.id,
eventType: event.type,
error: error.message,
stack: error.stack,
});
// Still return 200 to prevent Stripe retries for processing errors
// Failed payouts will be handled by retry job
res.json({ received: true, eventId: event.id });
}
});
module.exports = router;

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

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

230
backend/routes/upload.js Normal file
View File

@@ -0,0 +1,230 @@
const express = require("express");
const router = express.Router();
const { authenticateToken } = require("../middleware/auth");
const { uploadPresignLimiter } = require("../middleware/rateLimiter");
const s3Service = require("../services/s3Service");
const S3OwnershipService = require("../services/s3OwnershipService");
const { v4: uuidv4 } = require("uuid");
const logger = require("../utils/logger");
const MAX_BATCH_SIZE = 20;
/**
* Middleware to check if S3 is enabled
*/
const requireS3Enabled = (req, res, next) => {
if (!s3Service.isEnabled()) {
return res.status(503).json({
error: "File upload service is not available",
});
}
next();
};
/**
* POST /api/upload/presign
* Get a presigned URL for uploading a single file to S3
*/
router.post(
"/presign",
authenticateToken,
requireS3Enabled,
uploadPresignLimiter,
async (req, res, next) => {
try {
const { uploadType, contentType, fileName, fileSize } = req.body;
// Validate required fields
if (!uploadType || !contentType || !fileName || !fileSize) {
return res.status(400).json({ error: "Missing required fields" });
}
const result = await s3Service.getPresignedUploadUrl(
uploadType,
contentType,
fileName,
fileSize
);
logger.info("Presigned URL generated", {
userId: req.user.id,
uploadType,
key: result.key,
});
res.json(result);
} catch (error) {
if (error.message.includes("Invalid")) {
return res.status(400).json({ error: error.message });
}
next(error);
}
}
);
/**
* POST /api/upload/presign-batch
* Get presigned URLs for uploading multiple files to S3
* All files in a batch share the same UUID base for coordinated variant uploads
*/
router.post(
"/presign-batch",
authenticateToken,
requireS3Enabled,
uploadPresignLimiter,
async (req, res, next) => {
try {
const { uploadType, files } = req.body;
if (!uploadType || !files || !Array.isArray(files)) {
return res.status(400).json({ error: "Missing required fields" });
}
if (files.length === 0) {
return res.status(400).json({ error: "No files specified" });
}
if (files.length > MAX_BATCH_SIZE) {
return res
.status(400)
.json({ error: "Maximum ${MAX_BATCH_SIZE} files per batch" });
}
// Validate each file has required fields
for (const file of files) {
if (!file.contentType || !file.fileName || !file.fileSize) {
return res.status(400).json({
error: "Each file must have contentType, fileName, and fileSize",
});
}
}
// Generate one shared UUID for all files in this batch
const sharedBaseKey = uuidv4();
const results = await Promise.all(
files.map((f) =>
s3Service.getPresignedUploadUrl(
uploadType,
f.contentType,
f.fileName,
f.fileSize,
sharedBaseKey
)
)
);
logger.info("Batch presigned URLs generated", {
userId: req.user.id,
uploadType,
count: results.length,
baseKey: sharedBaseKey,
});
res.json({ uploads: results, baseKey: sharedBaseKey });
} catch (error) {
if (error.message.includes("Invalid")) {
return res.status(400).json({ error: error.message });
}
next(error);
}
}
);
/**
* POST /api/upload/confirm
* Confirm that files have been uploaded to S3
*/
router.post(
"/confirm",
authenticateToken,
requireS3Enabled,
async (req, res, next) => {
try {
const { keys } = req.body;
if (!keys || !Array.isArray(keys)) {
return res.status(400).json({ error: "Missing keys array" });
}
if (keys.length === 0) {
return res.status(400).json({ error: "No keys specified" });
}
const results = await Promise.all(
keys.map(async (key) => ({
key,
exists: await s3Service.verifyUpload(key),
}))
);
const confirmed = results.filter((r) => r.exists).map((r) => r.key);
logger.info("Upload confirmation", {
userId: req.user.id,
confirmed: confirmed.length,
total: keys.length,
});
// Only return confirmed keys, not which ones failed (prevents file existence probing)
res.json({ confirmed, total: keys.length });
} catch (error) {
next(error);
}
}
);
/**
* GET /api/upload/signed-url/*key
* Get a signed URL for accessing private content (messages, condition-checks)
* The key is the full path after /signed-url/ (e.g., "messages/uuid.jpg")
*/
router.get(
"/signed-url/*key",
authenticateToken,
requireS3Enabled,
async (req, res, next) => {
try {
// Express wildcard params may be string or array - handle both
let key = req.params.key;
if (Array.isArray(key)) {
key = key.join("/");
}
if (!key || typeof key !== "string") {
return res.status(400).json({ error: "Invalid key parameter" });
}
// Decode URL-encoded characters (e.g., %2F -> /)
key = decodeURIComponent(key);
// Only allow private folders to use signed URLs
const isPrivate =
key.startsWith("messages/") || key.startsWith("condition-checks/");
if (!isPrivate) {
return res
.status(400)
.json({ error: "Signed URLs only for private content" });
}
// Verify user is authorized to access this file
const authResult = await S3OwnershipService.canAccessFile(
key,
req.user.id
);
if (!authResult.authorized) {
logger.warn("Unauthorized signed URL request", {
userId: req.user.id,
key,
reason: authResult.reason,
});
return res.status(403).json({ error: "Access denied" });
}
const url = await s3Service.getPresignedDownloadUrl(key);
res.json({ url, expiresIn: 3600 });
} catch (error) {
next(error);
}
}
);
module.exports = router;

View File

@@ -1,14 +1,71 @@
const express = require('express');
const { User, UserAddress } = require('../models'); // Import from models/index.js to get models with associations
const { authenticateToken } = require('../middleware/auth');
const { uploadProfileImage } = require('../middleware/upload');
const { authenticateToken, optionalAuth, requireAdmin } = require('../middleware/auth');
const { validateCoordinatesBody, validatePasswordChange, handleValidationErrors, sanitizeInput } = require('../middleware/validation');
const { requireStepUpAuth } = require('../middleware/stepUpAuth');
const { csrfProtection } = require('../middleware/csrf');
const logger = require('../utils/logger');
const userService = require('../services/UserService');
const fs = require('fs').promises;
const path = require('path');
const emailServices = require('../services/email');
const { validateS3Keys } = require('../utils/s3KeyValidator');
const { IMAGE_LIMITS } = require('../config/imageLimits');
const router = express.Router();
router.get('/profile', authenticateToken, async (req, res) => {
// Allowed fields for profile update (prevents mass assignment)
const ALLOWED_PROFILE_FIELDS = [
'firstName',
'lastName',
'email',
'phone',
'address1',
'address2',
'city',
'state',
'zipCode',
'country',
'imageFilename',
'itemRequestNotificationRadius',
];
// Allowed fields for user address create/update (prevents mass assignment)
const ALLOWED_ADDRESS_FIELDS = [
'address1',
'address2',
'city',
'state',
'zipCode',
'country',
'latitude',
'longitude',
];
/**
* Extract only allowed fields from request body
*/
function extractAllowedProfileFields(body) {
const result = {};
for (const field of ALLOWED_PROFILE_FIELDS) {
if (body[field] !== undefined) {
result[field] = body[field];
}
}
return result;
}
/**
* Extract only allowed address fields from request body
*/
function extractAllowedAddressFields(body) {
const result = {};
for (const field of ALLOWED_ADDRESS_FIELDS) {
if (body[field] !== undefined) {
result[field] = body[field];
}
}
return result;
}
router.get('/profile', authenticateToken, async (req, res, next) => {
try {
const user = await User.findByPk(req.user.id, {
attributes: { exclude: ['password'] }
@@ -27,12 +84,12 @@ router.get('/profile', authenticateToken, async (req, res) => {
stack: error.stack,
userId: req.user.id
});
res.status(500).json({ error: error.message });
next(error);
}
});
// Address routes (must come before /:id route)
router.get('/addresses', authenticateToken, async (req, res) => {
router.get('/addresses', authenticateToken, async (req, res, next) => {
try {
const addresses = await UserAddress.findAll({
where: { userId: req.user.id },
@@ -52,13 +109,15 @@ router.get('/addresses', authenticateToken, async (req, res) => {
stack: error.stack,
userId: req.user.id
});
res.status(500).json({ error: error.message });
next(error);
}
});
router.post('/addresses', authenticateToken, async (req, res) => {
router.post('/addresses', authenticateToken, ...validateCoordinatesBody, handleValidationErrors, async (req, res, next) => {
try {
const address = await userService.createUserAddress(req.user.id, req.body);
// Extract only allowed fields (prevents mass assignment)
const allowedData = extractAllowedAddressFields(req.body);
const address = await userService.createUserAddress(req.user.id, allowedData);
res.status(201).json(address);
} catch (error) {
@@ -69,13 +128,15 @@ router.post('/addresses', authenticateToken, async (req, res) => {
userId: req.user.id,
addressData: logger.sanitize(req.body)
});
res.status(500).json({ error: error.message });
next(error);
}
});
router.put('/addresses/:id', authenticateToken, async (req, res) => {
router.put('/addresses/:id', authenticateToken, ...validateCoordinatesBody, handleValidationErrors, async (req, res, next) => {
try {
const address = await userService.updateUserAddress(req.user.id, req.params.id, req.body);
// Extract only allowed fields (prevents mass assignment)
const allowedData = extractAllowedAddressFields(req.body);
const address = await userService.updateUserAddress(req.user.id, req.params.id, allowedData);
res.json(address);
} catch (error) {
@@ -88,14 +149,14 @@ router.put('/addresses/:id', authenticateToken, async (req, res) => {
});
if (error.message === 'Address not found') {
return res.status(404).json({ error: error.message });
return res.status(404).json({ error: 'Address not found' });
}
res.status(500).json({ error: error.message });
next(error);
}
});
router.delete('/addresses/:id', authenticateToken, async (req, res) => {
router.delete('/addresses/:id', authenticateToken, async (req, res, next) => {
try {
await userService.deleteUserAddress(req.user.id, req.params.id);
@@ -110,15 +171,15 @@ router.delete('/addresses/:id', authenticateToken, async (req, res) => {
});
if (error.message === 'Address not found') {
return res.status(404).json({ error: error.message });
return res.status(404).json({ error: 'Address not found' });
}
res.status(500).json({ error: error.message });
next(error);
}
});
// User availability routes (must come before /:id route)
router.get('/availability', authenticateToken, async (req, res) => {
router.get('/availability', authenticateToken, async (req, res, next) => {
try {
const user = await User.findByPk(req.user.id, {
attributes: ['defaultAvailableAfter', 'defaultAvailableBefore', 'defaultSpecifyTimesPerDay', 'defaultWeeklyTimes']
@@ -130,11 +191,11 @@ router.get('/availability', authenticateToken, async (req, res) => {
weeklyTimes: user.defaultWeeklyTimes
});
} catch (error) {
res.status(500).json({ error: error.message });
next(error);
}
});
router.put('/availability', authenticateToken, async (req, res) => {
router.put('/availability', authenticateToken, async (req, res, next) => {
try {
const { generalAvailableAfter, generalAvailableBefore, specifyTimesPerDay, weeklyTimes } = req.body;
@@ -149,14 +210,24 @@ router.put('/availability', authenticateToken, async (req, res) => {
res.json({ message: 'Availability updated successfully' });
} catch (error) {
res.status(500).json({ error: error.message });
next(error);
}
});
router.get('/:id', async (req, res) => {
router.get('/:id', optionalAuth, async (req, res, next) => {
try {
const isAdmin = req.user?.role === 'admin';
// Base attributes to exclude
const excludedAttributes = ['password', 'email', 'phone', 'address', 'verificationToken', 'passwordResetToken'];
// If not admin, also exclude ban-related fields
if (!isAdmin) {
excludedAttributes.push('isBanned', 'bannedAt', 'bannedBy', 'banReason');
}
const user = await User.findByPk(req.params.id, {
attributes: { exclude: ['password', 'email', 'phone', 'address'] }
attributes: { exclude: excludedAttributes }
});
if (!user) {
@@ -165,7 +236,8 @@ router.get('/:id', async (req, res) => {
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Public user profile fetched", {
requestedUserId: req.params.id
requestedUserId: req.params.id,
viewerIsAdmin: isAdmin
});
res.json(user);
@@ -176,84 +248,219 @@ router.get('/:id', async (req, res) => {
stack: error.stack,
requestedUserId: req.params.id
});
res.status(500).json({ error: error.message });
next(error);
}
});
router.put('/profile', authenticateToken, async (req, res) => {
router.put('/profile', authenticateToken, async (req, res, next) => {
try {
// Extract only allowed fields (prevents mass assignment)
const allowedData = extractAllowedProfileFields(req.body);
// Validate imageFilename if provided
if (allowedData.imageFilename !== undefined && allowedData.imageFilename !== null) {
const keyValidation = validateS3Keys([allowedData.imageFilename], 'profiles', { maxKeys: IMAGE_LIMITS.profile });
if (!keyValidation.valid) {
return res.status(400).json({
error: keyValidation.error,
details: keyValidation.invalidKeys
});
}
}
// Use UserService to handle update and email notification
const updatedUser = await userService.updateProfile(req.user.id, req.body);
const updatedUser = await userService.updateProfile(req.user.id, allowedData);
res.json(updatedUser);
} catch (error) {
console.error('Profile update error:', error);
res.status(500).json({
error: error.message,
details: error.errors ? error.errors.map(e => ({ field: e.path, message: e.message })) : undefined
});
logger.error('Profile update error', { error });
next(error);
}
});
// Upload profile image endpoint
router.post('/profile/image', authenticateToken, (req, res) => {
uploadProfileImage(req, res, async (err) => {
if (err) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Profile image upload error", {
error: err.message,
userId: req.user.id
});
return res.status(400).json({ error: err.message });
// Admin: Ban a user
router.post('/admin/:id/ban', authenticateToken, requireAdmin, async (req, res, next) => {
try {
const { reason } = req.body;
const targetUserId = req.params.id;
// Validate reason is provided
if (!reason || !reason.trim()) {
return res.status(400).json({ error: "Ban reason is required" });
}
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
// Prevent banning yourself
if (targetUserId === req.user.id) {
return res.status(400).json({ error: "You cannot ban yourself" });
}
const targetUser = await User.findByPk(targetUserId);
if (!targetUser) {
return res.status(404).json({ error: "User not found" });
}
// Prevent banning other admins
if (targetUser.role === 'admin') {
return res.status(403).json({ error: "Cannot ban admin users" });
}
// Check if already banned
if (targetUser.isBanned) {
return res.status(400).json({ error: "User is already banned" });
}
// Ban the user (this also invalidates sessions via jwtVersion increment)
await targetUser.banUser(req.user.id, reason.trim());
// Send ban notification email
try {
// Delete old profile image if exists
const user = await User.findByPk(req.user.id);
if (user.profileImage) {
const oldImagePath = path.join(__dirname, '../uploads/profiles', user.profileImage);
try {
await fs.unlink(oldImagePath);
} catch (unlinkErr) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.warn("Error deleting old profile image", {
error: unlinkErr.message,
userId: req.user.id,
oldImagePath
});
}
}
// Update user with new image filename
await user.update({
profileImage: req.file.filename
});
const emailServices = require("../services/email");
await emailServices.userEngagement.sendUserBannedNotification(
targetUser,
req.user,
reason.trim()
);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Profile image uploaded successfully", {
userId: req.user.id,
filename: req.file.filename
reqLogger.info("User ban notification email sent", {
bannedUserId: targetUserId,
adminId: req.user.id
});
res.json({
message: 'Profile image uploaded successfully',
filename: req.file.filename,
imageUrl: `/uploads/profiles/${req.file.filename}`
});
} catch (error) {
} catch (emailError) {
// Log but don't fail the ban operation
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Profile image database update failed", {
error: error.message,
stack: error.stack,
reqLogger.error('Failed to send user ban notification email', {
error: emailError.message,
stack: emailError.stack,
bannedUserId: targetUserId,
adminId: req.user.id
});
}
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("User banned by admin", {
targetUserId,
adminId: req.user.id,
reason: reason.trim()
});
// Return updated user data (excluding sensitive fields)
const updatedUser = await User.findByPk(targetUserId, {
attributes: { exclude: ['password', 'verificationToken', 'passwordResetToken'] }
});
res.json({
message: "User has been banned successfully",
user: updatedUser
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Admin ban user failed", {
error: error.message,
stack: error.stack,
targetUserId: req.params.id,
adminId: req.user.id
});
next(error);
}
});
// Change password (requires step-up auth if 2FA is enabled)
router.put('/password', authenticateToken, csrfProtection, requireStepUpAuth('password_change'), sanitizeInput, validatePasswordChange, async (req, res, next) => {
try {
const { currentPassword, newPassword } = req.body;
const user = await User.findByPk(req.user.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Google OAuth users can't change password
if (user.authProvider === 'google' && !user.password) {
return res.status(400).json({
error: 'Cannot change password for accounts linked with Google'
});
}
// Verify current password
const isValid = await user.comparePassword(currentPassword);
if (!isValid) {
return res.status(400).json({ error: 'Current password is incorrect' });
}
// Update password (this increments jwtVersion to invalidate other sessions)
await user.resetPassword(newPassword);
// Send password changed notification
try {
await emailServices.auth.sendPasswordChangedEmail(user);
} catch (emailError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error('Failed to send password changed email', {
error: emailError.message,
userId: req.user.id
});
res.status(500).json({ error: 'Failed to update profile image' });
}
});
const reqLogger = logger.withRequestId(req.id);
reqLogger.info('Password changed successfully', { userId: req.user.id });
res.json({ message: 'Password changed successfully' });
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error('Password change failed', {
error: error.message,
stack: error.stack,
userId: req.user.id
});
next(error);
}
});
// Admin: Unban a user
router.post('/admin/:id/unban', authenticateToken, requireAdmin, async (req, res, next) => {
try {
const targetUserId = req.params.id;
const targetUser = await User.findByPk(targetUserId);
if (!targetUser) {
return res.status(404).json({ error: "User not found" });
}
// Check if user is actually banned
if (!targetUser.isBanned) {
return res.status(400).json({ error: "User is not banned" });
}
// Unban the user
await targetUser.unbanUser();
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("User unbanned by admin", {
targetUserId,
adminId: req.user.id
});
// Return updated user data (excluding sensitive fields)
const updatedUser = await User.findByPk(targetUserId, {
attributes: { exclude: ['password', 'verificationToken', 'passwordResetToken'] }
});
res.json({
message: "User has been unbanned successfully",
user: updatedUser
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Admin unban user failed", {
error: error.message,
stack: error.stack,
targetUserId: req.params.id,
adminId: req.user.id
});
next(error);
}
});
module.exports = router;

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;
@@ -353,8 +360,8 @@ async function main() {
const command = args[0];
try {
// Sync database
await sequelize.sync();
// Verify database connection
await sequelize.authenticate();
if (!command || command === "help") {
console.log(`
@@ -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

@@ -0,0 +1,225 @@
#!/usr/bin/env node
/**
* Migration Test Script
*
* Tests that all migrations can run successfully up and down.
* This script:
* 1. Connects to a test database
* 2. Runs all migrations down (clean slate)
* 3. Runs all migrations up
* 4. Verifies tables were created
* 5. Runs all migrations down (test rollback)
* 6. Runs all migrations up again (test idempotency)
* 7. Reports results
*
* Usage:
* NODE_ENV=test npm run test:migrations
*
* Requires:
* - Test database to exist (create with: npm run db:create)
* - Environment variables set for test database connection
*/
const { execSync } = require("child_process");
const path = require("path");
// Colors for console output
const colors = {
reset: "\x1b[0m",
green: "\x1b[32m",
red: "\x1b[31m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
};
function log(message, color = colors.reset) {
console.log(`${color}${message}${colors.reset}`);
}
function logStep(step, message) {
log(`\n[${step}] ${message}`, colors.blue);
}
function logSuccess(message) {
log(`${message}`, colors.green);
}
function logError(message) {
log(`${message}`, colors.red);
}
function logWarning(message) {
log(`${message}`, colors.yellow);
}
function runCommand(command, description) {
try {
log(` Running: ${command}`, colors.yellow);
const output = execSync(command, {
cwd: path.resolve(__dirname, ".."),
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, NODE_ENV: process.env.NODE_ENV || "test" },
});
if (output.trim()) {
console.log(output);
}
logSuccess(description);
return { success: true, output };
} catch (error) {
logError(`${description} failed`);
console.error(error.stderr || error.message);
return { success: false, error };
}
}
async function main() {
log("\n========================================", colors.blue);
log(" Migration Test Suite", colors.blue);
log("========================================\n", colors.blue);
const env = process.env.NODE_ENV;
// Safety checks - only allow running against test database
if (!env) {
logError("NODE_ENV is not set!");
logError("This script will DELETE ALL DATA in the target database.");
logError("You must explicitly set NODE_ENV=test to run this script.");
log("\nUsage: NODE_ENV=test npm run test:migrations\n");
process.exit(1);
}
if (env.toLowerCase() !== "test") {
logWarning(`Unrecognized NODE_ENV: ${env}`);
logWarning("This script will DELETE ALL DATA in the target database.");
logWarning("Recommended: NODE_ENV=test npm run test:migrations");
log("");
}
log(`Environment: ${env}`);
const results = {
steps: [],
passed: 0,
failed: 0,
};
function recordResult(step, success) {
results.steps.push({ step, success });
if (success) {
results.passed++;
} else {
results.failed++;
}
}
// Step 1: Check migration status
logStep(1, "Checking current migration status");
const statusResult = runCommand(
"npx sequelize-cli db:migrate:status",
"Migration status check"
);
recordResult("Status check", statusResult.success);
// Step 2: Undo all migrations (clean slate)
logStep(2, "Undoing all migrations (clean slate)");
const undoAllResult = runCommand(
"npx sequelize-cli db:migrate:undo:all",
"Undo all migrations"
);
recordResult("Undo all migrations", undoAllResult.success);
if (!undoAllResult.success) {
logWarning("Undo failed - database may already be empty, continuing...");
}
// Step 3: Run all migrations up
logStep(3, "Running all migrations up");
const migrateUpResult = runCommand(
"npx sequelize-cli db:migrate",
"Run all migrations"
);
recordResult("Migrate up", migrateUpResult.success);
if (!migrateUpResult.success) {
logError("Migration up failed - cannot continue");
printSummary(results);
process.exit(1);
}
// Step 4: Verify migration status shows all executed
logStep(4, "Verifying all migrations executed");
const verifyResult = runCommand(
"npx sequelize-cli db:migrate:status",
"Verify migration status"
);
recordResult("Verify status", verifyResult.success);
// Step 5: Test rollback - undo all migrations
logStep(5, "Testing rollback - undoing all migrations");
const rollbackResult = runCommand(
"npx sequelize-cli db:migrate:undo:all",
"Rollback all migrations"
);
recordResult("Rollback", rollbackResult.success);
if (!rollbackResult.success) {
logError("Rollback failed - down migrations have issues");
printSummary(results);
process.exit(1);
}
// Step 6: Test idempotency - run migrations up again
logStep(6, "Testing idempotency - running migrations up again");
const idempotencyResult = runCommand(
"npx sequelize-cli db:migrate",
"Re-run all migrations"
);
recordResult("Idempotency test", idempotencyResult.success);
if (!idempotencyResult.success) {
logError("Idempotency test failed - migrations may not be repeatable");
printSummary(results);
process.exit(1);
}
// Step 7: Final status check
logStep(7, "Final migration status");
const finalStatusResult = runCommand(
"npx sequelize-cli db:migrate:status",
"Final status check"
);
recordResult("Final status", finalStatusResult.success);
printSummary(results);
if (results.failed > 0) {
process.exit(1);
}
log("\nMigration tests completed successfully!", colors.green);
process.exit(0);
}
function printSummary(results) {
log("\n========================================", colors.blue);
log(" Test Summary", colors.blue);
log("========================================\n", colors.blue);
results.steps.forEach(({ step, success }) => {
if (success) {
logSuccess(step);
} else {
logError(step);
}
});
log(`\nTotal: ${results.passed} passed, ${results.failed} failed`);
}
main().catch((error) => {
logError("Unexpected error:");
console.error(error);
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,14 +25,16 @@ const rentalRoutes = require("./routes/rentals");
const messageRoutes = require("./routes/messages");
const forumRoutes = require("./routes/forum");
const stripeRoutes = require("./routes/stripe");
const stripeWebhookRoutes = require("./routes/stripeWebhooks");
const mapsRoutes = require("./routes/maps");
const conditionCheckRoutes = require("./routes/conditionChecks");
const feedbackRoutes = require("./routes/feedback");
const uploadRoutes = require("./routes/upload");
const healthRoutes = require("./routes/health");
const twoFactorRoutes = require("./routes/twoFactor");
const PayoutProcessor = require("./jobs/payoutProcessor");
const RentalStatusJob = require("./jobs/rentalStatusJob");
const ConditionCheckReminderJob = require("./jobs/conditionCheckReminder");
const emailServices = require("./services/email");
const s3Service = require("./services/s3Service");
// Socket.io setup
const { authenticateSocket } = require("./sockets/socketAuth");
@@ -44,7 +46,7 @@ const server = http.createServer(app);
// Initialize Socket.io with CORS
const io = new Server(server, {
cors: {
origin: process.env.FRONTEND_URL || "http://localhost:3000",
origin: process.env.FRONTEND_URL,
credentials: true,
methods: ["GET", "POST"],
},
@@ -66,6 +68,7 @@ const {
addRequestId,
sanitizeError,
} = require("./middleware/security");
const { sanitizeInput } = require("./middleware/validation");
const { generalLimiter } = require("./middleware/rateLimiter");
const errorLogger = require("./middleware/errorLogger");
const apiLogger = require("./middleware/apiLogger");
@@ -90,7 +93,7 @@ app.use(
frameSrc: ["'self'", "https://accounts.google.com"],
},
},
})
}),
);
// Cookie parser for CSRF
@@ -105,10 +108,11 @@ app.use("/api/", apiLogger);
// CORS with security settings (must come BEFORE rate limiter to ensure headers on all responses)
app.use(
cors({
origin: process.env.FRONTEND_URL || "http://localhost:3000",
origin: process.env.FRONTEND_URL,
credentials: true,
optionsSuccessStatus: 200,
})
exposedHeaders: ["X-CSRF-Token"],
}),
);
// General rate limiting for all routes
@@ -122,31 +126,34 @@ app.use(
// Store raw body for webhook verification
req.rawBody = buf;
},
})
}),
);
app.use(
bodyParser.urlencoded({
extended: true,
limit: "1mb",
parameterLimit: 100, // Limit number of parameters
})
}),
);
// Serve static files from uploads directory with CORS headers
app.use(
"/uploads",
helmet.crossOriginResourcePolicy({ policy: "cross-origin" }),
express.static(path.join(__dirname, "uploads"))
);
// Apply input sanitization to all API routes (XSS prevention)
app.use("/api/", sanitizeInput);
// Health check endpoints (no auth, no rate limiting)
app.use("/health", healthRoutes);
// Stripe webhooks (no auth, uses signature verification instead)
app.use("/api/stripe/webhooks", stripeWebhookRoutes);
// Root endpoint
app.get("/", (req, res) => {
res.json({ message: "Village Share API is running!" });
});
// Public routes (no alpha access required)
app.use("/api/alpha", alphaRoutes);
app.use("/api/auth", authRoutes); // Auth has its own alpha checks in registration
// Health check endpoint
app.get("/", (req, res) => {
res.json({ message: "CommunityRentals.App API is running!" });
});
app.use("/api/2fa", twoFactorRoutes); // 2FA routes require authentication (handled in router)
// Protected routes (require alpha access)
app.use("/api/users", requireAlphaAccess, userRoutes);
@@ -158,17 +165,31 @@ app.use("/api/stripe", requireAlphaAccess, stripeRoutes);
app.use("/api/maps", requireAlphaAccess, mapsRoutes);
app.use("/api/condition-checks", requireAlphaAccess, conditionCheckRoutes);
app.use("/api/feedback", requireAlphaAccess, feedbackRoutes);
app.use("/api/upload", requireAlphaAccess, uploadRoutes);
// Error handling middleware (must be last)
app.use(errorLogger);
app.use(sanitizeError);
const PORT = process.env.PORT || 5000;
const PORT = process.env.PORT;
const { checkPendingMigrations } = require("./utils/checkMigrations");
sequelize
.sync({ alter: true })
.authenticate()
.then(async () => {
logger.info("Database synced successfully");
logger.info("Database connection established successfully");
// Check for pending migrations
const pendingMigrations = await checkPendingMigrations(sequelize);
if (pendingMigrations.length > 0) {
logger.error(
`Found ${pendingMigrations.length} pending migration(s). Please run 'npm run db:migrate'`,
{ pendingMigrations },
);
process.exit(1);
}
logger.info("All migrations are up to date");
// Initialize email services and load templates
try {
@@ -181,24 +202,29 @@ sequelize
});
// Fail fast - don't start server if email templates can't load
if (env === "prod" || env === "production") {
logger.error("Cannot start server without email services in production");
logger.error(
"Cannot start server without email services in production",
);
process.exit(1);
} else {
logger.warn("Email services failed to initialize - continuing in dev mode");
logger.warn(
"Email services failed to initialize - continuing in dev mode",
);
}
}
// Start the payout processor
const payoutJobs = PayoutProcessor.startScheduledPayouts();
logger.info("Payout processor started");
// Start the rental status job
const rentalStatusJobs = RentalStatusJob.startScheduledStatusUpdates();
logger.info("Rental status job started");
// Start the condition check reminder job
const conditionCheckJobs = ConditionCheckReminderJob.startScheduledReminders();
logger.info("Condition check reminder job started");
// Initialize S3 service for image uploads
try {
s3Service.initialize();
logger.info("S3 service initialized successfully");
} catch (err) {
logger.error("Failed to initialize S3 service", {
error: err.message,
stack: err.stack,
});
logger.error("Cannot start server without S3 service in production");
process.exit(1);
}
server.listen(PORT, () => {
logger.info(`Server is running on port ${PORT}`, {
@@ -209,8 +235,9 @@ sequelize
});
})
.catch((err) => {
logger.error("Unable to sync database", {
logger.error("Unable to connect to database", {
error: err.message,
stack: err.stack,
});
process.exit(1);
});

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":
@@ -116,18 +117,16 @@ class ConditionCheckService {
* @param {string} rentalId - Rental ID
* @param {string} checkType - Type of check
* @param {string} userId - User submitting the check
* @param {Array} photos - Array of photo URLs
* @param {Array} imageFilenames - Array of image filenames
* @param {string} notes - Optional notes
* @param {Object} metadata - Additional metadata (device info, location, etc.)
* @returns {Object} - Created condition check
*/
static async submitConditionCheck(
rentalId,
checkType,
userId,
photos = [],
notes = null,
metadata = {}
imageFilenames = [],
notes = null
) {
// Validate the check
const validation = await this.validateConditionCheck(
@@ -141,44 +140,42 @@ class ConditionCheckService {
}
// Validate photos (basic validation)
if (photos.length > 20) {
if (imageFilenames.length > 20) {
throw new Error("Maximum 20 photos allowed per condition check");
}
// Add timestamp and user agent to metadata
const enrichedMetadata = {
...metadata,
submittedAt: new Date().toISOString(),
userAgent: metadata.userAgent || "Unknown",
ipAddress: metadata.ipAddress || "Unknown",
deviceType: metadata.deviceType || "Unknown",
};
const conditionCheck = await ConditionCheck.create({
rentalId,
checkType,
submittedBy: userId,
photos,
imageFilenames,
notes,
metadata: enrichedMetadata,
});
return conditionCheck;
}
/**
* Get all condition checks for a rental
* @param {string} rentalId - Rental ID
* Get all condition checks for multiple rentals (batch)
* @param {Array<string>} rentalIds - Array of Rental IDs
* @returns {Array} - Array of condition checks with user info
*/
static async getConditionChecks(rentalId) {
static async getConditionChecksForRentals(rentalIds) {
if (!rentalIds || rentalIds.length === 0) {
return [];
}
const checks = await ConditionCheck.findAll({
where: { rentalId },
where: {
rentalId: {
[Op.in]: rentalIds,
},
},
include: [
{
model: User,
as: "submittedByUser",
attributes: ["id", "username", "firstName", "lastName"],
attributes: ["id", "firstName", "lastName"],
},
],
order: [["submittedAt", "ASC"]],
@@ -187,119 +184,24 @@ class ConditionCheckService {
return checks;
}
/**
* Get condition check timeline for a rental
* @param {string} rentalId - Rental ID
* @returns {Object} - Timeline showing what checks are available/completed
*/
static async getConditionCheckTimeline(rentalId) {
const rental = await Rental.findByPk(rentalId);
if (!rental) {
throw new Error("Rental not found");
}
const existingChecks = await ConditionCheck.findAll({
where: { rentalId },
include: [
{
model: User,
as: "submittedByUser",
attributes: ["id", "username", "firstName", "lastName"],
},
],
});
const checkTypes = [
"pre_rental_owner",
"rental_start_renter",
"rental_end_renter",
"post_rental_owner",
];
const timeline = {};
for (const checkType of checkTypes) {
const existingCheck = existingChecks.find(
(check) => check.checkType === checkType
);
if (existingCheck) {
timeline[checkType] = {
status: "completed",
submittedAt: existingCheck.submittedAt,
submittedBy: existingCheck.submittedBy,
photoCount: existingCheck.photos.length,
hasNotes: !!existingCheck.notes,
};
} else {
// Calculate if this check type is available
const now = new Date();
const startDate = new Date(rental.startDateTime);
const endDate = new Date(rental.endDateTime);
const twentyFourHours = 24 * 60 * 60 * 1000;
let timeWindow = {};
let status = "not_available";
switch (checkType) {
case "pre_rental_owner":
timeWindow.start = new Date(startDate.getTime() - twentyFourHours);
timeWindow.end = startDate;
break;
case "rental_start_renter":
timeWindow.start = startDate;
timeWindow.end = new Date(startDate.getTime() + twentyFourHours);
break;
case "rental_end_renter":
timeWindow.start = new Date(endDate.getTime() - twentyFourHours);
timeWindow.end = endDate;
break;
case "post_rental_owner":
timeWindow.start = endDate;
timeWindow.end = new Date(endDate.getTime() + twentyFourHours);
break;
}
if (now >= timeWindow.start && now <= timeWindow.end) {
status = "available";
} else if (now < timeWindow.start) {
status = "pending";
} else {
status = "expired";
}
timeline[checkType] = {
status,
timeWindow,
availableFrom: timeWindow.start,
availableUntil: timeWindow.end,
};
}
}
return {
rental: {
id: rental.id,
startDateTime: rental.startDateTime,
endDateTime: rental.endDateTime,
status: rental.status,
},
timeline,
};
}
/**
* Get available condition checks for a user
* @param {string} userId - User ID
* @param {Array<string>} rentalIds - Array of rental IDs to check
* @returns {Array} - Array of available condition checks
*/
static async getAvailableChecks(userId) {
static async getAvailableChecks(userId, rentalIds) {
if (!rentalIds || rentalIds.length === 0) {
return [];
}
const now = new Date();
const twentyFourHours = 24 * 60 * 60 * 1000;
// Find rentals where user is owner or renter
// Find specified rentals where user is owner or renter
const rentals = await Rental.findAll({
where: {
id: { [Op.in]: rentalIds },
[Op.or]: [{ ownerId: userId }, { renterId: userId }],
status: {
[Op.in]: ["confirmed", "active", "completed"],

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

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" };
}
@@ -79,7 +80,7 @@ class EmailClient {
}
// Use friendly sender name format for better recognition
const fromName = process.env.SES_FROM_NAME || "RentAll";
const fromName = process.env.SES_FROM_NAME || "Village Share";
const fromEmail = process.env.SES_FROM_EMAIL;
const source = `${fromName} <${fromEmail}>`;
@@ -115,12 +116,10 @@ class EmailClient {
const command = new SendEmailCommand(params);
const result = await this.sesClient.send(command);
console.log(
`Email sent successfully to ${to}, MessageId: ${result.MessageId}`
);
logger.info("Email sent successfully", { to, messageId: result.MessageId });
return { success: true, messageId: result.MessageId };
} catch (error) {
console.error("Failed to send email:", error);
logger.error("Failed to send email", { error, to });
return { success: false, error: error.message };
}
}

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,342 +149,94 @@ class TemplateManager {
async renderTemplate(templateName, variables = {}) {
// Ensure service is initialized before rendering
if (!this.initialized) {
console.log(`Template manager not initialized yet, initializing now...`);
logger.debug("Template manager not initialized yet, initializing now...");
await this.initialize();
}
let template = this.templates.get(templateName);
let template;
if (!template) {
console.error(`Template not found: ${templateName}`);
console.error(
`Available templates: ${Array.from(this.templates.keys()).join(", ")}`
);
console.error(`Stack trace:`, new Error().stack);
console.log(`Using fallback template for: ${templateName}`);
template = this.getFallbackTemplate(templateName);
// Check if template exists in our discovered templates
if (this.templateNames.has(templateName)) {
// Lazy load the template if not already cached
template = await this.loadTemplate(templateName);
} else {
console.log(`Template found: ${templateName}`);
logger.error("Template not found, using fallback", {
templateName,
discoveredTemplates: Array.from(this.templateNames),
});
template = this.getFallbackTemplate(templateName);
}
let rendered = template;
try {
Object.keys(variables).forEach((key) => {
// Variables ending in 'Html' or 'Section' contain trusted HTML content
// (e.g., refundSection, stripeSection, earningsSection) - don't escape these
const isTrustedHtml = key.endsWith("Html") || key.endsWith("Section");
let value = variables[key] || "";
// Escape HTML in user-provided values to prevent XSS
if (!isTrustedHtml && typeof value === "string") {
value = escapeHtml(value);
}
const regex = new RegExp(`{{${key}}}`, "g");
rendered = rendered.replace(regex, variables[key] || "");
rendered = rendered.replace(regex, value);
});
} catch (error) {
console.error(`Error rendering template ${templateName}:`, error);
console.error(`Stack trace:`, error.stack);
console.error(`Variables provided:`, Object.keys(variables));
logger.error("Error rendering template", {
templateName,
variableKeys: Object.keys(variables),
error,
});
}
return rendered;
}
/**
* Get a fallback template when the HTML file is not available
* @param {string} templateName - Name of the template
* @returns {string} Fallback HTML template
* Get a generic fallback template when the HTML file is not available
* This is used as a last resort when a template cannot be loaded
* @param {string} templateName - Name of the template (for logging)
* @returns {string} Generic fallback HTML template
*/
getFallbackTemplate(templateName) {
const baseTemplate = `
logger.warn("Using generic fallback template", { templateName });
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{title}}</title>
<title>Village Share</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5; }
.container { max-width: 600px; margin: 0 auto; background-color: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.header { text-align: center; border-bottom: 2px solid #e9ecef; padding-bottom: 20px; margin-bottom: 30px; }
.logo { font-size: 24px; font-weight: bold; color: #333; }
.content { line-height: 1.6; color: #555; }
.button { display: inline-block; background-color: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; margin: 20px 0; }
.footer { margin-top: 30px; padding-top: 20px; border-top: 1px solid #e9ecef; text-align: center; font-size: 12px; color: #6c757d; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">RentAll</div>
<div class="logo">Village Share</div>
</div>
<div class="content">
{{content}}
<p>Hi {{recipientName}},</p>
<h2>{{title}}</h2>
<p>{{message}}</p>
</div>
<div class="footer">
<p>This email was sent from RentAll. If you have any questions, please contact support.</p>
<p>This email was sent from Village Share. If you have any questions, please contact support.</p>
</div>
</div>
</body>
</html>
`;
const templates = {
conditionCheckReminderToUser: baseTemplate.replace(
"{{content}}",
`
<h2>{{title}}</h2>
<p>{{message}}</p>
<p><strong>Rental Item:</strong> {{itemName}}</p>
<p><strong>Deadline:</strong> {{deadline}}</p>
<p>Please complete this condition check as soon as possible to ensure proper documentation.</p>
`
),
rentalConfirmationToUser: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{recipientName}},</p>
<h2>{{title}}</h2>
<p>{{message}}</p>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
<p>Thank you for using RentAll!</p>
`
),
emailVerificationToUser: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{recipientName}},</p>
<h2>Verify Your Email Address</h2>
<p>Thank you for registering with RentAll! Please verify your email address by clicking the button below.</p>
<p><a href="{{verificationUrl}}" class="button">Verify Email Address</a></p>
<p>If the button doesn't work, copy and paste this link into your browser: {{verificationUrl}}</p>
<p><strong>This link will expire in 24 hours.</strong></p>
`
),
passwordResetToUser: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{recipientName}},</p>
<h2>Reset Your Password</h2>
<p>We received a request to reset the password for your RentAll account. Click the button below to choose a new password.</p>
<p><a href="{{resetUrl}}" class="button">Reset Password</a></p>
<p>If the button doesn't work, copy and paste this link into your browser: {{resetUrl}}</p>
<p><strong>This link will expire in 1 hour.</strong></p>
<p>If you didn't request this, you can safely ignore this email.</p>
`
),
passwordChangedToUser: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{recipientName}},</p>
<h2>Your Password Has Been Changed</h2>
<p>This is a confirmation that the password for your RentAll account ({{email}}) has been successfully changed.</p>
<p><strong>Changed on:</strong> {{timestamp}}</p>
<p>For your security, all existing sessions have been logged out.</p>
<p><strong>Didn't change your password?</strong> If you did not make this change, please contact our support team immediately.</p>
`
),
rentalRequestToOwner: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{ownerName}},</p>
<h2>New Rental Request for {{itemName}}</h2>
<p>{{renterName}} would like to rent your item.</p>
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
<p><strong>Total Amount:</strong> ${{totalAmount}}</p>
<p><strong>Your Earnings:</strong> ${{payoutAmount}}</p>
<p><strong>Delivery Method:</strong> {{deliveryMethod}}</p>
<p><strong>Renter Notes:</strong> {{rentalNotes}}</p>
<p><a href="{{approveUrl}}" class="button">Review & Respond</a></p>
<p>Please respond to this request within 24 hours.</p>
`
),
rentalRequestConfirmationToRenter: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{renterName}},</p>
<h2>Your Rental Request Has Been Submitted!</h2>
<p>Your request to rent <strong>{{itemName}}</strong> has been sent to the owner.</p>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
<p><strong>Delivery Method:</strong> {{deliveryMethod}}</p>
<p><strong>Total Amount:</strong> ${{totalAmount}}</p>
<p>{{paymentMessage}}</p>
<p>You'll receive an email notification once the owner responds to your request.</p>
<p><a href="{{viewRentalsUrl}}" class="button">View My Rentals</a></p>
`
),
rentalCancellationConfirmationToUser: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{recipientName}},</p>
<h2>Rental Cancelled Successfully</h2>
<p>This confirms that your rental for <strong>{{itemName}}</strong> has been cancelled.</p>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Start Date:</strong> {{startDate}}</p>
<p><strong>End Date:</strong> {{endDate}}</p>
<p><strong>Cancelled On:</strong> {{cancelledAt}}</p>
{{refundSection}}
`
),
rentalCancellationNotificationToUser: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{recipientName}},</p>
<h2>Rental Cancellation Notice</h2>
<p>{{cancellationMessage}}</p>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Start Date:</strong> {{startDate}}</p>
<p><strong>End Date:</strong> {{endDate}}</p>
<p><strong>Cancelled On:</strong> {{cancelledAt}}</p>
{{additionalInfo}}
<p>If you have any questions or concerns, please reach out to our support team.</p>
`
),
payoutReceivedToOwner: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{ownerName}},</p>
<h2 style="color: #28a745;">Earnings Received: ${{payoutAmount}}</h2>
<p>Great news! Your earnings from the rental of <strong>{{itemName}}</strong> have been transferred to your account.</p>
<h3>Rental Details</h3>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
<p><strong>Transfer ID:</strong> {{stripeTransferId}}</p>
<h3>Earnings Breakdown</h3>
<p><strong>Rental Amount:</strong> ${{totalAmount}}</p>
<p><strong>Community Upkeep Fee (10%):</strong> -${{platformFee}}</p>
<p style="font-size: 18px; color: #28a745;"><strong>Your Earnings:</strong> ${{payoutAmount}}</p>
<p>Funds are typically available in your bank account within 2-3 business days.</p>
<p><a href="{{earningsDashboardUrl}}" class="button">View Earnings Dashboard</a></p>
<p>Thank you for being a valued member of the RentAll community!</p>
`
),
rentalDeclinedToRenter: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{renterName}},</p>
<h2>Rental Request Declined</h2>
<p>Thank you for your interest in renting <strong>{{itemName}}</strong>. Unfortunately, the owner is unable to accept your rental request at this time.</p>
<h3>Request Details</h3>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Start Date:</strong> {{startDate}}</p>
<p><strong>End Date:</strong> {{endDate}}</p>
<p><strong>Delivery Method:</strong> {{deliveryMethod}}</p>
{{ownerMessage}}
<div class="info-box">
<p><strong>What happens next?</strong></p>
<p>{{paymentMessage}}</p>
<p>We encourage you to explore other similar items available for rent on RentAll. There are many great options waiting for you!</p>
</div>
<p style="text-align: center;"><a href="{{browseItemsUrl}}" class="button">Browse Available Items</a></p>
<p>If you have any questions or concerns, please don't hesitate to contact our support team.</p>
`
),
rentalApprovalConfirmationToOwner: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{ownerName}},</p>
<h2>You've Approved the Rental Request!</h2>
<p>You've successfully approved the rental request for <strong>{{itemName}}</strong>.</p>
<h3>Rental Details</h3>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Renter:</strong> {{renterName}}</p>
<p><strong>Start Date:</strong> {{startDate}}</p>
<p><strong>End Date:</strong> {{endDate}}</p>
<p><strong>Your Earnings:</strong> ${{payoutAmount}}</p>
{{stripeSection}}
<h3>What's Next?</h3>
<ul>
<li>Coordinate with the renter on pickup details</li>
<li>Take photos of the item's condition before handoff</li>
<li>Provide any care instructions or usage tips</li>
</ul>
<p><a href="{{rentalDetailsUrl}}" class="button">View Rental Details</a></p>
`
),
rentalCompletionThankYouToRenter: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{renterName}},</p>
<h2>Thank You for Returning On Time!</h2>
<p>You've successfully returned <strong>{{itemName}}</strong> on time. On-time returns like yours help build trust in the RentAll community!</p>
<h3>Rental Summary</h3>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
<p><strong>Returned On:</strong> {{returnedDate}}</p>
{{reviewSection}}
<p><a href="{{browseItemsUrl}}" class="button">Browse Available Items</a></p>
`
),
rentalCompletionCongratsToOwner: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{ownerName}},</p>
<h2>Congratulations on Completing a Rental!</h2>
<p><strong>{{itemName}}</strong> has been successfully returned on time. Great job!</p>
<h3>Rental Summary</h3>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Renter:</strong> {{renterName}}</p>
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
{{earningsSection}}
{{stripeSection}}
<p><a href="{{owningUrl}}" class="button">View My Listings</a></p>
`
),
feedbackConfirmationToUser: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{userName}},</p>
<h2>Thank You for Your Feedback!</h2>
<p>We've received your feedback and our team will review it carefully.</p>
<div style="background-color: #f8f9fa; border-left: 4px solid #007bff; padding: 15px; margin: 20px 0; font-style: italic;">
{{feedbackText}}
</div>
<p><strong>Submitted:</strong> {{submittedAt}}</p>
<p>Your input helps us improve RentAll for everyone. We take all feedback seriously and use it to make the platform better.</p>
<p>If your feedback requires a response, our team will reach out to you directly.</p>
`
),
feedbackNotificationToAdmin: baseTemplate.replace(
"{{content}}",
`
<h2>New Feedback Received</h2>
<p><strong>From:</strong> {{userName}} ({{userEmail}})</p>
<p><strong>User ID:</strong> {{userId}}</p>
<p><strong>Submitted:</strong> {{submittedAt}}</p>
<h3>Feedback Content</h3>
<div style="background-color: #e7f3ff; border-left: 4px solid #007bff; padding: 20px; margin: 20px 0;">
{{feedbackText}}
</div>
<h3>Technical Context</h3>
<p><strong>Feedback ID:</strong> {{feedbackId}}</p>
<p><strong>Page URL:</strong> {{url}}</p>
<p><strong>User Agent:</strong> {{userAgent}}</p>
<p>Please review this feedback and take appropriate action if needed.</p>
`
),
};
return (
templates[templateName] ||
baseTemplate.replace(
"{{content}}",
`
<h2>{{title}}</h2>
<p>{{message}}</p>
`
)
);
}
}

View File

@@ -90,9 +90,26 @@ function formatCurrency(amount, currency = "USD") {
}).format(amount / 100);
}
/**
* 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 - RentAll",
htmlContent
"Your Alpha Access Code - Village Share",
htmlContent,
);
} catch (error) {
console.error("Failed to send alpha invitation email:", error);
logger.error("Failed to send alpha invitation email", { error });
return { success: false, error: error.message };
}
}

View File

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

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

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");
}
/**
@@ -39,7 +40,6 @@ class MessagingEmailService {
* @param {string} sender.firstName - Sender's first name
* @param {string} sender.lastName - Sender's last name
* @param {Object} message - Message object
* @param {string} message.subject - Message subject
* @param {string} message.content - Message content
* @param {Date} message.createdAt - Message creation timestamp
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
@@ -50,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", {
@@ -61,7 +61,6 @@ class MessagingEmailService {
const variables = {
recipientName: receiver.firstName || "there",
senderName: `${sender.firstName} ${sender.lastName}`.trim() || "A user",
subject: message.subject,
messageContent: message.content,
conversationUrl: conversationUrl,
timestamp: timestamp,
@@ -69,7 +68,7 @@ class MessagingEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"newMessageToUser",
variables
variables,
);
const subject = `New message from ${sender.firstName} ${sender.lastName}`;
@@ -77,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");
}
/**
@@ -53,7 +54,6 @@ class RentalFlowEmailService {
* @param {string} rental.totalAmount - Total rental amount
* @param {string} rental.payoutAmount - Owner's payout amount
* @param {string} rental.deliveryMethod - Delivery method
* @param {string} rental.notes - Rental notes from renter
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
*/
async sendRentalRequestEmail(owner, renter, rental) {
@@ -62,12 +62,13 @@ class RentalFlowEmailService {
}
try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
const approveUrl = `${frontendUrl}/owning?rentalId=${rental.id}`;
const variables = {
ownerName: owner.firstName,
renterName: `${renter.firstName} ${renter.lastName}`.trim() || "A renter",
renterName:
`${renter.firstName} ${renter.lastName}`.trim() || "A renter",
itemName: rental.item?.name || "your item",
startDate: rental.startDateTime
? new Date(rental.startDateTime).toLocaleString("en-US", {
@@ -88,22 +89,22 @@ class RentalFlowEmailService {
? parseFloat(rental.payoutAmount).toFixed(2)
: "0.00",
deliveryMethod: rental.deliveryMethod || "Not specified",
rentalNotes: rental.notes || "No additional notes provided",
intendedUse: rental.intendedUse || "Not specified",
approveUrl: approveUrl,
};
const htmlContent = await this.templateManager.renderTemplate(
"rentalRequestToOwner",
variables
variables,
);
return await this.emailClient.sendEmail(
owner.email,
`Rental Request for ${rental.item?.name || "Your Item"}`,
htmlContent
htmlContent,
);
} catch (error) {
console.error("Failed to send rental request email:", error);
logger.error("Failed to send rental request email", { error });
return { success: false, error: error.message };
}
}
@@ -128,7 +129,7 @@ class RentalFlowEmailService {
}
try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
const viewRentalsUrl = `${frontendUrl}/renting`;
// Determine payment message based on rental amount
@@ -161,16 +162,18 @@ class RentalFlowEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"rentalRequestConfirmationToRenter",
variables
variables,
);
return await this.emailClient.sendEmail(
renter.email,
`Rental Request Submitted - ${rental.item?.name || "Item"}`,
htmlContent
htmlContent,
);
} catch (error) {
console.error("Failed to send rental request confirmation email:", error);
logger.error("Failed to send rental request confirmation email", {
error,
});
return { success: false, error: error.message };
}
}
@@ -202,7 +205,7 @@ class RentalFlowEmailService {
}
try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
// Determine if Stripe setup is needed
const hasStripeAccount = !!owner.stripeConnectedAccountId;
@@ -227,15 +230,15 @@ class RentalFlowEmailService {
<table class="info-table">
<tr>
<th>Total Rental Amount</th>
<td>\\$${totalAmount.toFixed(2)}</td>
<td>$${totalAmount.toFixed(2)}</td>
</tr>
<tr>
<th>Community Upkeep Fee (10%)</th>
<td>-\\$${platformFee.toFixed(2)}</td>
<td>-$${platformFee.toFixed(2)}</td>
</tr>
<tr>
<th>Your Payout</th>
<td class="highlight">\\$${payoutAmount.toFixed(2)}</td>
<td class="highlight">$${payoutAmount.toFixed(2)}</td>
</tr>
</table>
`;
@@ -248,8 +251,8 @@ class RentalFlowEmailService {
stripeSection = `
<div class="warning-box">
<p><strong>⚠️ Action Required: Set Up Your Earnings Account</strong></p>
<p>To receive your payout of <strong>\\$${payoutAmount.toFixed(
2
<p>To receive your payout of <strong>$${payoutAmount.toFixed(
2,
)}</strong> when this rental completes, you need to set up your earnings account.</p>
</div>
<h2>Set Up Earnings to Get Paid</h2>
@@ -259,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>
@@ -274,8 +277,8 @@ class RentalFlowEmailService {
stripeSection = `
<div class="success-box">
<p><strong>✓ Earnings Account Active</strong></p>
<p>Your earnings account is set up. You'll automatically receive \\$${payoutAmount.toFixed(
2
<p>Your earnings account is set up. You'll automatically receive $${payoutAmount.toFixed(
2,
)} when this rental completes.</p>
<p><a href="${frontendUrl}/earnings" style="color: #155724; text-decoration: underline;">View your earnings dashboard →</a></p>
</div>
@@ -312,7 +315,7 @@ class RentalFlowEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"rentalApprovalConfirmationToOwner",
variables
variables,
);
const subject = `Rental Approved - ${rental.item?.name || "Your Item"}`;
@@ -320,10 +323,12 @@ class RentalFlowEmailService {
return await this.emailClient.sendEmail(
owner.email,
subject,
htmlContent
htmlContent,
);
} catch (error) {
console.error("Failed to send rental approval confirmation email:", error);
logger.error("Failed to send rental approval confirmation email", {
error,
});
return { success: false, error: error.message };
}
}
@@ -350,7 +355,7 @@ class RentalFlowEmailService {
}
try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
const browseItemsUrl = `${frontendUrl}/`;
// Determine payment message based on rental amount
@@ -397,16 +402,16 @@ class RentalFlowEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"rentalDeclinedToRenter",
variables
variables,
);
return await this.emailClient.sendEmail(
renter.email,
`Rental Request Declined - ${rental.item?.name || "Item"}`,
htmlContent
htmlContent,
);
} catch (error) {
console.error("Failed to send rental declined email:", error);
logger.error("Failed to send rental declined email", { error });
return { success: false, error: error.message };
}
}
@@ -437,7 +442,7 @@ class RentalFlowEmailService {
notification,
rental,
recipientName = null,
isRenter = false
isRenter = false,
) {
if (!this.initialized) {
await this.initialize();
@@ -532,7 +537,7 @@ class RentalFlowEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"rentalConfirmationToUser",
variables
variables,
);
// Use clear, transactional subject line with item name
@@ -540,7 +545,7 @@ class RentalFlowEmailService {
return await this.emailClient.sendEmail(userEmail, subject, htmlContent);
} catch (error) {
console.error("Failed to send rental confirmation:", error);
logger.error("Failed to send rental confirmation", { error });
return { success: false, error: error.message };
}
}
@@ -601,24 +606,24 @@ class RentalFlowEmailService {
ownerNotification,
rental,
owner.firstName,
false // isRenter = false for owner
false, // isRenter = false for owner
);
if (ownerResult.success) {
console.log(
`Rental confirmation email sent to owner: ${owner.email}`
);
logger.info("Rental confirmation email sent to owner", {
email: owner.email,
});
results.ownerEmailSent = true;
} else {
console.error(
`Failed to send rental confirmation email to owner (${owner.email}):`,
ownerResult.error
);
logger.error("Failed to send rental confirmation email to owner", {
email: owner.email,
error: ownerResult.error,
});
}
} catch (error) {
console.error(
`Failed to send rental confirmation email to owner (${owner.email}):`,
error.message
);
logger.error("Failed to send rental confirmation email to owner", {
email: owner.email,
error,
});
}
}
@@ -630,31 +635,30 @@ class RentalFlowEmailService {
renterNotification,
rental,
renter.firstName,
true // isRenter = true for renter (enables payment receipt)
true, // isRenter = true for renter (enables payment receipt)
);
if (renterResult.success) {
console.log(
`Rental confirmation email sent to renter: ${renter.email}`
);
logger.info("Rental confirmation email sent to renter", {
email: renter.email,
});
results.renterEmailSent = true;
} else {
console.error(
`Failed to send rental confirmation email to renter (${renter.email}):`,
renterResult.error
);
logger.error("Failed to send rental confirmation email to renter", {
email: renter.email,
error: renterResult.error,
});
}
} catch (error) {
console.error(
`Failed to send rental confirmation email to renter (${renter.email}):`,
error.message
);
logger.error("Failed to send rental confirmation email to renter", {
email: renter.email,
error,
});
}
}
} catch (error) {
console.error(
"Error fetching user data for rental confirmation emails:",
error
);
logger.error("Error fetching user data for rental confirmation emails", {
error,
});
}
return results;
@@ -693,7 +697,7 @@ class RentalFlowEmailService {
};
try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
const browseUrl = `${frontendUrl}/`;
const cancelledBy = rental.cancelledBy;
@@ -737,7 +741,7 @@ class RentalFlowEmailService {
<div class="info-box">
<p><strong>Full Refund Processed</strong></p>
<p>You will receive a full refund of $${refundInfo.amount.toFixed(
2
2,
)}. The refund will appear in your account within 5-10 business days.</p>
</div>
<div style="text-align: center">
@@ -780,7 +784,7 @@ class RentalFlowEmailService {
<div class="refund-amount">$${refundInfo.amount.toFixed(2)}</div>
<div class="info-box">
<p><strong>Refund Amount:</strong> $${refundInfo.amount.toFixed(
2
2,
)} (${refundPercentage}% of total)</p>
<p><strong>Reason:</strong> ${refundInfo.reason}</p>
<p><strong>Processing Time:</strong> Refunds typically appear within 5-10 business days.</p>
@@ -810,26 +814,27 @@ class RentalFlowEmailService {
const confirmationHtml = await this.templateManager.renderTemplate(
"rentalCancellationConfirmationToUser",
confirmationVariables
confirmationVariables,
);
const confirmationResult = await this.emailClient.sendEmail(
confirmationRecipient,
`Cancellation Confirmed - ${itemName}`,
confirmationHtml
confirmationHtml,
);
if (confirmationResult.success) {
console.log(
`Cancellation confirmation email sent to ${cancelledBy}: ${confirmationRecipient}`
);
logger.info("Cancellation confirmation email sent", {
cancelledBy,
email: confirmationRecipient,
});
results.confirmationEmailSent = true;
}
} catch (error) {
console.error(
`Failed to send cancellation confirmation email to ${cancelledBy}:`,
error.message
);
logger.error("Failed to send cancellation confirmation email", {
cancelledBy,
error,
});
}
// Send notification email to other party
@@ -846,31 +851,29 @@ class RentalFlowEmailService {
const notificationHtml = await this.templateManager.renderTemplate(
"rentalCancellationNotificationToUser",
notificationVariables
notificationVariables,
);
const notificationResult = await this.emailClient.sendEmail(
notificationRecipient,
`Rental Cancelled - ${itemName}`,
notificationHtml
notificationHtml,
);
if (notificationResult.success) {
console.log(
`Cancellation notification email sent to ${
cancelledBy === "owner" ? "renter" : "owner"
}: ${notificationRecipient}`
);
logger.info("Cancellation notification email sent", {
recipientType: cancelledBy === "owner" ? "renter" : "owner",
email: notificationRecipient,
});
results.notificationEmailSent = true;
}
} catch (error) {
console.error(
`Failed to send cancellation notification email:`,
error.message
);
logger.error("Failed to send cancellation notification email", {
error,
});
}
} catch (error) {
console.error("Error sending cancellation emails:", error);
logger.error("Error sending cancellation emails", { error });
}
return results;
@@ -905,7 +908,7 @@ class RentalFlowEmailService {
await this.initialize();
}
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
const results = {
renterEmailSent: false,
ownerEmailSent: false,
@@ -942,7 +945,7 @@ class RentalFlowEmailService {
<h2>Share Your Experience</h2>
<div class="info-box">
<p><strong>Help the community by leaving a review!</strong></p>
<p>Your feedback helps other renters make informed decisions and supports quality listings on RentAll.</p>
<p>Your feedback helps other renters make informed decisions and supports quality listings on Village Share.</p>
<ul>
<li>How was the item's condition?</li>
<li>Was the owner responsive and helpful?</li>
@@ -957,7 +960,7 @@ class RentalFlowEmailService {
reviewSection = `
<div class="success-box">
<p><strong>✓ Thank You for Your Review!</strong></p>
<p>Your feedback has been submitted and helps strengthen the RentAll community.</p>
<p>Your feedback has been submitted and helps strengthen the Village Share community.</p>
</div>
`;
}
@@ -977,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
@@ -1019,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>
`;
}
@@ -1043,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>
@@ -1054,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>
@@ -1068,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>
`;
@@ -1094,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;
@@ -1149,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
@@ -1181,7 +1193,7 @@ class RentalFlowEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"payoutReceivedToOwner",
variables
variables,
);
return await this.emailClient.sendEmail(
@@ -1189,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");
}
/**
@@ -64,11 +65,11 @@ class RentalReminderEmailService {
return await this.emailClient.sendEmail(
userEmail,
`RentAll: ${notification.title}`,
`Village Share: ${notification.title}`,
htmlContent
);
} catch (error) {
console.error("Failed to send condition check reminder:", error);
logger.error("Failed to send condition check reminder:", error);
return { success: false, error: error.message };
}
}

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

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

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