Compare commits

...

5 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
70 changed files with 17599 additions and 6771 deletions

113
README.md
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,13 +10,9 @@ module.exports = {
}, },
down: async (queryInterface, Sequelize) => { down: async (queryInterface, Sequelize) => {
// Note: PostgreSQL does not support removing values from ENUMs directly.
// The 'requires_action' value will remain in the enum but can be unused.
// To fully remove it would require recreating the enum and column,
// which is complex and risky for production data.
console.log( console.log(
"Note: PostgreSQL does not support removing ENUM values. " + "PostgreSQL does not support removing ENUM values. " +
"'requires_action' will remain in the enum but will not be used." "'requires_action' will remain in the enum but will not be used.",
); );
}, },
}; };

View File

@@ -9,10 +9,8 @@ module.exports = {
}, },
down: async (queryInterface, Sequelize) => { down: async (queryInterface, Sequelize) => {
// Note: PostgreSQL doesn't support removing enum values directly
// This would require recreating the enum type
console.log( console.log(
"Cannot remove enum value - manual intervention required if rollback needed" "Cannot remove enum value - manual intervention required if rollback needed",
); );
}, },
}; };

View File

@@ -265,7 +265,7 @@ const User = sequelize.define(
} }
}, },
}, },
} },
); );
User.prototype.comparePassword = async function (password) { User.prototype.comparePassword = async function (password) {
@@ -457,7 +457,7 @@ User.prototype.unbanUser = async function () {
bannedAt: null, bannedAt: null,
bannedBy: null, bannedBy: null,
banReason: null, banReason: null,
// Note: We don't increment jwtVersion on unban - user will need to log in fresh // We don't increment jwtVersion on unban - user will need to log in fresh
}); });
}; };
@@ -467,7 +467,7 @@ const TwoFactorService = require("../services/TwoFactorService");
// Store pending TOTP secret during setup // Store pending TOTP secret during setup
User.prototype.storePendingTotpSecret = async function ( User.prototype.storePendingTotpSecret = async function (
encryptedSecret, encryptedSecret,
encryptedSecretIv encryptedSecretIv,
) { ) {
return this.update({ return this.update({
twoFactorSetupPendingSecret: encryptedSecret, twoFactorSetupPendingSecret: encryptedSecret,
@@ -478,7 +478,7 @@ User.prototype.storePendingTotpSecret = async function (
// Enable TOTP 2FA after verification // Enable TOTP 2FA after verification
User.prototype.enableTotp = async function (recoveryCodes) { User.prototype.enableTotp = async function (recoveryCodes) {
const hashedCodes = await Promise.all( const hashedCodes = await Promise.all(
recoveryCodes.map((code) => bcrypt.hash(code, 12)) recoveryCodes.map((code) => bcrypt.hash(code, 12)),
); );
// Store in structured format // Store in structured format
@@ -506,7 +506,7 @@ User.prototype.enableTotp = async function (recoveryCodes) {
// Enable Email 2FA // Enable Email 2FA
User.prototype.enableEmailTwoFactor = async function (recoveryCodes) { User.prototype.enableEmailTwoFactor = async function (recoveryCodes) {
const hashedCodes = await Promise.all( const hashedCodes = await Promise.all(
recoveryCodes.map((code) => bcrypt.hash(code, 12)) recoveryCodes.map((code) => bcrypt.hash(code, 12)),
); );
// Store in structured format // Store in structured format
@@ -563,7 +563,7 @@ User.prototype.verifyEmailOtp = function (inputCode) {
return TwoFactorService.verifyEmailOtp( return TwoFactorService.verifyEmailOtp(
inputCode, inputCode,
this.emailOtpCode, this.emailOtpCode,
this.emailOtpExpiry this.emailOtpExpiry,
); );
}; };
@@ -603,7 +603,9 @@ User.prototype.markTotpCodeUsed = async function (code) {
const codeHash = crypto.createHash("sha256").update(code).digest("hex"); const codeHash = crypto.createHash("sha256").update(code).digest("hex");
recentCodes.unshift(codeHash); recentCodes.unshift(codeHash);
// Keep only last 5 codes (covers about 2.5 minutes of 30-second windows) // Keep only last 5 codes (covers about 2.5 minutes of 30-second windows)
await this.update({ recentTotpCodes: JSON.stringify(recentCodes.slice(0, 5)) }); await this.update({
recentTotpCodes: JSON.stringify(recentCodes.slice(0, 5)),
});
}; };
// Verify TOTP code with replay protection // Verify TOTP code with replay protection
@@ -615,18 +617,25 @@ User.prototype.verifyTotpCode = function (code) {
if (this.hasUsedTotpCode(code)) { if (this.hasUsedTotpCode(code)) {
return false; return false;
} }
return TwoFactorService.verifyTotpCode(this.totpSecret, this.totpSecretIv, code); return TwoFactorService.verifyTotpCode(
this.totpSecret,
this.totpSecretIv,
code,
);
}; };
// Verify pending TOTP code (during setup) // Verify pending TOTP code (during setup)
User.prototype.verifyPendingTotpCode = function (code) { User.prototype.verifyPendingTotpCode = function (code) {
if (!this.twoFactorSetupPendingSecret || !this.twoFactorSetupPendingSecretIv) { if (
!this.twoFactorSetupPendingSecret ||
!this.twoFactorSetupPendingSecretIv
) {
return false; return false;
} }
return TwoFactorService.verifyTotpCode( return TwoFactorService.verifyTotpCode(
this.twoFactorSetupPendingSecret, this.twoFactorSetupPendingSecret,
this.twoFactorSetupPendingSecretIv, this.twoFactorSetupPendingSecretIv,
code code,
); );
}; };
@@ -639,7 +648,7 @@ User.prototype.useRecoveryCode = async function (inputCode) {
const recoveryData = JSON.parse(this.recoveryCodesHash); const recoveryData = JSON.parse(this.recoveryCodesHash);
const { valid, index } = await TwoFactorService.verifyRecoveryCode( const { valid, index } = await TwoFactorService.verifyRecoveryCode(
inputCode, inputCode,
recoveryData recoveryData,
); );
if (valid) { if (valid) {
@@ -661,7 +670,8 @@ User.prototype.useRecoveryCode = async function (inputCode) {
return { return {
valid, valid,
remainingCodes: TwoFactorService.getRemainingRecoveryCodesCount(recoveryData), remainingCodes:
TwoFactorService.getRemainingRecoveryCodesCount(recoveryData),
}; };
}; };

View File

@@ -28,8 +28,7 @@ const router = express.Router();
const googleClient = new OAuth2Client( const googleClient = new OAuth2Client(
process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET, process.env.GOOGLE_CLIENT_SECRET,
process.env.GOOGLE_REDIRECT_URI || process.env.GOOGLE_REDIRECT_URI,
"http://localhost:3000/auth/google/callback"
); );
// Get CSRF token endpoint // Get CSRF token endpoint
@@ -120,7 +119,7 @@ router.post(
try { try {
await emailServices.auth.sendVerificationEmail( await emailServices.auth.sendVerificationEmail(
user, user,
user.verificationToken user.verificationToken,
); );
verificationEmailSent = true; verificationEmailSent = true;
} catch (emailError) { } catch (emailError) {
@@ -137,13 +136,13 @@ router.post(
const token = jwt.sign( const token = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion }, { id: user.id, jwtVersion: user.jwtVersion },
process.env.JWT_ACCESS_SECRET, process.env.JWT_ACCESS_SECRET,
{ expiresIn: "15m" } // Short-lived access token { expiresIn: "15m" }, // Short-lived access token
); );
const refreshToken = jwt.sign( const refreshToken = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" }, { id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
process.env.JWT_REFRESH_SECRET, process.env.JWT_REFRESH_SECRET,
{ expiresIn: "7d" } { expiresIn: "7d" },
); );
// Set tokens as httpOnly cookies // Set tokens as httpOnly cookies
@@ -188,7 +187,7 @@ router.post(
}); });
res.status(500).json({ error: "Registration failed. Please try again." }); res.status(500).json({ error: "Registration failed. Please try again." });
} }
} },
); );
router.post( router.post(
@@ -220,7 +219,8 @@ router.post(
// Check if user is banned // Check if user is banned
if (user.isBanned) { if (user.isBanned) {
return res.status(403).json({ return res.status(403).json({
error: "Your account has been suspended. Please contact support for more information.", error:
"Your account has been suspended. Please contact support for more information.",
code: "USER_BANNED", code: "USER_BANNED",
}); });
} }
@@ -242,13 +242,13 @@ router.post(
const token = jwt.sign( const token = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion }, { id: user.id, jwtVersion: user.jwtVersion },
process.env.JWT_ACCESS_SECRET, process.env.JWT_ACCESS_SECRET,
{ expiresIn: "15m" } // Short-lived access token { expiresIn: "15m" }, // Short-lived access token
); );
const refreshToken = jwt.sign( const refreshToken = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" }, { id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
process.env.JWT_REFRESH_SECRET, process.env.JWT_REFRESH_SECRET,
{ expiresIn: "7d" } { expiresIn: "7d" },
); );
// Set tokens as httpOnly cookies // Set tokens as httpOnly cookies
@@ -292,7 +292,7 @@ router.post(
}); });
res.status(500).json({ error: "Login failed. Please try again." }); res.status(500).json({ error: "Login failed. Please try again." });
} }
} },
); );
router.post( router.post(
@@ -314,9 +314,7 @@ router.post(
// Exchange authorization code for tokens // Exchange authorization code for tokens
const { tokens } = await googleClient.getToken({ const { tokens } = await googleClient.getToken({
code, code,
redirect_uri: redirect_uri: process.env.GOOGLE_REDIRECT_URI,
process.env.GOOGLE_REDIRECT_URI ||
"http://localhost:3000/auth/google/callback",
}); });
// Verify the ID token from the token response // Verify the ID token from the token response
@@ -413,7 +411,8 @@ router.post(
// Check if user is banned // Check if user is banned
if (user.isBanned) { if (user.isBanned) {
return res.status(403).json({ return res.status(403).json({
error: "Your account has been suspended. Please contact support for more information.", error:
"Your account has been suspended. Please contact support for more information.",
code: "USER_BANNED", code: "USER_BANNED",
}); });
} }
@@ -422,13 +421,13 @@ router.post(
const token = jwt.sign( const token = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion }, { id: user.id, jwtVersion: user.jwtVersion },
process.env.JWT_ACCESS_SECRET, process.env.JWT_ACCESS_SECRET,
{ expiresIn: "15m" } { expiresIn: "15m" },
); );
const refreshToken = jwt.sign( const refreshToken = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" }, { id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
process.env.JWT_REFRESH_SECRET, process.env.JWT_REFRESH_SECRET,
{ expiresIn: "7d" } { expiresIn: "7d" },
); );
// Set tokens as httpOnly cookies // Set tokens as httpOnly cookies
@@ -488,7 +487,7 @@ router.post(
.status(500) .status(500)
.json({ error: "Google authentication failed. Please try again." }); .json({ error: "Google authentication failed. Please try again." });
} }
} },
); );
// Email verification endpoint // Email verification endpoint
@@ -605,7 +604,7 @@ router.post(
error: "Email verification failed. Please try again.", error: "Email verification failed. Please try again.",
}); });
} }
} },
); );
// Resend verification email endpoint // Resend verification email endpoint
@@ -650,7 +649,7 @@ router.post(
try { try {
await emailServices.auth.sendVerificationEmail( await emailServices.auth.sendVerificationEmail(
user, user,
user.verificationToken user.verificationToken,
); );
} catch (emailError) { } catch (emailError) {
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
@@ -691,7 +690,7 @@ router.post(
error: "Failed to resend verification email. Please try again.", error: "Failed to resend verification email. Please try again.",
}); });
} }
} },
); );
// Refresh token endpoint // Refresh token endpoint
@@ -727,7 +726,8 @@ router.post("/refresh", async (req, res) => {
// Check if user is banned (defense-in-depth, jwtVersion should already catch this) // Check if user is banned (defense-in-depth, jwtVersion should already catch this)
if (user.isBanned) { if (user.isBanned) {
return res.status(403).json({ return res.status(403).json({
error: "Your account has been suspended. Please contact support for more information.", error:
"Your account has been suspended. Please contact support for more information.",
code: "USER_BANNED", code: "USER_BANNED",
}); });
} }
@@ -736,7 +736,7 @@ router.post("/refresh", async (req, res) => {
const newAccessToken = jwt.sign( const newAccessToken = jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion }, { id: user.id, jwtVersion: user.jwtVersion },
process.env.JWT_ACCESS_SECRET, process.env.JWT_ACCESS_SECRET,
{ expiresIn: "15m" } { expiresIn: "15m" },
); );
// Set new access token cookie // Set new access token cookie
@@ -851,7 +851,7 @@ router.post(
"Password reset requested for non-existent or OAuth user", "Password reset requested for non-existent or OAuth user",
{ {
email: email, email: email,
} },
); );
} }
@@ -871,7 +871,7 @@ router.post(
error: "Failed to process password reset request. Please try again.", error: "Failed to process password reset request. Please try again.",
}); });
} }
} },
); );
// Verify reset token endpoint (optional - for frontend UX) // Verify reset token endpoint (optional - for frontend UX)
@@ -925,7 +925,7 @@ router.post(
error: "Failed to verify reset token. Please try again.", error: "Failed to verify reset token. Please try again.",
}); });
} }
} },
); );
// Reset password endpoint // Reset password endpoint
@@ -1008,7 +1008,7 @@ router.post(
error: "Failed to reset password. Please try again.", error: "Failed to reset password. Please try again.",
}); });
} }
} },
); );
module.exports = router; module.exports = router;

View File

@@ -269,11 +269,11 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
totalAmount = RentalDurationCalculator.calculateRentalCost( totalAmount = RentalDurationCalculator.calculateRentalCost(
rentalStartDateTime, rentalStartDateTime,
rentalEndDateTime, rentalEndDateTime,
item item,
); );
// Check for overlapping rentals using datetime ranges // Check for overlapping rentals using datetime ranges
// Note: "active" rentals are stored as "confirmed" with startDateTime in the past // "active" rentals are stored as "confirmed" with startDateTime in the past
// Two ranges [A,B] and [C,D] overlap if and only if A < D AND C < B // Two ranges [A,B] and [C,D] overlap if and only if A < D AND C < B
// Here: existing rental [existingStart, existingEnd], new rental [rentalStartDateTime, rentalEndDateTime] // Here: existing rental [existingStart, existingEnd], new rental [rentalStartDateTime, rentalEndDateTime]
// Overlap: existingStart < rentalEndDateTime AND rentalStartDateTime < existingEnd // Overlap: existingStart < rentalEndDateTime AND rentalStartDateTime < existingEnd
@@ -352,7 +352,7 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
await emailServices.rentalFlow.sendRentalRequestEmail( await emailServices.rentalFlow.sendRentalRequestEmail(
rentalWithDetails.owner, rentalWithDetails.owner,
rentalWithDetails.renter, rentalWithDetails.renter,
rentalWithDetails rentalWithDetails,
); );
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental request notification sent to owner", { reqLogger.info("Rental request notification sent to owner", {
@@ -374,7 +374,7 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
try { try {
await emailServices.rentalFlow.sendRentalRequestConfirmationEmail( await emailServices.rentalFlow.sendRentalRequestConfirmationEmail(
rentalWithDetails.renter, rentalWithDetails.renter,
rentalWithDetails rentalWithDetails,
); );
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental request confirmation sent to renter", { reqLogger.info("Rental request confirmation sent to renter", {
@@ -474,7 +474,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
itemName: rental.item.name, itemName: rental.item.name,
renterId: rental.renterId, renterId: rental.renterId,
ownerId: rental.ownerId, ownerId: rental.ownerId,
} },
); );
// Check if 3DS authentication is required // Check if 3DS authentication is required
@@ -494,7 +494,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
itemName: rental.item.name, itemName: rental.item.name,
ownerName: rental.owner.firstName, ownerName: rental.owner.firstName,
amount: rental.totalAmount, amount: rental.totalAmount,
} },
); );
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Authentication required email sent to renter", { reqLogger.info("Authentication required email sent to renter", {
@@ -503,15 +503,12 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
}); });
} catch (emailError) { } catch (emailError) {
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.error( reqLogger.error("Failed to send authentication required email", {
"Failed to send authentication required email",
{
error: emailError.message, error: emailError.message,
stack: emailError.stack, stack: emailError.stack,
rentalId: rental.id, rentalId: rental.id,
renterId: rental.renterId, renterId: rental.renterId,
} });
);
} }
return res.status(402).json({ return res.status(402).json({
@@ -557,17 +554,14 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
// Create condition check reminder schedules // Create condition check reminder schedules
try { try {
await EventBridgeSchedulerService.createConditionCheckSchedules( await EventBridgeSchedulerService.createConditionCheckSchedules(
updatedRental updatedRental,
); );
} catch (schedulerError) { } catch (schedulerError) {
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.error( reqLogger.error("Failed to create condition check schedules", {
"Failed to create condition check schedules",
{
error: schedulerError.message, error: schedulerError.message,
rentalId: updatedRental.id, rentalId: updatedRental.id,
} });
);
// Don't fail the confirmation - schedules are non-critical // Don't fail the confirmation - schedules are non-critical
} }
@@ -577,7 +571,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail( await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(
updatedRental.owner, updatedRental.owner,
updatedRental.renter, updatedRental.renter,
updatedRental updatedRental,
); );
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental approval confirmation sent to owner", { reqLogger.info("Rental approval confirmation sent to owner", {
@@ -593,7 +587,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
stack: emailError.stack, stack: emailError.stack,
rentalId: updatedRental.id, rentalId: updatedRental.id,
ownerId: updatedRental.ownerId, ownerId: updatedRental.ownerId,
} },
); );
} }
@@ -616,7 +610,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
renterNotification, renterNotification,
updatedRental, updatedRental,
renter.firstName, renter.firstName,
true // isRenter = true to show payment receipt true, // isRenter = true to show payment receipt
); );
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental confirmation sent to renter", { reqLogger.info("Rental confirmation sent to renter", {
@@ -633,7 +627,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
stack: emailError.stack, stack: emailError.stack,
rentalId: updatedRental.id, rentalId: updatedRental.id,
renterId: updatedRental.renterId, renterId: updatedRental.renterId,
} },
); );
} }
@@ -670,7 +664,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
itemName: rental.item.name, itemName: rental.item.name,
declineReason: renterMessage, declineReason: renterMessage,
rentalId: rental.id, rentalId: rental.id,
} },
); );
reqLogger.info("Payment declined email auto-sent to renter", { reqLogger.info("Payment declined email auto-sent to renter", {
rentalId: rental.id, rentalId: rental.id,
@@ -728,17 +722,14 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
// Create condition check reminder schedules // Create condition check reminder schedules
try { try {
await EventBridgeSchedulerService.createConditionCheckSchedules( await EventBridgeSchedulerService.createConditionCheckSchedules(
updatedRental updatedRental,
); );
} catch (schedulerError) { } catch (schedulerError) {
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.error( reqLogger.error("Failed to create condition check schedules", {
"Failed to create condition check schedules",
{
error: schedulerError.message, error: schedulerError.message,
rentalId: updatedRental.id, rentalId: updatedRental.id,
} });
);
// Don't fail the confirmation - schedules are non-critical // Don't fail the confirmation - schedules are non-critical
} }
@@ -748,7 +739,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail( await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(
updatedRental.owner, updatedRental.owner,
updatedRental.renter, updatedRental.renter,
updatedRental updatedRental,
); );
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental approval confirmation sent to owner", { reqLogger.info("Rental approval confirmation sent to owner", {
@@ -764,7 +755,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
stack: emailError.stack, stack: emailError.stack,
rentalId: updatedRental.id, rentalId: updatedRental.id,
ownerId: updatedRental.ownerId, ownerId: updatedRental.ownerId,
} },
); );
} }
@@ -787,7 +778,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
renterNotification, renterNotification,
updatedRental, updatedRental,
renter.firstName, renter.firstName,
true // isRenter = true (for free rentals, shows "no payment required") true, // isRenter = true (for free rentals, shows "no payment required")
); );
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental confirmation sent to renter", { reqLogger.info("Rental confirmation sent to renter", {
@@ -804,7 +795,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
stack: emailError.stack, stack: emailError.stack,
rentalId: updatedRental.id, rentalId: updatedRental.id,
renterId: updatedRental.renterId, renterId: updatedRental.renterId,
} },
); );
} }
@@ -910,7 +901,7 @@ router.put("/:id/decline", authenticateToken, async (req, res) => {
await emailServices.rentalFlow.sendRentalDeclinedEmail( await emailServices.rentalFlow.sendRentalDeclinedEmail(
updatedRental.renter, updatedRental.renter,
updatedRental, updatedRental,
reason reason,
); );
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental decline notification sent to renter", { reqLogger.info("Rental decline notification sent to renter", {
@@ -1130,7 +1121,7 @@ router.post("/cost-preview", authenticateToken, async (req, res) => {
const totalAmount = RentalDurationCalculator.calculateRentalCost( const totalAmount = RentalDurationCalculator.calculateRentalCost(
rentalStartDateTime, rentalStartDateTime,
rentalEndDateTime, rentalEndDateTime,
item item,
); );
// Calculate fees // Calculate fees
@@ -1202,7 +1193,7 @@ router.get("/:id/refund-preview", authenticateToken, async (req, res, next) => {
try { try {
const preview = await RefundService.getRefundPreview( const preview = await RefundService.getRefundPreview(
req.params.id, req.params.id,
req.user.id req.user.id,
); );
res.json(preview); res.json(preview);
} catch (error) { } catch (error) {
@@ -1246,7 +1237,7 @@ router.get(
const lateCalculation = LateReturnService.calculateLateFee( const lateCalculation = LateReturnService.calculateLateFee(
rental, rental,
actualReturnDateTime actualReturnDateTime,
); );
res.json(lateCalculation); res.json(lateCalculation);
@@ -1260,7 +1251,7 @@ router.get(
}); });
next(error); next(error);
} }
} },
); );
// Cancel rental with refund processing // Cancel rental with refund processing
@@ -1276,7 +1267,7 @@ router.post("/:id/cancel", authenticateToken, async (req, res, next) => {
const result = await RefundService.processCancellation( const result = await RefundService.processCancellation(
req.params.id, req.params.id,
req.user.id, req.user.id,
reason.trim() reason.trim(),
); );
// Return the updated rental with refund information // Return the updated rental with refund information
@@ -1302,7 +1293,7 @@ router.post("/:id/cancel", authenticateToken, async (req, res, next) => {
updatedRental.owner, updatedRental.owner,
updatedRental.renter, updatedRental.renter,
updatedRental, updatedRental,
result.refund result.refund,
); );
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Cancellation emails sent", { reqLogger.info("Cancellation emails sent", {
@@ -1403,7 +1394,7 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
await emailServices.rentalFlow.sendRentalCompletionEmails( await emailServices.rentalFlow.sendRentalCompletionEmails(
rentalWithDetails.owner, rentalWithDetails.owner,
rentalWithDetails.renter, rentalWithDetails.renter,
rentalWithDetails rentalWithDetails,
); );
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental completion emails sent", { reqLogger.info("Rental completion emails sent", {
@@ -1441,7 +1432,7 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
if (statusOptions?.returned_late && actualReturnDateTime) { if (statusOptions?.returned_late && actualReturnDateTime) {
const lateReturnDamaged = await LateReturnService.processLateReturn( const lateReturnDamaged = await LateReturnService.processLateReturn(
rentalId, rentalId,
actualReturnDateTime actualReturnDateTime,
); );
damageUpdates.status = "returned_late_and_damaged"; damageUpdates.status = "returned_late_and_damaged";
damageUpdates.lateFees = lateReturnDamaged.lateCalculation.lateFee; damageUpdates.lateFees = lateReturnDamaged.lateCalculation.lateFee;
@@ -1463,7 +1454,7 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
const lateReturn = await LateReturnService.processLateReturn( const lateReturn = await LateReturnService.processLateReturn(
rentalId, rentalId,
actualReturnDateTime actualReturnDateTime,
); );
updatedRental = lateReturn.rental; updatedRental = lateReturn.rental;
@@ -1484,7 +1475,7 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
await emailServices.customerService.sendLostItemToCustomerService( await emailServices.customerService.sendLostItemToCustomerService(
updatedRental, updatedRental,
owner, owner,
renter renter,
); );
break; break;
@@ -1562,7 +1553,7 @@ router.post("/:id/report-damage", authenticateToken, async (req, res, next) => {
"damage-reports", "damage-reports",
{ {
maxKeys: IMAGE_LIMITS.damageReports, maxKeys: IMAGE_LIMITS.damageReports,
} },
); );
if (!keyValidation.valid) { if (!keyValidation.valid) {
return res.status(400).json({ return res.status(400).json({
@@ -1576,7 +1567,7 @@ router.post("/:id/report-damage", authenticateToken, async (req, res, next) => {
const result = await DamageAssessmentService.processDamageAssessment( const result = await DamageAssessmentService.processDamageAssessment(
rentalId, rentalId,
damageInfo, damageInfo,
userId userId,
); );
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
@@ -1654,7 +1645,7 @@ router.put("/:id/payment-method", authenticateToken, async (req, res, next) => {
let paymentMethod; let paymentMethod;
try { try {
paymentMethod = await StripeService.getPaymentMethod( paymentMethod = await StripeService.getPaymentMethod(
stripePaymentMethodId stripePaymentMethodId,
); );
} catch { } catch {
return res.status(400).json({ error: "Invalid payment method" }); return res.status(400).json({ error: "Invalid payment method" });
@@ -1699,7 +1690,7 @@ router.put("/:id/payment-method", authenticateToken, async (req, res, next) => {
status: "pending", status: "pending",
paymentStatus: "pending", paymentStatus: "pending",
}, },
} },
); );
if (updateCount === 0) { if (updateCount === 0) {
@@ -1725,7 +1716,7 @@ router.put("/:id/payment-method", authenticateToken, async (req, res, next) => {
itemName: rental.item.name, itemName: rental.item.name,
rentalId: rental.id, rentalId: rental.id,
approvalUrl: `${process.env.FRONTEND_URL}/rentals/${rentalId}`, approvalUrl: `${process.env.FRONTEND_URL}/rentals/${rentalId}`,
} },
); );
} catch (emailError) { } catch (emailError) {
// Don't fail the request if email fails // Don't fail the request if email fails
@@ -1781,7 +1772,7 @@ router.get(
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY); const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
const paymentIntent = await stripe.paymentIntents.retrieve( const paymentIntent = await stripe.paymentIntents.retrieve(
rental.stripePaymentIntentId rental.stripePaymentIntentId,
); );
return res.json({ return res.json({
@@ -1798,7 +1789,7 @@ router.get(
}); });
next(error); next(error);
} }
} },
); );
/** /**
@@ -1812,8 +1803,29 @@ router.post(
try { try {
const rental = await Rental.findByPk(req.params.id, { const rental = await Rental.findByPk(req.params.id, {
include: [ include: [
{ model: User, as: "renter", attributes: ["id", "firstName", "lastName", "email", "stripeCustomerId"] }, {
{ model: User, as: "owner", attributes: ["id", "firstName", "lastName", "email", "stripeConnectedAccountId", "stripePayoutsEnabled"] }, model: User,
as: "renter",
attributes: [
"id",
"firstName",
"lastName",
"email",
"stripeCustomerId",
],
},
{
model: User,
as: "owner",
attributes: [
"id",
"firstName",
"lastName",
"email",
"stripeConnectedAccountId",
"stripePayoutsEnabled",
],
},
{ model: Item, as: "item" }, { model: Item, as: "item" },
], ],
}); });
@@ -1837,7 +1849,7 @@ router.post(
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY); const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
const paymentIntent = await stripe.paymentIntents.retrieve( const paymentIntent = await stripe.paymentIntents.retrieve(
rental.stripePaymentIntentId, rental.stripePaymentIntentId,
{ expand: ['latest_charge.payment_method_details'] } { expand: ["latest_charge.payment_method_details"] },
); );
if (paymentIntent.status !== "succeeded") { if (paymentIntent.status !== "succeeded") {
@@ -1864,7 +1876,8 @@ router.post(
paymentMethodLast4 = paymentMethodDetails.card?.last4 || null; paymentMethodLast4 = paymentMethodDetails.card?.last4 || null;
} else if (type === "us_bank_account") { } else if (type === "us_bank_account") {
paymentMethodBrand = "bank_account"; paymentMethodBrand = "bank_account";
paymentMethodLast4 = paymentMethodDetails.us_bank_account?.last4 || null; paymentMethodLast4 =
paymentMethodDetails.us_bank_account?.last4 || null;
} }
} }
@@ -1882,13 +1895,10 @@ router.post(
await EventBridgeSchedulerService.createConditionCheckSchedules(rental); await EventBridgeSchedulerService.createConditionCheckSchedules(rental);
} catch (schedulerError) { } catch (schedulerError) {
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.error( reqLogger.error("Failed to create condition check schedules", {
"Failed to create condition check schedules",
{
error: schedulerError.message, error: schedulerError.message,
rentalId: rental.id, rentalId: rental.id,
} });
);
// Don't fail the confirmation - schedules are non-critical // Don't fail the confirmation - schedules are non-critical
} }
@@ -1897,13 +1907,16 @@ router.post(
await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail( await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(
rental.owner, rental.owner,
rental.renter, rental.renter,
rental rental,
); );
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental approval confirmation sent to owner (after 3DS)", { reqLogger.info(
"Rental approval confirmation sent to owner (after 3DS)",
{
rentalId: rental.id, rentalId: rental.id,
ownerId: rental.ownerId, ownerId: rental.ownerId,
}); },
);
} catch (emailError) { } catch (emailError) {
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.error( reqLogger.error(
@@ -1911,7 +1924,7 @@ router.post(
{ {
error: emailError.message, error: emailError.message,
rentalId: rental.id, rentalId: rental.id,
} },
); );
} }
@@ -1929,7 +1942,7 @@ router.post(
renterNotification, renterNotification,
rental, rental,
rental.renter.firstName, rental.renter.firstName,
true true,
); );
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental confirmation sent to renter (after 3DS)", { reqLogger.info("Rental confirmation sent to renter (after 3DS)", {
@@ -1938,17 +1951,17 @@ router.post(
}); });
} catch (emailError) { } catch (emailError) {
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.error( reqLogger.error("Failed to send rental confirmation email after 3DS", {
"Failed to send rental confirmation email after 3DS",
{
error: emailError.message, error: emailError.message,
rentalId: rental.id, rentalId: rental.id,
} });
);
} }
// Trigger payout if owner has payouts enabled // Trigger payout if owner has payouts enabled
if (rental.owner.stripePayoutsEnabled && rental.owner.stripeConnectedAccountId) { if (
rental.owner.stripePayoutsEnabled &&
rental.owner.stripeConnectedAccountId
) {
try { try {
await PayoutService.processRentalPayout(rental); await PayoutService.processRentalPayout(rental);
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
@@ -1983,7 +1996,7 @@ router.post(
}); });
next(error); next(error);
} }
} },
); );
module.exports = router; module.exports = router;

View File

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

View File

@@ -1,5 +1,5 @@
// Load environment-specific config // Load environment-specific config
const env = process.env.NODE_ENV || "dev"; const env = process.env.NODE_ENV;
const envFile = `.env.${env}`; const envFile = `.env.${env}`;
require("dotenv").config({ require("dotenv").config({
@@ -46,7 +46,7 @@ const server = http.createServer(app);
// Initialize Socket.io with CORS // Initialize Socket.io with CORS
const io = new Server(server, { const io = new Server(server, {
cors: { cors: {
origin: process.env.FRONTEND_URL || "http://localhost:3000", origin: process.env.FRONTEND_URL,
credentials: true, credentials: true,
methods: ["GET", "POST"], methods: ["GET", "POST"],
}, },
@@ -93,7 +93,7 @@ app.use(
frameSrc: ["'self'", "https://accounts.google.com"], frameSrc: ["'self'", "https://accounts.google.com"],
}, },
}, },
}) }),
); );
// Cookie parser for CSRF // Cookie parser for CSRF
@@ -108,11 +108,11 @@ app.use("/api/", apiLogger);
// CORS with security settings (must come BEFORE rate limiter to ensure headers on all responses) // CORS with security settings (must come BEFORE rate limiter to ensure headers on all responses)
app.use( app.use(
cors({ cors({
origin: process.env.FRONTEND_URL || "http://localhost:3000", origin: process.env.FRONTEND_URL,
credentials: true, credentials: true,
optionsSuccessStatus: 200, optionsSuccessStatus: 200,
exposedHeaders: ["X-CSRF-Token"], exposedHeaders: ["X-CSRF-Token"],
}) }),
); );
// General rate limiting for all routes // General rate limiting for all routes
@@ -126,14 +126,14 @@ app.use(
// Store raw body for webhook verification // Store raw body for webhook verification
req.rawBody = buf; req.rawBody = buf;
}, },
}) }),
); );
app.use( app.use(
bodyParser.urlencoded({ bodyParser.urlencoded({
extended: true, extended: true,
limit: "1mb", limit: "1mb",
parameterLimit: 100, // Limit number of parameters parameterLimit: 100, // Limit number of parameters
}) }),
); );
// Apply input sanitization to all API routes (XSS prevention) // Apply input sanitization to all API routes (XSS prevention)
@@ -171,7 +171,7 @@ app.use("/api/upload", requireAlphaAccess, uploadRoutes);
app.use(errorLogger); app.use(errorLogger);
app.use(sanitizeError); app.use(sanitizeError);
const PORT = process.env.PORT || 5000; const PORT = process.env.PORT;
const { checkPendingMigrations } = require("./utils/checkMigrations"); const { checkPendingMigrations } = require("./utils/checkMigrations");
@@ -185,7 +185,7 @@ sequelize
if (pendingMigrations.length > 0) { if (pendingMigrations.length > 0) {
logger.error( logger.error(
`Found ${pendingMigrations.length} pending migration(s). Please run 'npm run db:migrate'`, `Found ${pendingMigrations.length} pending migration(s). Please run 'npm run db:migrate'`,
{ pendingMigrations } { pendingMigrations },
); );
process.exit(1); process.exit(1);
} }
@@ -203,12 +203,12 @@ sequelize
// Fail fast - don't start server if email templates can't load // Fail fast - don't start server if email templates can't load
if (env === "prod" || env === "production") { if (env === "prod" || env === "production") {
logger.error( logger.error(
"Cannot start server without email services in production" "Cannot start server without email services in production",
); );
process.exit(1); process.exit(1);
} else { } else {
logger.warn( logger.warn(
"Email services failed to initialize - continuing in dev mode" "Email services failed to initialize - continuing in dev mode",
); );
} }
} }

View File

@@ -5,14 +5,14 @@ const bcrypt = require("bcryptjs");
const logger = require("../utils/logger"); const logger = require("../utils/logger");
// Configuration // Configuration
const TOTP_ISSUER = process.env.TOTP_ISSUER || "VillageShare"; const TOTP_ISSUER = process.env.TOTP_ISSUER;
const EMAIL_OTP_EXPIRY_MINUTES = parseInt( const EMAIL_OTP_EXPIRY_MINUTES = parseInt(
process.env.TWO_FACTOR_EMAIL_OTP_EXPIRY_MINUTES || "10", process.env.TWO_FACTOR_EMAIL_OTP_EXPIRY_MINUTES,
10 10,
); );
const STEP_UP_VALIDITY_MINUTES = parseInt( const STEP_UP_VALIDITY_MINUTES = parseInt(
process.env.TWO_FACTOR_STEP_UP_VALIDITY_MINUTES || "5", process.env.TWO_FACTOR_STEP_UP_VALIDITY_MINUTES,
10 10,
); );
const MAX_EMAIL_OTP_ATTEMPTS = 3; const MAX_EMAIL_OTP_ATTEMPTS = 3;
const RECOVERY_CODE_COUNT = 10; const RECOVERY_CODE_COUNT = 10;
@@ -243,7 +243,7 @@ class TwoFactorService {
const encryptionKey = process.env.TOTP_ENCRYPTION_KEY; const encryptionKey = process.env.TOTP_ENCRYPTION_KEY;
if (!encryptionKey || encryptionKey.length !== 64) { if (!encryptionKey || encryptionKey.length !== 64) {
throw new Error( throw new Error(
"TOTP_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)" "TOTP_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)",
); );
} }
@@ -251,7 +251,7 @@ class TwoFactorService {
const cipher = crypto.createCipheriv( const cipher = crypto.createCipheriv(
"aes-256-gcm", "aes-256-gcm",
Buffer.from(encryptionKey, "hex"), Buffer.from(encryptionKey, "hex"),
iv iv,
); );
let encrypted = cipher.update(secret, "utf8", "hex"); let encrypted = cipher.update(secret, "utf8", "hex");
@@ -275,7 +275,7 @@ class TwoFactorService {
const encryptionKey = process.env.TOTP_ENCRYPTION_KEY; const encryptionKey = process.env.TOTP_ENCRYPTION_KEY;
if (!encryptionKey || encryptionKey.length !== 64) { if (!encryptionKey || encryptionKey.length !== 64) {
throw new Error( throw new Error(
"TOTP_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)" "TOTP_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)",
); );
} }
@@ -283,7 +283,7 @@ class TwoFactorService {
const decipher = crypto.createDecipheriv( const decipher = crypto.createDecipheriv(
"aes-256-gcm", "aes-256-gcm",
Buffer.from(encryptionKey, "hex"), Buffer.from(encryptionKey, "hex"),
Buffer.from(iv, "hex") Buffer.from(iv, "hex"),
); );
decipher.setAuthTag(Buffer.from(authTag, "hex")); decipher.setAuthTag(Buffer.from(authTag, "hex"));

View File

@@ -42,7 +42,7 @@ class AlphaInvitationEmailService {
} }
try { try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
const variables = { const variables = {
code: code, code: code,
@@ -54,13 +54,13 @@ class AlphaInvitationEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"alphaInvitationToUser", "alphaInvitationToUser",
variables variables,
); );
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
email, email,
"Your Alpha Access Code - Village Share", "Your Alpha Access Code - Village Share",
htmlContent htmlContent,
); );
} catch (error) { } catch (error) {
logger.error("Failed to send alpha invitation email", { error }); logger.error("Failed to send alpha invitation email", { error });

View File

@@ -44,7 +44,7 @@ class AuthEmailService {
await this.initialize(); await this.initialize();
} }
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
const verificationUrl = `${frontendUrl}/verify-email?token=${verificationToken}`; const verificationUrl = `${frontendUrl}/verify-email?token=${verificationToken}`;
const variables = { const variables = {
@@ -55,13 +55,13 @@ class AuthEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"emailVerificationToUser", "emailVerificationToUser",
variables variables,
); );
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
user.email, user.email,
"Verify Your Email - Village Share", "Verify Your Email - Village Share",
htmlContent htmlContent,
); );
} }
@@ -78,7 +78,7 @@ class AuthEmailService {
await this.initialize(); await this.initialize();
} }
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
const resetUrl = `${frontendUrl}/reset-password?token=${resetToken}`; const resetUrl = `${frontendUrl}/reset-password?token=${resetToken}`;
const variables = { const variables = {
@@ -88,13 +88,13 @@ class AuthEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"passwordResetToUser", "passwordResetToUser",
variables variables,
); );
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
user.email, user.email,
"Reset Your Password - Village Share", "Reset Your Password - Village Share",
htmlContent htmlContent,
); );
} }
@@ -123,13 +123,13 @@ class AuthEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"passwordChangedToUser", "passwordChangedToUser",
variables variables,
); );
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
user.email, user.email,
"Password Changed Successfully - Village Share", "Password Changed Successfully - Village Share",
htmlContent htmlContent,
); );
} }
@@ -158,13 +158,13 @@ class AuthEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"personalInfoChangedToUser", "personalInfoChangedToUser",
variables variables,
); );
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
user.email, user.email,
"Personal Information Updated - Village Share", "Personal Information Updated - Village Share",
htmlContent htmlContent,
); );
} }
@@ -188,13 +188,13 @@ class AuthEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"twoFactorOtpToUser", "twoFactorOtpToUser",
variables variables,
); );
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
user.email, user.email,
"Your Verification Code - Village Share", "Your Verification Code - Village Share",
htmlContent htmlContent,
); );
} }
@@ -222,13 +222,13 @@ class AuthEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"twoFactorEnabledToUser", "twoFactorEnabledToUser",
variables variables,
); );
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
user.email, user.email,
"Multi-Factor Authentication Enabled - Village Share", "Multi-Factor Authentication Enabled - Village Share",
htmlContent htmlContent,
); );
} }
@@ -256,13 +256,13 @@ class AuthEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"twoFactorDisabledToUser", "twoFactorDisabledToUser",
variables variables,
); );
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
user.email, user.email,
"Multi-Factor Authentication Disabled - Village Share", "Multi-Factor Authentication Disabled - Village Share",
htmlContent htmlContent,
); );
} }
@@ -302,13 +302,13 @@ class AuthEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"recoveryCodeUsedToUser", "recoveryCodeUsedToUser",
variables variables,
); );
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
user.email, user.email,
"Recovery Code Used - Village Share", "Recovery Code Used - Village Share",
htmlContent htmlContent,
); );
} }
} }

View File

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

View File

@@ -57,7 +57,7 @@ class ForumEmailService {
} }
try { try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
const postUrl = `${frontendUrl}/forum/posts/${post.id}`; const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
const timestamp = new Date(comment.createdAt).toLocaleString("en-US", { const timestamp = new Date(comment.createdAt).toLocaleString("en-US", {
@@ -77,7 +77,7 @@ class ForumEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"forumCommentToPostAuthor", "forumCommentToPostAuthor",
variables variables,
); );
const subject = `${commenter.firstName} ${commenter.lastName} commented on your post`; const subject = `${commenter.firstName} ${commenter.lastName} commented on your post`;
@@ -85,12 +85,12 @@ class ForumEmailService {
const result = await this.emailClient.sendEmail( const result = await this.emailClient.sendEmail(
postAuthor.email, postAuthor.email,
subject, subject,
htmlContent htmlContent,
); );
if (result.success) { if (result.success) {
logger.info( logger.info(
`Forum comment notification email sent to ${postAuthor.email}` `Forum comment notification email sent to ${postAuthor.email}`,
); );
} }
@@ -124,14 +124,14 @@ class ForumEmailService {
replier, replier,
post, post,
reply, reply,
parentComment parentComment,
) { ) {
if (!this.initialized) { if (!this.initialized) {
await this.initialize(); await this.initialize();
} }
try { try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
const postUrl = `${frontendUrl}/forum/posts/${post.id}`; const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
const timestamp = new Date(reply.createdAt).toLocaleString("en-US", { const timestamp = new Date(reply.createdAt).toLocaleString("en-US", {
@@ -152,7 +152,7 @@ class ForumEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"forumReplyToCommentAuthor", "forumReplyToCommentAuthor",
variables variables,
); );
const subject = `${replier.firstName} ${replier.lastName} replied to your comment`; const subject = `${replier.firstName} ${replier.lastName} replied to your comment`;
@@ -160,12 +160,12 @@ class ForumEmailService {
const result = await this.emailClient.sendEmail( const result = await this.emailClient.sendEmail(
commentAuthor.email, commentAuthor.email,
subject, subject,
htmlContent htmlContent,
); );
if (result.success) { if (result.success) {
logger.info( logger.info(
`Forum reply notification email sent to ${commentAuthor.email}` `Forum reply notification email sent to ${commentAuthor.email}`,
); );
} }
@@ -195,14 +195,14 @@ class ForumEmailService {
commentAuthor, commentAuthor,
postAuthor, postAuthor,
post, post,
comment comment,
) { ) {
if (!this.initialized) { if (!this.initialized) {
await this.initialize(); await this.initialize();
} }
try { try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
const postUrl = `${frontendUrl}/forum/posts/${post.id}`; const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
const variables = { const variables = {
@@ -216,7 +216,7 @@ class ForumEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"forumAnswerAcceptedToCommentAuthor", "forumAnswerAcceptedToCommentAuthor",
variables variables,
); );
const subject = `Your comment was marked as the accepted answer!`; const subject = `Your comment was marked as the accepted answer!`;
@@ -224,12 +224,12 @@ class ForumEmailService {
const result = await this.emailClient.sendEmail( const result = await this.emailClient.sendEmail(
commentAuthor.email, commentAuthor.email,
subject, subject,
htmlContent htmlContent,
); );
if (result.success) { if (result.success) {
logger.info( logger.info(
`Forum answer accepted notification email sent to ${commentAuthor.email}` `Forum answer accepted notification email sent to ${commentAuthor.email}`,
); );
} }
@@ -237,7 +237,7 @@ class ForumEmailService {
} catch (error) { } catch (error) {
logger.error( logger.error(
"Failed to send forum answer accepted notification email:", "Failed to send forum answer accepted notification email:",
error error,
); );
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
@@ -263,14 +263,14 @@ class ForumEmailService {
participant, participant,
commenter, commenter,
post, post,
comment comment,
) { ) {
if (!this.initialized) { if (!this.initialized) {
await this.initialize(); await this.initialize();
} }
try { try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
const postUrl = `${frontendUrl}/forum/posts/${post.id}`; const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
const timestamp = new Date(comment.createdAt).toLocaleString("en-US", { const timestamp = new Date(comment.createdAt).toLocaleString("en-US", {
@@ -290,7 +290,7 @@ class ForumEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"forumThreadActivityToParticipant", "forumThreadActivityToParticipant",
variables variables,
); );
const subject = `New activity on a post you're following`; const subject = `New activity on a post you're following`;
@@ -298,12 +298,12 @@ class ForumEmailService {
const result = await this.emailClient.sendEmail( const result = await this.emailClient.sendEmail(
participant.email, participant.email,
subject, subject,
htmlContent htmlContent,
); );
if (result.success) { if (result.success) {
logger.info( logger.info(
`Forum thread activity notification email sent to ${participant.email}` `Forum thread activity notification email sent to ${participant.email}`,
); );
} }
@@ -311,7 +311,7 @@ class ForumEmailService {
} catch (error) { } catch (error) {
logger.error( logger.error(
"Failed to send forum thread activity notification email:", "Failed to send forum thread activity notification email:",
error error,
); );
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
@@ -331,18 +331,13 @@ class ForumEmailService {
* @param {Date} closedAt - Timestamp when discussion was closed * @param {Date} closedAt - Timestamp when discussion was closed
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>} * @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
*/ */
async sendForumPostClosedNotification( async sendForumPostClosedNotification(recipient, closer, post, closedAt) {
recipient,
closer,
post,
closedAt
) {
if (!this.initialized) { if (!this.initialized) {
await this.initialize(); await this.initialize();
} }
try { try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
const postUrl = `${frontendUrl}/forum/posts/${post.id}`; const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
const timestamp = new Date(closedAt).toLocaleString("en-US", { const timestamp = new Date(closedAt).toLocaleString("en-US", {
@@ -352,8 +347,7 @@ class ForumEmailService {
const variables = { const variables = {
recipientName: recipient.firstName || "there", recipientName: recipient.firstName || "there",
adminName: adminName: `${closer.firstName} ${closer.lastName}`.trim() || "A user",
`${closer.firstName} ${closer.lastName}`.trim() || "A user",
postTitle: post.title, postTitle: post.title,
postUrl: postUrl, postUrl: postUrl,
timestamp: timestamp, timestamp: timestamp,
@@ -361,7 +355,7 @@ class ForumEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"forumPostClosed", "forumPostClosed",
variables variables,
); );
const subject = `Discussion closed: ${post.title}`; const subject = `Discussion closed: ${post.title}`;
@@ -369,12 +363,12 @@ class ForumEmailService {
const result = await this.emailClient.sendEmail( const result = await this.emailClient.sendEmail(
recipient.email, recipient.email,
subject, subject,
htmlContent htmlContent,
); );
if (result.success) { if (result.success) {
logger.info( logger.info(
`Forum post closed notification email sent to ${recipient.email}` `Forum post closed notification email sent to ${recipient.email}`,
); );
} }
@@ -382,7 +376,7 @@ class ForumEmailService {
} catch (error) { } catch (error) {
logger.error( logger.error(
"Failed to send forum post closed notification email:", "Failed to send forum post closed notification email:",
error error,
); );
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
@@ -401,18 +395,24 @@ class ForumEmailService {
* @param {string} deletionReason - Reason for deletion * @param {string} deletionReason - Reason for deletion
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>} * @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
*/ */
async sendForumPostDeletionNotification(postAuthor, admin, post, deletionReason) { async sendForumPostDeletionNotification(
postAuthor,
admin,
post,
deletionReason,
) {
if (!this.initialized) { if (!this.initialized) {
await this.initialize(); await this.initialize();
} }
try { try {
const supportEmail = process.env.SUPPORT_EMAIL; const supportEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
const variables = { const variables = {
postAuthorName: postAuthor.firstName || "there", postAuthorName: postAuthor.firstName || "there",
adminName: `${admin.firstName} ${admin.lastName}`.trim() || "An administrator", adminName:
`${admin.firstName} ${admin.lastName}`.trim() || "An administrator",
postTitle: post.title, postTitle: post.title,
deletionReason, deletionReason,
supportEmail, supportEmail,
@@ -421,7 +421,7 @@ class ForumEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"forumPostDeletionToAuthor", "forumPostDeletionToAuthor",
variables variables,
); );
const subject = `Important: Your forum post "${post.title}" has been removed`; const subject = `Important: Your forum post "${post.title}" has been removed`;
@@ -429,12 +429,12 @@ class ForumEmailService {
const result = await this.emailClient.sendEmail( const result = await this.emailClient.sendEmail(
postAuthor.email, postAuthor.email,
subject, subject,
htmlContent htmlContent,
); );
if (result.success) { if (result.success) {
logger.info( logger.info(
`Forum post deletion notification email sent to ${postAuthor.email}` `Forum post deletion notification email sent to ${postAuthor.email}`,
); );
} }
@@ -442,7 +442,7 @@ class ForumEmailService {
} catch (error) { } catch (error) {
logger.error( logger.error(
"Failed to send forum post deletion notification email:", "Failed to send forum post deletion notification email:",
error error,
); );
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
@@ -462,19 +462,25 @@ class ForumEmailService {
* @param {string} deletionReason - Reason for deletion * @param {string} deletionReason - Reason for deletion
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>} * @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
*/ */
async sendForumCommentDeletionNotification(commentAuthor, admin, post, deletionReason) { async sendForumCommentDeletionNotification(
commentAuthor,
admin,
post,
deletionReason,
) {
if (!this.initialized) { if (!this.initialized) {
await this.initialize(); await this.initialize();
} }
try { try {
const supportEmail = process.env.SUPPORT_EMAIL; const supportEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
const postUrl = `${frontendUrl}/forum/posts/${post.id}`; const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
const variables = { const variables = {
commentAuthorName: commentAuthor.firstName || "there", commentAuthorName: commentAuthor.firstName || "there",
adminName: `${admin.firstName} ${admin.lastName}`.trim() || "An administrator", adminName:
`${admin.firstName} ${admin.lastName}`.trim() || "An administrator",
postTitle: post.title, postTitle: post.title,
postUrl, postUrl,
deletionReason, deletionReason,
@@ -483,7 +489,7 @@ class ForumEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"forumCommentDeletionToAuthor", "forumCommentDeletionToAuthor",
variables variables,
); );
const subject = `Your comment on "${post.title}" has been removed`; const subject = `Your comment on "${post.title}" has been removed`;
@@ -491,12 +497,12 @@ class ForumEmailService {
const result = await this.emailClient.sendEmail( const result = await this.emailClient.sendEmail(
commentAuthor.email, commentAuthor.email,
subject, subject,
htmlContent htmlContent,
); );
if (result.success) { if (result.success) {
logger.info( logger.info(
`Forum comment deletion notification email sent to ${commentAuthor.email}` `Forum comment deletion notification email sent to ${commentAuthor.email}`,
); );
} }
@@ -504,7 +510,7 @@ class ForumEmailService {
} catch (error) { } catch (error) {
logger.error( logger.error(
"Failed to send forum comment deletion notification email:", "Failed to send forum comment deletion notification email:",
error error,
); );
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
@@ -531,7 +537,7 @@ class ForumEmailService {
} }
try { try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
const postUrl = `${frontendUrl}/forum/posts/${post.id}`; const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
const variables = { const variables = {
@@ -546,7 +552,7 @@ class ForumEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"forumItemRequestNotification", "forumItemRequestNotification",
variables variables,
); );
const subject = `Someone nearby is looking for: ${post.title}`; const subject = `Someone nearby is looking for: ${post.title}`;
@@ -554,12 +560,12 @@ class ForumEmailService {
const result = await this.emailClient.sendEmail( const result = await this.emailClient.sendEmail(
recipient.email, recipient.email,
subject, subject,
htmlContent htmlContent,
); );
if (result.success) { if (result.success) {
logger.info( logger.info(
`Item request notification email sent to ${recipient.email}` `Item request notification email sent to ${recipient.email}`,
); );
} }

View File

@@ -50,7 +50,7 @@ class MessagingEmailService {
} }
try { try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
const conversationUrl = `${frontendUrl}/messages/conversations/${sender.id}`; const conversationUrl = `${frontendUrl}/messages/conversations/${sender.id}`;
const timestamp = new Date(message.createdAt).toLocaleString("en-US", { const timestamp = new Date(message.createdAt).toLocaleString("en-US", {
@@ -68,7 +68,7 @@ class MessagingEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"newMessageToUser", "newMessageToUser",
variables variables,
); );
const subject = `New message from ${sender.firstName} ${sender.lastName}`; const subject = `New message from ${sender.firstName} ${sender.lastName}`;
@@ -76,12 +76,12 @@ class MessagingEmailService {
const result = await this.emailClient.sendEmail( const result = await this.emailClient.sendEmail(
receiver.email, receiver.email,
subject, subject,
htmlContent htmlContent,
); );
if (result.success) { if (result.success) {
logger.info( logger.info(
`Message notification email sent to ${receiver.email} from ${sender.firstName} ${sender.lastName}` `Message notification email sent to ${receiver.email} from ${sender.firstName} ${sender.lastName}`,
); );
} }

View File

@@ -49,12 +49,8 @@ class PaymentEmailService {
} }
try { try {
const { const { renterFirstName, itemName, declineReason, updatePaymentUrl } =
renterFirstName, params;
itemName,
declineReason,
updatePaymentUrl,
} = params;
const variables = { const variables = {
renterFirstName: renterFirstName || "there", renterFirstName: renterFirstName || "there",
@@ -65,13 +61,13 @@ class PaymentEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"paymentDeclinedToRenter", "paymentDeclinedToRenter",
variables variables,
); );
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
renterEmail, renterEmail,
`Action Required: Payment Issue - ${itemName || "Your Rental"}`, `Action Required: Payment Issue - ${itemName || "Your Rental"}`,
htmlContent htmlContent,
); );
} catch (error) { } catch (error) {
logger.error("Failed to send payment declined notification", { error }); logger.error("Failed to send payment declined notification", { error });
@@ -105,16 +101,18 @@ class PaymentEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"paymentMethodUpdatedToOwner", "paymentMethodUpdatedToOwner",
variables variables,
); );
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
ownerEmail, ownerEmail,
`Payment Method Updated - ${itemName || "Your Item"}`, `Payment Method Updated - ${itemName || "Your Item"}`,
htmlContent htmlContent,
); );
} catch (error) { } catch (error) {
logger.error("Failed to send payment method updated notification", { error }); logger.error("Failed to send payment method updated notification", {
error,
});
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
} }
@@ -151,22 +149,25 @@ class PaymentEmailService {
const variables = { const variables = {
ownerName: ownerName || "there", ownerName: ownerName || "there",
payoutAmount: payoutAmount?.toFixed(2) || "0.00", payoutAmount: payoutAmount?.toFixed(2) || "0.00",
failureMessage: failureMessage || "There was an issue with your payout.", failureMessage:
actionRequired: actionRequired || "Please check your bank account details.", failureMessage || "There was an issue with your payout.",
actionRequired:
actionRequired || "Please check your bank account details.",
failureCode: failureCode || "unknown", failureCode: failureCode || "unknown",
requiresBankUpdate: requiresBankUpdate || false, requiresBankUpdate: requiresBankUpdate || false,
payoutSettingsUrl: payoutSettingsUrl || process.env.FRONTEND_URL + "/settings/payouts", payoutSettingsUrl:
payoutSettingsUrl || process.env.FRONTEND_URL + "/settings/payouts",
}; };
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"payoutFailedToOwner", "payoutFailedToOwner",
variables variables,
); );
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
ownerEmail, ownerEmail,
"Action Required: Payout Issue - Village Share", "Action Required: Payout Issue - Village Share",
htmlContent htmlContent,
); );
} catch (error) { } catch (error) {
logger.error("Failed to send payout failed notification", { error }); logger.error("Failed to send payout failed notification", { error });
@@ -200,13 +201,13 @@ class PaymentEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"accountDisconnectedToOwner", "accountDisconnectedToOwner",
variables variables,
); );
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
ownerEmail, ownerEmail,
"Your payout account has been disconnected - Village Share", "Your payout account has been disconnected - Village Share",
htmlContent htmlContent,
); );
} catch (error) { } catch (error) {
logger.error("Failed to send account disconnected email", { error }); logger.error("Failed to send account disconnected email", { error });
@@ -240,13 +241,13 @@ class PaymentEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"payoutsDisabledToOwner", "payoutsDisabledToOwner",
variables variables,
); );
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
ownerEmail, ownerEmail,
"Action Required: Your payouts have been paused - Village Share", "Action Required: Your payouts have been paused - Village Share",
htmlContent htmlContent,
); );
} catch (error) { } catch (error) {
logger.error("Failed to send payouts disabled email", { error }); logger.error("Failed to send payouts disabled email", { error });
@@ -289,16 +290,16 @@ class PaymentEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"disputeAlertToAdmin", "disputeAlertToAdmin",
variables variables,
); );
// Send to admin email (configure in env) // Send to admin email (configure in env)
const adminEmail = process.env.ADMIN_EMAIL || process.env.SES_FROM_EMAIL; const adminEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
adminEmail, adminEmail,
`URGENT: Payment Dispute - Rental #${disputeData.rentalId}`, `URGENT: Payment Dispute - Rental #${disputeData.rentalId}`,
htmlContent htmlContent,
); );
} catch (error) { } catch (error) {
logger.error("Failed to send dispute alert email", { error }); logger.error("Failed to send dispute alert email", { error });
@@ -326,22 +327,24 @@ class PaymentEmailService {
const variables = { const variables = {
rentalId: disputeData.rentalId, rentalId: disputeData.rentalId,
amount: disputeData.amount.toFixed(2), amount: disputeData.amount.toFixed(2),
ownerPayoutAmount: parseFloat(disputeData.ownerPayoutAmount || 0).toFixed(2), ownerPayoutAmount: parseFloat(
disputeData.ownerPayoutAmount || 0,
).toFixed(2),
ownerName: disputeData.ownerName || "Unknown", ownerName: disputeData.ownerName || "Unknown",
ownerEmail: disputeData.ownerEmail || "Unknown", ownerEmail: disputeData.ownerEmail || "Unknown",
}; };
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"disputeLostAlertToAdmin", "disputeLostAlertToAdmin",
variables variables,
); );
const adminEmail = process.env.ADMIN_EMAIL || process.env.SES_FROM_EMAIL; const adminEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
adminEmail, adminEmail,
`ACTION REQUIRED: Dispute Lost - Owner Already Paid - Rental #${disputeData.rentalId}`, `ACTION REQUIRED: Dispute Lost - Owner Already Paid - Rental #${disputeData.rentalId}`,
htmlContent htmlContent,
); );
} catch (error) { } catch (error) {
logger.error("Failed to send dispute lost alert email", { error }); logger.error("Failed to send dispute lost alert email", { error });

View File

@@ -62,7 +62,7 @@ class RentalFlowEmailService {
} }
try { try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
const approveUrl = `${frontendUrl}/owning?rentalId=${rental.id}`; const approveUrl = `${frontendUrl}/owning?rentalId=${rental.id}`;
const variables = { const variables = {
@@ -95,13 +95,13 @@ class RentalFlowEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"rentalRequestToOwner", "rentalRequestToOwner",
variables variables,
); );
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
owner.email, owner.email,
`Rental Request for ${rental.item?.name || "Your Item"}`, `Rental Request for ${rental.item?.name || "Your Item"}`,
htmlContent htmlContent,
); );
} catch (error) { } catch (error) {
logger.error("Failed to send rental request email", { error }); logger.error("Failed to send rental request email", { error });
@@ -129,7 +129,7 @@ class RentalFlowEmailService {
} }
try { try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
const viewRentalsUrl = `${frontendUrl}/renting`; const viewRentalsUrl = `${frontendUrl}/renting`;
// Determine payment message based on rental amount // Determine payment message based on rental amount
@@ -162,16 +162,18 @@ class RentalFlowEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"rentalRequestConfirmationToRenter", "rentalRequestConfirmationToRenter",
variables variables,
); );
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
renter.email, renter.email,
`Rental Request Submitted - ${rental.item?.name || "Item"}`, `Rental Request Submitted - ${rental.item?.name || "Item"}`,
htmlContent htmlContent,
); );
} catch (error) { } catch (error) {
logger.error("Failed to send rental request confirmation email", { error }); logger.error("Failed to send rental request confirmation email", {
error,
});
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
} }
@@ -203,7 +205,7 @@ class RentalFlowEmailService {
} }
try { try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
// Determine if Stripe setup is needed // Determine if Stripe setup is needed
const hasStripeAccount = !!owner.stripeConnectedAccountId; const hasStripeAccount = !!owner.stripeConnectedAccountId;
@@ -250,7 +252,7 @@ class RentalFlowEmailService {
<div class="warning-box"> <div class="warning-box">
<p><strong>⚠️ Action Required: Set Up Your Earnings Account</strong></p> <p><strong>⚠️ Action Required: Set Up Your Earnings Account</strong></p>
<p>To receive your payout of <strong>$${payoutAmount.toFixed( <p>To receive your payout of <strong>$${payoutAmount.toFixed(
2 2,
)}</strong> when this rental completes, you need to set up your earnings account.</p> )}</strong> when this rental completes, you need to set up your earnings account.</p>
</div> </div>
<h2>Set Up Earnings to Get Paid</h2> <h2>Set Up Earnings to Get Paid</h2>
@@ -276,7 +278,7 @@ class RentalFlowEmailService {
<div class="success-box"> <div class="success-box">
<p><strong>✓ Earnings Account Active</strong></p> <p><strong>✓ Earnings Account Active</strong></p>
<p>Your earnings account is set up. You'll automatically receive $${payoutAmount.toFixed( <p>Your earnings account is set up. You'll automatically receive $${payoutAmount.toFixed(
2 2,
)} when this rental completes.</p> )} when this rental completes.</p>
<p><a href="${frontendUrl}/earnings" style="color: #155724; text-decoration: underline;">View your earnings dashboard →</a></p> <p><a href="${frontendUrl}/earnings" style="color: #155724; text-decoration: underline;">View your earnings dashboard →</a></p>
</div> </div>
@@ -313,7 +315,7 @@ class RentalFlowEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"rentalApprovalConfirmationToOwner", "rentalApprovalConfirmationToOwner",
variables variables,
); );
const subject = `Rental Approved - ${rental.item?.name || "Your Item"}`; const subject = `Rental Approved - ${rental.item?.name || "Your Item"}`;
@@ -321,10 +323,12 @@ class RentalFlowEmailService {
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
owner.email, owner.email,
subject, subject,
htmlContent htmlContent,
); );
} catch (error) { } catch (error) {
logger.error("Failed to send rental approval confirmation email", { error }); logger.error("Failed to send rental approval confirmation email", {
error,
});
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
} }
@@ -351,7 +355,7 @@ class RentalFlowEmailService {
} }
try { try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
const browseItemsUrl = `${frontendUrl}/`; const browseItemsUrl = `${frontendUrl}/`;
// Determine payment message based on rental amount // Determine payment message based on rental amount
@@ -398,13 +402,13 @@ class RentalFlowEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"rentalDeclinedToRenter", "rentalDeclinedToRenter",
variables variables,
); );
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
renter.email, renter.email,
`Rental Request Declined - ${rental.item?.name || "Item"}`, `Rental Request Declined - ${rental.item?.name || "Item"}`,
htmlContent htmlContent,
); );
} catch (error) { } catch (error) {
logger.error("Failed to send rental declined email", { error }); logger.error("Failed to send rental declined email", { error });
@@ -438,7 +442,7 @@ class RentalFlowEmailService {
notification, notification,
rental, rental,
recipientName = null, recipientName = null,
isRenter = false isRenter = false,
) { ) {
if (!this.initialized) { if (!this.initialized) {
await this.initialize(); await this.initialize();
@@ -533,7 +537,7 @@ class RentalFlowEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"rentalConfirmationToUser", "rentalConfirmationToUser",
variables variables,
); );
// Use clear, transactional subject line with item name // Use clear, transactional subject line with item name
@@ -602,10 +606,12 @@ class RentalFlowEmailService {
ownerNotification, ownerNotification,
rental, rental,
owner.firstName, owner.firstName,
false // isRenter = false for owner false, // isRenter = false for owner
); );
if (ownerResult.success) { if (ownerResult.success) {
logger.info("Rental confirmation email sent to owner", { email: owner.email }); logger.info("Rental confirmation email sent to owner", {
email: owner.email,
});
results.ownerEmailSent = true; results.ownerEmailSent = true;
} else { } else {
logger.error("Failed to send rental confirmation email to owner", { logger.error("Failed to send rental confirmation email to owner", {
@@ -629,10 +635,12 @@ class RentalFlowEmailService {
renterNotification, renterNotification,
rental, rental,
renter.firstName, renter.firstName,
true // isRenter = true for renter (enables payment receipt) true, // isRenter = true for renter (enables payment receipt)
); );
if (renterResult.success) { if (renterResult.success) {
logger.info("Rental confirmation email sent to renter", { email: renter.email }); logger.info("Rental confirmation email sent to renter", {
email: renter.email,
});
results.renterEmailSent = true; results.renterEmailSent = true;
} else { } else {
logger.error("Failed to send rental confirmation email to renter", { logger.error("Failed to send rental confirmation email to renter", {
@@ -648,7 +656,9 @@ class RentalFlowEmailService {
} }
} }
} catch (error) { } catch (error) {
logger.error("Error fetching user data for rental confirmation emails", { error }); logger.error("Error fetching user data for rental confirmation emails", {
error,
});
} }
return results; return results;
@@ -687,7 +697,7 @@ class RentalFlowEmailService {
}; };
try { try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
const browseUrl = `${frontendUrl}/`; const browseUrl = `${frontendUrl}/`;
const cancelledBy = rental.cancelledBy; const cancelledBy = rental.cancelledBy;
@@ -731,7 +741,7 @@ class RentalFlowEmailService {
<div class="info-box"> <div class="info-box">
<p><strong>Full Refund Processed</strong></p> <p><strong>Full Refund Processed</strong></p>
<p>You will receive a full refund of $${refundInfo.amount.toFixed( <p>You will receive a full refund of $${refundInfo.amount.toFixed(
2 2,
)}. The refund will appear in your account within 5-10 business days.</p> )}. The refund will appear in your account within 5-10 business days.</p>
</div> </div>
<div style="text-align: center"> <div style="text-align: center">
@@ -774,7 +784,7 @@ class RentalFlowEmailService {
<div class="refund-amount">$${refundInfo.amount.toFixed(2)}</div> <div class="refund-amount">$${refundInfo.amount.toFixed(2)}</div>
<div class="info-box"> <div class="info-box">
<p><strong>Refund Amount:</strong> $${refundInfo.amount.toFixed( <p><strong>Refund Amount:</strong> $${refundInfo.amount.toFixed(
2 2,
)} (${refundPercentage}% of total)</p> )} (${refundPercentage}% of total)</p>
<p><strong>Reason:</strong> ${refundInfo.reason}</p> <p><strong>Reason:</strong> ${refundInfo.reason}</p>
<p><strong>Processing Time:</strong> Refunds typically appear within 5-10 business days.</p> <p><strong>Processing Time:</strong> Refunds typically appear within 5-10 business days.</p>
@@ -804,13 +814,13 @@ class RentalFlowEmailService {
const confirmationHtml = await this.templateManager.renderTemplate( const confirmationHtml = await this.templateManager.renderTemplate(
"rentalCancellationConfirmationToUser", "rentalCancellationConfirmationToUser",
confirmationVariables confirmationVariables,
); );
const confirmationResult = await this.emailClient.sendEmail( const confirmationResult = await this.emailClient.sendEmail(
confirmationRecipient, confirmationRecipient,
`Cancellation Confirmed - ${itemName}`, `Cancellation Confirmed - ${itemName}`,
confirmationHtml confirmationHtml,
); );
if (confirmationResult.success) { if (confirmationResult.success) {
@@ -841,13 +851,13 @@ class RentalFlowEmailService {
const notificationHtml = await this.templateManager.renderTemplate( const notificationHtml = await this.templateManager.renderTemplate(
"rentalCancellationNotificationToUser", "rentalCancellationNotificationToUser",
notificationVariables notificationVariables,
); );
const notificationResult = await this.emailClient.sendEmail( const notificationResult = await this.emailClient.sendEmail(
notificationRecipient, notificationRecipient,
`Rental Cancelled - ${itemName}`, `Rental Cancelled - ${itemName}`,
notificationHtml notificationHtml,
); );
if (notificationResult.success) { if (notificationResult.success) {
@@ -858,7 +868,9 @@ class RentalFlowEmailService {
results.notificationEmailSent = true; results.notificationEmailSent = true;
} }
} catch (error) { } catch (error) {
logger.error("Failed to send cancellation notification email", { error }); logger.error("Failed to send cancellation notification email", {
error,
});
} }
} catch (error) { } catch (error) {
logger.error("Error sending cancellation emails", { error }); logger.error("Error sending cancellation emails", { error });
@@ -896,7 +908,7 @@ class RentalFlowEmailService {
await this.initialize(); await this.initialize();
} }
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
const results = { const results = {
renterEmailSent: false, renterEmailSent: false,
ownerEmailSent: false, ownerEmailSent: false,
@@ -968,17 +980,19 @@ class RentalFlowEmailService {
const renterHtmlContent = await this.templateManager.renderTemplate( const renterHtmlContent = await this.templateManager.renderTemplate(
"rentalCompletionThankYouToRenter", "rentalCompletionThankYouToRenter",
renterVariables renterVariables,
); );
const renterResult = await this.emailClient.sendEmail( const renterResult = await this.emailClient.sendEmail(
renter.email, renter.email,
`Thank You for Returning "${rental.item?.name || "Item"}" On Time!`, `Thank You for Returning "${rental.item?.name || "Item"}" On Time!`,
renterHtmlContent renterHtmlContent,
); );
if (renterResult.success) { if (renterResult.success) {
logger.info("Rental completion thank you email sent to renter", { email: renter.email }); logger.info("Rental completion thank you email sent to renter", {
email: renter.email,
});
results.renterEmailSent = true; results.renterEmailSent = true;
} else { } else {
logger.error("Failed to send rental completion email to renter", { logger.error("Failed to send rental completion email to renter", {
@@ -1035,7 +1049,7 @@ class RentalFlowEmailService {
<div class="warning-box"> <div class="warning-box">
<p><strong>⚠️ Action Required: Set Up Your Earnings Account</strong></p> <p><strong>⚠️ Action Required: Set Up Your Earnings Account</strong></p>
<p>To receive your payout of <strong>$${payoutAmount.toFixed( <p>To receive your payout of <strong>$${payoutAmount.toFixed(
2 2,
)}</strong>, you need to set up your earnings account.</p> )}</strong>, you need to set up your earnings account.</p>
</div> </div>
<h2>Set Up Earnings to Get Paid</h2> <h2>Set Up Earnings to Get Paid</h2>
@@ -1061,7 +1075,7 @@ class RentalFlowEmailService {
<div class="success-box"> <div class="success-box">
<p><strong>✓ Payout Initiated</strong></p> <p><strong>✓ Payout Initiated</strong></p>
<p>Your earnings of <strong>$${payoutAmount.toFixed( <p>Your earnings of <strong>$${payoutAmount.toFixed(
2 2,
)}</strong> have been transferred to your Stripe account.</p> )}</strong> have been transferred to your Stripe account.</p>
<p style="font-size: 14px; margin-top: 10px;">Funds typically reach your bank within 2-7 business days.</p> <p style="font-size: 14px; margin-top: 10px;">Funds typically reach your bank within 2-7 business days.</p>
<p><a href="${frontendUrl}/earnings" style="color: #155724; text-decoration: underline;">View your earnings dashboard →</a></p> <p><a href="${frontendUrl}/earnings" style="color: #155724; text-decoration: underline;">View your earnings dashboard →</a></p>
@@ -1086,17 +1100,19 @@ class RentalFlowEmailService {
const ownerHtmlContent = await this.templateManager.renderTemplate( const ownerHtmlContent = await this.templateManager.renderTemplate(
"rentalCompletionCongratsToOwner", "rentalCompletionCongratsToOwner",
ownerVariables ownerVariables,
); );
const ownerResult = await this.emailClient.sendEmail( const ownerResult = await this.emailClient.sendEmail(
owner.email, owner.email,
`Rental Complete - ${rental.item?.name || "Your Item"}`, `Rental Complete - ${rental.item?.name || "Your Item"}`,
ownerHtmlContent ownerHtmlContent,
); );
if (ownerResult.success) { if (ownerResult.success) {
logger.info("Rental completion congratulations email sent to owner", { email: owner.email }); logger.info("Rental completion congratulations email sent to owner", {
email: owner.email,
});
results.ownerEmailSent = true; results.ownerEmailSent = true;
} else { } else {
logger.error("Failed to send rental completion email to owner", { logger.error("Failed to send rental completion email to owner", {
@@ -1145,7 +1161,7 @@ class RentalFlowEmailService {
} }
try { try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
const earningsDashboardUrl = `${frontendUrl}/earnings`; const earningsDashboardUrl = `${frontendUrl}/earnings`;
// Format currency values // Format currency values
@@ -1177,7 +1193,7 @@ class RentalFlowEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"payoutReceivedToOwner", "payoutReceivedToOwner",
variables variables,
); );
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
@@ -1185,7 +1201,7 @@ class RentalFlowEmailService {
`Earnings Received - $${payoutAmount.toFixed(2)} for ${ `Earnings Received - $${payoutAmount.toFixed(2)} for ${
rental.item?.name || "Your Item" rental.item?.name || "Your Item"
}`, }`,
htmlContent htmlContent,
); );
} catch (error) { } catch (error) {
logger.error("Failed to send payout received email", { error }); logger.error("Failed to send payout received email", { error });
@@ -1223,13 +1239,13 @@ class RentalFlowEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"authenticationRequiredToRenter", "authenticationRequiredToRenter",
variables variables,
); );
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
email, email,
`Action Required: Complete payment for ${itemName}`, `Action Required: Complete payment for ${itemName}`,
htmlContent htmlContent,
); );
} catch (error) { } catch (error) {
logger.error("Failed to send authentication required email", { error }); logger.error("Failed to send authentication required email", { error });

View File

@@ -47,7 +47,7 @@ class UserEngagementEmailService {
} }
try { try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
const variables = { const variables = {
ownerName: owner.firstName || "there", ownerName: owner.firstName || "there",
@@ -58,7 +58,7 @@ class UserEngagementEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"firstListingCelebrationToOwner", "firstListingCelebrationToOwner",
variables variables,
); );
const subject = `Congratulations! Your first item is live on Village Share`; const subject = `Congratulations! Your first item is live on Village Share`;
@@ -66,7 +66,7 @@ class UserEngagementEmailService {
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
owner.email, owner.email,
subject, subject,
htmlContent htmlContent,
); );
} catch (error) { } catch (error) {
logger.error("Failed to send first listing celebration email", { error }); logger.error("Failed to send first listing celebration email", { error });
@@ -91,8 +91,8 @@ class UserEngagementEmailService {
} }
try { try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const frontendUrl = process.env.FRONTEND_URL;
const supportEmail = process.env.SUPPORT_EMAIL; const supportEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
const variables = { const variables = {
ownerName: owner.firstName || "there", ownerName: owner.firstName || "there",
@@ -104,7 +104,7 @@ class UserEngagementEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"itemDeletionToOwner", "itemDeletionToOwner",
variables variables,
); );
const subject = `Important: Your listing "${item.name}" has been removed`; const subject = `Important: Your listing "${item.name}" has been removed`;
@@ -112,10 +112,12 @@ class UserEngagementEmailService {
return await this.emailClient.sendEmail( return await this.emailClient.sendEmail(
owner.email, owner.email,
subject, subject,
htmlContent htmlContent,
); );
} catch (error) { } catch (error) {
logger.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 }; return { success: false, error: error.message };
} }
} }
@@ -137,7 +139,7 @@ class UserEngagementEmailService {
} }
try { try {
const supportEmail = process.env.SUPPORT_EMAIL; const supportEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
const variables = { const variables = {
userName: bannedUser.firstName || "there", userName: bannedUser.firstName || "there",
@@ -147,15 +149,16 @@ class UserEngagementEmailService {
const htmlContent = await this.templateManager.renderTemplate( const htmlContent = await this.templateManager.renderTemplate(
"userBannedNotification", "userBannedNotification",
variables variables,
); );
const subject = "Important: Your Village Share Account Has Been Suspended"; const subject =
"Important: Your Village Share Account Has Been Suspended";
const result = await this.emailClient.sendEmail( const result = await this.emailClient.sendEmail(
bannedUser.email, bannedUser.email,
subject, subject,
htmlContent htmlContent,
); );
if (result.success) { if (result.success) {

View File

@@ -26,7 +26,7 @@ class LocationService {
// distance = 3959 * acos(cos(radians(lat1)) * cos(radians(lat2)) // distance = 3959 * acos(cos(radians(lat1)) * cos(radians(lat2))
// * cos(radians(lng2) - radians(lng1)) // * cos(radians(lng2) - radians(lng1))
// + sin(radians(lat1)) * sin(radians(lat2))) // + sin(radians(lat1)) * sin(radians(lat2)))
// Note: 3959 is Earth's radius in miles // 3959 is Earth's radius in miles
const query = ` const query = `
SELECT * FROM ( SELECT * FROM (
SELECT SELECT

View File

@@ -116,7 +116,7 @@ class StripeService {
destination, destination,
metadata, metadata,
}, },
idempotencyKey ? { idempotencyKey } : undefined idempotencyKey ? { idempotencyKey } : undefined,
); );
return transfer; return transfer;
@@ -236,7 +236,7 @@ class StripeService {
metadata, metadata,
reason, reason,
}, },
idempotencyKey ? { idempotencyKey } : undefined idempotencyKey ? { idempotencyKey } : undefined,
); );
return refund; return refund;
@@ -265,7 +265,7 @@ class StripeService {
paymentMethodId, paymentMethodId,
amount, amount,
customerId, customerId,
metadata = {} metadata = {},
) { ) {
try { try {
// Generate idempotency key to prevent duplicate charges for same rental // Generate idempotency key to prevent duplicate charges for same rental
@@ -282,13 +282,11 @@ class StripeService {
customer: customerId, // Include customer ID customer: customerId, // Include customer ID
confirm: true, // Automatically confirm the payment confirm: true, // Automatically confirm the payment
off_session: true, // Indicate this is an off-session payment off_session: true, // Indicate this is an off-session payment
return_url: `${ return_url: `${process.env.FRONTEND_URL}/complete-payment`,
process.env.FRONTEND_URL || "http://localhost:3000"
}/complete-payment`,
metadata, metadata,
expand: ["latest_charge.payment_method_details"], // Expand to get payment method details expand: ["latest_charge.payment_method_details"], // Expand to get payment method details
}, },
idempotencyKey ? { idempotencyKey } : undefined idempotencyKey ? { idempotencyKey } : undefined,
); );
// Check if additional authentication is required // Check if additional authentication is required

View File

@@ -1,17 +1,24 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Your Alpha Access Code - Village Share</title> <title>Your Alpha Access Code - Village Share</title>
<style> <style>
/* Reset styles */ /* Reset styles */
body, table, td, p, a, li, blockquote { body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%; -ms-text-size-adjust: 100%;
} }
table, td { table,
td {
mso-table-lspace: 0pt; mso-table-lspace: 0pt;
mso-table-rspace: 0pt; mso-table-rspace: 0pt;
} }
@@ -27,7 +34,9 @@
min-width: 100%; min-width: 100%;
height: 100%; height: 100%;
background-color: #f8f9fa; background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
line-height: 1.6; line-height: 1.6;
color: #212529; color: #212529;
} }
@@ -104,7 +113,7 @@
} }
.code { .code {
font-family: 'Courier New', Courier, monospace; font-family: "Courier New", Courier, monospace;
font-size: 32px; font-size: 32px;
font-weight: 700; font-weight: 700;
color: #ffffff; color: #ffffff;
@@ -192,7 +201,9 @@
border-radius: 0; border-radius: 0;
} }
.header, .content, .footer { .header,
.content,
.footer {
padding: 20px; padding: 20px;
} }
@@ -227,24 +238,40 @@
<div class="content"> <div class="content">
<h1>Welcome to Alpha Testing!</h1> <h1>Welcome to Alpha Testing!</h1>
<p>Congratulations! You've been selected to participate in the exclusive alpha testing program for Village Share, the community-powered rental marketplace.</p> <p>
Congratulations! You've been selected to participate in the exclusive
alpha testing program for Village Share, the community-powered rental
marketplace.
</p>
<p>Your unique alpha access code is: <strong style="font-family: monospace;">{{code}}</strong></p> <p>
Your unique alpha access code is:
<strong style="font-family: monospace">{{code}}</strong>
</p>
<p>To get started:</p> <p>To get started:</p>
<div class="info-box"> <div class="info-box">
<p><strong>Steps to Access:</strong></p> <p><strong>Steps to Access:</strong></p>
<ul> <ul>
<li>Visit <a href="{{frontendUrl}}" style="color: #667eea; font-weight: 600;">{{frontendUrl}}</a></li> <li>
Visit
<a href="{{frontendUrl}}" style="color: #667eea; font-weight: 600"
>{{frontendUrl}}</a
>
</li>
<li>Enter your alpha access code when prompted</li> <li>Enter your alpha access code when prompted</li>
<li>Register with <strong>this email address</strong> ({{email}})</li> <li>
Register with <strong>this email address</strong> ({{email}})
</li>
<li>Start exploring the platform!</li> <li>Start exploring the platform!</li>
</ul> </ul>
</div> </div>
<div style="text-align: center;"> <div style="text-align: center">
<a href="{{frontendUrl}}" class="button">Access Village Share Alpha</a> <a href="{{frontendUrl}}" class="button"
>Access Village Share Alpha</a
>
</div> </div>
<p><strong>What to expect as an alpha tester:</strong></p> <p><strong>What to expect as an alpha tester:</strong></p>
@@ -259,23 +286,35 @@
</div> </div>
<p><strong>Important notes:</strong></p> <p><strong>Important notes:</strong></p>
<ul style="color: #6c757d; font-size: 14px;"> <ul style="color: #6c757d; font-size: 14px">
<li>Your code is tied to this email address only</li> <li>Your code is tied to this email address only</li>
<li>This is a permanent access code (no expiration)</li> <li>This is a permanent access code (no expiration)</li>
<li>Please keep your code confidential</li> <li>Please keep your code confidential</li>
<li>We value your feedback - let us know what you think!</li> <li>We value your feedback - let us know what you think!</li>
</ul> </ul>
<p>We're excited to have you as part of our alpha testing community. Your feedback will be invaluable in making Village Share the best it can be.</p> <p>
We're excited to have you as part of our alpha testing community. Your
feedback will be invaluable in making Village Share the best it can
be.
</p>
<p>If you have any questions or encounter any issues, please don't hesitate to reach out to us.</p> <p>
If you have any questions or encounter any issues, please don't
hesitate to reach out to us.
</p>
<p>Happy renting!</p> <p>Happy renting!</p>
</div> </div>
<div class="footer"> <div class="footer">
<p><strong>Village Share Alpha Testing Program</strong></p> <p><strong>Village Share Alpha Testing Program</strong></p>
<p>Need help? Contact us at <a href="mailto:support@villageshare.app">support@villageshare.app</a></p> <p>
Need help? Contact us at
<a href="mailto:community-support@village-share.com"
>community-support@village-share.com</a
>
</p>
<p>&copy; 2025 Village Share. All rights reserved.</p> <p>&copy; 2025 Village Share. All rights reserved.</p>
</div> </div>
</div> </div>

View File

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

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
@@ -34,8 +34,9 @@
min-width: 100%; min-width: 100%;
height: 100%; height: 100%;
background-color: #f8f9fa; background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, font-family:
Oxygen, Ubuntu, Cantarell, sans-serif; -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
line-height: 1.6; line-height: 1.6;
color: #212529; color: #212529;
} }
@@ -246,8 +247,8 @@
<p> <p>
<strong>Didn't change your password?</strong> If you did not make <strong>Didn't change your password?</strong> If you did not make
this change, your account may be compromised. Please contact our this change, your account may be compromised. Please contact our
support team immediately at support@villageshare.app to secure your support team immediately at community-support@village-share.com to
account. secure your account.
</p> </p>
</div> </div>

View File

@@ -1,17 +1,24 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Personal Information Updated - Village Share</title> <title>Personal Information Updated - Village Share</title>
<style> <style>
/* Reset styles */ /* Reset styles */
body, table, td, p, a, li, blockquote { body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%; -ms-text-size-adjust: 100%;
} }
table, td { table,
td {
mso-table-lspace: 0pt; mso-table-lspace: 0pt;
mso-table-rspace: 0pt; mso-table-rspace: 0pt;
} }
@@ -27,7 +34,9 @@
min-width: 100%; min-width: 100%;
height: 100%; height: 100%;
background-color: #f8f9fa; background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
line-height: 1.6; line-height: 1.6;
color: #212529; color: #212529;
} }
@@ -182,7 +191,9 @@
border-radius: 0; border-radius: 0;
} }
.header, .content, .footer { .header,
.content,
.footer {
padding: 20px; padding: 20px;
} }
@@ -209,10 +220,18 @@
<h1>Your Personal Information Has Been Updated</h1> <h1>Your Personal Information Has Been Updated</h1>
<div class="info-box"> <div class="info-box">
<p><strong>Your account information was recently updated.</strong> This email is to notify you that changes were made to your personal information on your Village Share account.</p> <p>
<strong>Your account information was recently updated.</strong> This
email is to notify you that changes were made to your personal
information on your Village Share account.
</p>
</div> </div>
<p>We're sending you this notification as part of our commitment to keeping your account secure. If you made these changes, no further action is required.</p> <p>
We're sending you this notification as part of our commitment to
keeping your account secure. If you made these changes, no further
action is required.
</p>
<table class="details-table"> <table class="details-table">
<tr> <tr>
@@ -226,11 +245,21 @@
</table> </table>
<div class="security-box"> <div class="security-box">
<p><strong>Didn't make these changes?</strong> If you did not update your personal information, your account may be compromised. Please contact our support team immediately at support@villageshare.app and consider changing your password.</p> <p>
<strong>Didn't make these changes?</strong> If you did not update
your personal information, your account may be compromised. Please
contact our support team immediately at
community-support@village-share.com and consider changing your
password.
</p>
</div> </div>
<div class="info-box"> <div class="info-box">
<p><strong>Security tip:</strong> Regularly review your account information to ensure it's accurate and up to date. If you notice any suspicious activity, contact our support team right away.</p> <p>
<strong>Security tip:</strong> Regularly review your account
information to ensure it's accurate and up to date. If you notice
any suspicious activity, contact our support team right away.
</p>
</div> </div>
<p>Thanks for using Village Share!</p> <p>Thanks for using Village Share!</p>
@@ -238,7 +267,11 @@
<div class="footer"> <div class="footer">
<p><strong>Village Share</strong></p> <p><strong>Village Share</strong></p>
<p>This is a security notification sent to confirm changes to your account. If you have any concerns about your account security, please contact our support team immediately.</p> <p>
This is a security notification sent to confirm changes to your
account. If you have any concerns about your account security, please
contact our support team immediately.
</p>
<p>&copy; 2025 Village Share. All rights reserved.</p> <p>&copy; 2025 Village Share. All rights reserved.</p>
</div> </div>
</div> </div>

View File

@@ -6,12 +6,12 @@
* cancellation flows. * cancellation flows.
*/ */
const request = require('supertest'); const request = require("supertest");
const express = require('express'); const express = require("express");
const cookieParser = require('cookie-parser'); const cookieParser = require("cookie-parser");
const jwt = require('jsonwebtoken'); const jwt = require("jsonwebtoken");
const { sequelize, User, Item, Rental } = require('../../models'); const { sequelize, User, Item, Rental } = require("../../models");
const rentalRoutes = require('../../routes/rentals'); const rentalRoutes = require("../../routes/rentals");
// Test app setup // Test app setup
const createTestApp = () => { const createTestApp = () => {
@@ -21,11 +21,11 @@ const createTestApp = () => {
// Add request ID middleware // Add request ID middleware
app.use((req, res, next) => { app.use((req, res, next) => {
req.id = 'test-request-id'; req.id = "test-request-id";
next(); next();
}); });
app.use('/rentals', rentalRoutes); app.use("/rentals", rentalRoutes);
return app; return app;
}; };
@@ -34,7 +34,7 @@ const generateAuthToken = (user) => {
return jwt.sign( return jwt.sign(
{ id: user.id, jwtVersion: user.jwtVersion || 0 }, { id: user.id, jwtVersion: user.jwtVersion || 0 },
process.env.JWT_ACCESS_SECRET, process.env.JWT_ACCESS_SECRET,
{ expiresIn: '15m' } { expiresIn: "15m" },
); );
}; };
@@ -42,11 +42,11 @@ const generateAuthToken = (user) => {
const createTestUser = async (overrides = {}) => { const createTestUser = async (overrides = {}) => {
const defaultData = { const defaultData = {
email: `user-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`, email: `user-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`,
password: 'TestPassword123!', password: "TestPassword123!",
firstName: 'Test', firstName: "Test",
lastName: 'User', lastName: "User",
isVerified: true, isVerified: true,
authProvider: 'local', authProvider: "local",
}; };
return User.create({ ...defaultData, ...overrides }); return User.create({ ...defaultData, ...overrides });
@@ -54,17 +54,17 @@ const createTestUser = async (overrides = {}) => {
const createTestItem = async (ownerId, overrides = {}) => { const createTestItem = async (ownerId, overrides = {}) => {
const defaultData = { const defaultData = {
name: 'Test Item', name: "Test Item",
description: 'A test item for rental', description: "A test item for rental",
pricePerDay: 25.00, pricePerDay: 25.0,
pricePerHour: 5.00, pricePerHour: 5.0,
replacementCost: 500.00, replacementCost: 500.0,
condition: 'excellent', condition: "excellent",
isAvailable: true, isAvailable: true,
pickUpAvailable: true, pickUpAvailable: true,
ownerId, ownerId,
city: 'Test City', city: "Test City",
state: 'California', state: "California",
}; };
return Item.create({ ...defaultData, ...overrides }); return Item.create({ ...defaultData, ...overrides });
@@ -84,15 +84,15 @@ const createTestRental = async (itemId, renterId, ownerId, overrides = {}) => {
totalAmount: 0, totalAmount: 0,
platformFee: 0, platformFee: 0,
payoutAmount: 0, payoutAmount: 0,
status: 'pending', status: "pending",
paymentStatus: 'pending', paymentStatus: "pending",
deliveryMethod: 'pickup', deliveryMethod: "pickup",
}; };
return Rental.create({ ...defaultData, ...overrides }); return Rental.create({ ...defaultData, ...overrides });
}; };
describe('Rental Integration Tests', () => { describe("Rental Integration Tests", () => {
let app; let app;
let owner; let owner;
let renter; let renter;
@@ -100,9 +100,9 @@ describe('Rental Integration Tests', () => {
beforeAll(async () => { beforeAll(async () => {
// Set test environment variables // Set test environment variables
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = "test";
process.env.JWT_ACCESS_SECRET = 'test-access-secret'; process.env.JWT_ACCESS_SECRET = "test-access-secret";
process.env.JWT_REFRESH_SECRET = 'test-refresh-secret'; process.env.JWT_REFRESH_SECRET = "test-refresh-secret";
// Sync database // Sync database
await sequelize.sync({ force: true }); await sequelize.sync({ force: true });
@@ -122,32 +122,32 @@ describe('Rental Integration Tests', () => {
// Create test users // Create test users
owner = await createTestUser({ owner = await createTestUser({
email: 'owner@example.com', email: "owner@example.com",
firstName: 'Item', firstName: "Item",
lastName: 'Owner', lastName: "Owner",
stripeConnectedAccountId: 'acct_test_owner', stripeConnectedAccountId: "acct_test_owner",
}); });
renter = await createTestUser({ renter = await createTestUser({
email: 'renter@example.com', email: "renter@example.com",
firstName: 'Item', firstName: "Item",
lastName: 'Renter', lastName: "Renter",
}); });
// Create test item // Create test item
item = await createTestItem(owner.id); item = await createTestItem(owner.id);
}); });
describe('GET /rentals/renting', () => { describe("GET /rentals/renting", () => {
it('should return rentals where user is the renter', async () => { it("should return rentals where user is the renter", async () => {
// Create a rental where renter is the renter // Create a rental where renter is the renter
await createTestRental(item.id, renter.id, owner.id); await createTestRental(item.id, renter.id, owner.id);
const token = generateAuthToken(renter); const token = generateAuthToken(renter);
const response = await request(app) const response = await request(app)
.get('/rentals/renting') .get("/rentals/renting")
.set('Cookie', [`accessToken=${token}`]) .set("Cookie", [`accessToken=${token}`])
.expect(200); .expect(200);
expect(Array.isArray(response.body)).toBe(true); expect(Array.isArray(response.body)).toBe(true);
@@ -155,37 +155,35 @@ describe('Rental Integration Tests', () => {
expect(response.body[0].renterId).toBe(renter.id); expect(response.body[0].renterId).toBe(renter.id);
}); });
it('should return empty array for user with no rentals', async () => { it("should return empty array for user with no rentals", async () => {
const token = generateAuthToken(renter); const token = generateAuthToken(renter);
const response = await request(app) const response = await request(app)
.get('/rentals/renting') .get("/rentals/renting")
.set('Cookie', [`accessToken=${token}`]) .set("Cookie", [`accessToken=${token}`])
.expect(200); .expect(200);
expect(Array.isArray(response.body)).toBe(true); expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBe(0); expect(response.body.length).toBe(0);
}); });
it('should require authentication', async () => { it("should require authentication", async () => {
const response = await request(app) const response = await request(app).get("/rentals/renting").expect(401);
.get('/rentals/renting')
.expect(401);
expect(response.body.code).toBeDefined(); expect(response.body.code).toBeDefined();
}); });
}); });
describe('GET /rentals/owning', () => { describe("GET /rentals/owning", () => {
it('should return rentals where user is the owner', async () => { it("should return rentals where user is the owner", async () => {
// Create a rental where owner is the item owner // Create a rental where owner is the item owner
await createTestRental(item.id, renter.id, owner.id); await createTestRental(item.id, renter.id, owner.id);
const token = generateAuthToken(owner); const token = generateAuthToken(owner);
const response = await request(app) const response = await request(app)
.get('/rentals/owning') .get("/rentals/owning")
.set('Cookie', [`accessToken=${token}`]) .set("Cookie", [`accessToken=${token}`])
.expect(200); .expect(200);
expect(Array.isArray(response.body)).toBe(true); expect(Array.isArray(response.body)).toBe(true);
@@ -194,208 +192,213 @@ describe('Rental Integration Tests', () => {
}); });
}); });
describe('PUT /rentals/:id/status', () => { describe("PUT /rentals/:id/status", () => {
let rental; let rental;
beforeEach(async () => { beforeEach(async () => {
rental = await createTestRental(item.id, renter.id, owner.id); rental = await createTestRental(item.id, renter.id, owner.id);
}); });
it('should allow owner to confirm a pending rental', async () => { it("should allow owner to confirm a pending rental", async () => {
const token = generateAuthToken(owner); const token = generateAuthToken(owner);
const response = await request(app) const response = await request(app)
.put(`/rentals/${rental.id}/status`) .put(`/rentals/${rental.id}/status`)
.set('Cookie', [`accessToken=${token}`]) .set("Cookie", [`accessToken=${token}`])
.send({ status: 'confirmed' }) .send({ status: "confirmed" })
.expect(200); .expect(200);
expect(response.body.status).toBe('confirmed'); expect(response.body.status).toBe("confirmed");
// Verify in database // Verify in database
await rental.reload(); await rental.reload();
expect(rental.status).toBe('confirmed'); expect(rental.status).toBe("confirmed");
}); });
it('should allow renter to update status (no owner-only restriction)', async () => { it("should allow renter to update status (no owner-only restriction)", async () => {
const token = generateAuthToken(renter); const token = generateAuthToken(renter);
const response = await request(app) const response = await request(app)
.put(`/rentals/${rental.id}/status`) .put(`/rentals/${rental.id}/status`)
.set('Cookie', [`accessToken=${token}`]) .set("Cookie", [`accessToken=${token}`])
.send({ status: 'confirmed' }) .send({ status: "confirmed" })
.expect(200); .expect(200);
// Note: API currently allows both owner and renter to update status
// Owner-specific logic (payment processing) only runs for owner // Owner-specific logic (payment processing) only runs for owner
await rental.reload(); await rental.reload();
expect(rental.status).toBe('confirmed'); expect(rental.status).toBe("confirmed");
}); });
it('should handle confirming already confirmed rental (idempotent)', async () => { it("should handle confirming already confirmed rental (idempotent)", async () => {
// First confirm it // First confirm it
await rental.update({ status: 'confirmed' }); await rental.update({ status: "confirmed" });
const token = generateAuthToken(owner); const token = generateAuthToken(owner);
// API allows re-confirming (idempotent operation) // API allows re-confirming (idempotent operation)
const response = await request(app) const response = await request(app)
.put(`/rentals/${rental.id}/status`) .put(`/rentals/${rental.id}/status`)
.set('Cookie', [`accessToken=${token}`]) .set("Cookie", [`accessToken=${token}`])
.send({ status: 'confirmed' }) .send({ status: "confirmed" })
.expect(200); .expect(200);
// Status should remain confirmed // Status should remain confirmed
await rental.reload(); await rental.reload();
expect(rental.status).toBe('confirmed'); expect(rental.status).toBe("confirmed");
}); });
}); });
describe('PUT /rentals/:id/decline', () => { describe("PUT /rentals/:id/decline", () => {
let rental; let rental;
beforeEach(async () => { beforeEach(async () => {
rental = await createTestRental(item.id, renter.id, owner.id); rental = await createTestRental(item.id, renter.id, owner.id);
}); });
it('should allow owner to decline a pending rental', async () => { it("should allow owner to decline a pending rental", async () => {
const token = generateAuthToken(owner); const token = generateAuthToken(owner);
const response = await request(app) const response = await request(app)
.put(`/rentals/${rental.id}/decline`) .put(`/rentals/${rental.id}/decline`)
.set('Cookie', [`accessToken=${token}`]) .set("Cookie", [`accessToken=${token}`])
.send({ reason: 'Item not available for those dates' }) .send({ reason: "Item not available for those dates" })
.expect(200); .expect(200);
expect(response.body.status).toBe('declined'); expect(response.body.status).toBe("declined");
// Verify in database // Verify in database
await rental.reload(); await rental.reload();
expect(rental.status).toBe('declined'); expect(rental.status).toBe("declined");
expect(rental.declineReason).toBe('Item not available for those dates'); expect(rental.declineReason).toBe("Item not available for those dates");
}); });
it('should not allow declining already declined rental', async () => { it("should not allow declining already declined rental", async () => {
await rental.update({ status: 'declined' }); await rental.update({ status: "declined" });
const token = generateAuthToken(owner); const token = generateAuthToken(owner);
const response = await request(app) const response = await request(app)
.put(`/rentals/${rental.id}/decline`) .put(`/rentals/${rental.id}/decline`)
.set('Cookie', [`accessToken=${token}`]) .set("Cookie", [`accessToken=${token}`])
.send({ reason: 'Already declined' }) .send({ reason: "Already declined" })
.expect(400); .expect(400);
expect(response.body.error).toBeDefined(); expect(response.body.error).toBeDefined();
}); });
}); });
describe('POST /rentals/:id/cancel', () => { describe("POST /rentals/:id/cancel", () => {
let rental; let rental;
beforeEach(async () => { beforeEach(async () => {
rental = await createTestRental(item.id, renter.id, owner.id, { rental = await createTestRental(item.id, renter.id, owner.id, {
status: 'confirmed', status: "confirmed",
paymentStatus: 'paid', paymentStatus: "paid",
}); });
}); });
it('should allow renter to cancel their rental', async () => { it("should allow renter to cancel their rental", async () => {
const token = generateAuthToken(renter); const token = generateAuthToken(renter);
const response = await request(app) const response = await request(app)
.post(`/rentals/${rental.id}/cancel`) .post(`/rentals/${rental.id}/cancel`)
.set('Cookie', [`accessToken=${token}`]) .set("Cookie", [`accessToken=${token}`])
.send({ reason: 'Change of plans' }) .send({ reason: "Change of plans" })
.expect(200); .expect(200);
// Response format is { rental: {...}, refund: {...} } // Response format is { rental: {...}, refund: {...} }
expect(response.body.rental.status).toBe('cancelled'); expect(response.body.rental.status).toBe("cancelled");
expect(response.body.rental.cancelledBy).toBe('renter'); expect(response.body.rental.cancelledBy).toBe("renter");
// Verify in database // Verify in database
await rental.reload(); await rental.reload();
expect(rental.status).toBe('cancelled'); expect(rental.status).toBe("cancelled");
expect(rental.cancelledBy).toBe('renter'); expect(rental.cancelledBy).toBe("renter");
expect(rental.cancelledAt).toBeDefined(); expect(rental.cancelledAt).toBeDefined();
}); });
it('should allow owner to cancel their rental', async () => { it("should allow owner to cancel their rental", async () => {
const token = generateAuthToken(owner); const token = generateAuthToken(owner);
const response = await request(app) const response = await request(app)
.post(`/rentals/${rental.id}/cancel`) .post(`/rentals/${rental.id}/cancel`)
.set('Cookie', [`accessToken=${token}`]) .set("Cookie", [`accessToken=${token}`])
.send({ reason: 'Item broken' }) .send({ reason: "Item broken" })
.expect(200); .expect(200);
expect(response.body.rental.status).toBe('cancelled'); expect(response.body.rental.status).toBe("cancelled");
expect(response.body.rental.cancelledBy).toBe('owner'); expect(response.body.rental.cancelledBy).toBe("owner");
}); });
it('should not allow cancelling completed rental', async () => { it("should not allow cancelling completed rental", async () => {
await rental.update({ status: 'completed', paymentStatus: 'paid' }); await rental.update({ status: "completed", paymentStatus: "paid" });
const token = generateAuthToken(renter); const token = generateAuthToken(renter);
// RefundService throws error which becomes 500 via next(error) // RefundService throws error which becomes 500 via next(error)
const response = await request(app) const response = await request(app)
.post(`/rentals/${rental.id}/cancel`) .post(`/rentals/${rental.id}/cancel`)
.set('Cookie', [`accessToken=${token}`]) .set("Cookie", [`accessToken=${token}`])
.send({ reason: 'Too late' }); .send({ reason: "Too late" });
// Expect error (could be 400 or 500 depending on error middleware) // Expect error (could be 400 or 500 depending on error middleware)
expect(response.status).toBeGreaterThanOrEqual(400); expect(response.status).toBeGreaterThanOrEqual(400);
}); });
it('should not allow unauthorized user to cancel rental', async () => { it("should not allow unauthorized user to cancel rental", async () => {
const otherUser = await createTestUser({ email: 'other@example.com' }); const otherUser = await createTestUser({ email: "other@example.com" });
const token = generateAuthToken(otherUser); const token = generateAuthToken(otherUser);
const response = await request(app) const response = await request(app)
.post(`/rentals/${rental.id}/cancel`) .post(`/rentals/${rental.id}/cancel`)
.set('Cookie', [`accessToken=${token}`]) .set("Cookie", [`accessToken=${token}`])
.send({ reason: 'Not my rental' }); .send({ reason: "Not my rental" });
// Expect error (could be 403 or 500 depending on error middleware) // Expect error (could be 403 or 500 depending on error middleware)
expect(response.status).toBeGreaterThanOrEqual(400); expect(response.status).toBeGreaterThanOrEqual(400);
}); });
}); });
describe('GET /rentals/pending-requests-count', () => { describe("GET /rentals/pending-requests-count", () => {
it('should return count of pending rental requests for owner', async () => { it("should return count of pending rental requests for owner", async () => {
// Create multiple pending rentals // Create multiple pending rentals
await createTestRental(item.id, renter.id, owner.id, { status: 'pending' }); await createTestRental(item.id, renter.id, owner.id, {
await createTestRental(item.id, renter.id, owner.id, { status: 'pending' }); status: "pending",
await createTestRental(item.id, renter.id, owner.id, { status: 'confirmed' }); });
await createTestRental(item.id, renter.id, owner.id, {
status: "pending",
});
await createTestRental(item.id, renter.id, owner.id, {
status: "confirmed",
});
const token = generateAuthToken(owner); const token = generateAuthToken(owner);
const response = await request(app) const response = await request(app)
.get('/rentals/pending-requests-count') .get("/rentals/pending-requests-count")
.set('Cookie', [`accessToken=${token}`]) .set("Cookie", [`accessToken=${token}`])
.expect(200); .expect(200);
expect(response.body.count).toBe(2); expect(response.body.count).toBe(2);
}); });
it('should return 0 for user with no pending requests', async () => { it("should return 0 for user with no pending requests", async () => {
const token = generateAuthToken(renter); const token = generateAuthToken(renter);
const response = await request(app) const response = await request(app)
.get('/rentals/pending-requests-count') .get("/rentals/pending-requests-count")
.set('Cookie', [`accessToken=${token}`]) .set("Cookie", [`accessToken=${token}`])
.expect(200); .expect(200);
expect(response.body.count).toBe(0); expect(response.body.count).toBe(0);
}); });
}); });
describe('Rental Lifecycle', () => { describe("Rental Lifecycle", () => {
it('should complete full rental lifecycle: pending -> confirmed -> active -> completed', async () => { it("should complete full rental lifecycle: pending -> confirmed -> active -> completed", async () => {
// Create pending free rental (totalAmount: 0 is default) // Create pending free rental (totalAmount: 0 is default)
const rental = await createTestRental(item.id, renter.id, owner.id, { const rental = await createTestRental(item.id, renter.id, owner.id, {
status: 'pending', status: "pending",
startDateTime: new Date(Date.now() - 60 * 60 * 1000), // Started 1 hour ago startDateTime: new Date(Date.now() - 60 * 60 * 1000), // Started 1 hour ago
endDateTime: new Date(Date.now() + 60 * 60 * 1000), // Ends in 1 hour endDateTime: new Date(Date.now() + 60 * 60 * 1000), // Ends in 1 hour
}); });
@@ -405,52 +408,52 @@ describe('Rental Integration Tests', () => {
// Step 1: Owner confirms rental (works for free rentals) // Step 1: Owner confirms rental (works for free rentals)
let response = await request(app) let response = await request(app)
.put(`/rentals/${rental.id}/status`) .put(`/rentals/${rental.id}/status`)
.set('Cookie', [`accessToken=${ownerToken}`]) .set("Cookie", [`accessToken=${ownerToken}`])
.send({ status: 'confirmed' }) .send({ status: "confirmed" })
.expect(200); .expect(200);
expect(response.body.status).toBe('confirmed'); expect(response.body.status).toBe("confirmed");
// Step 2: Rental is now "active" because status is confirmed and startDateTime has passed // Step 2: Rental is now "active" because status is confirmed and startDateTime has passed.
// Note: "active" is a computed status, not stored. The stored status remains "confirmed" // "active" is a computed status, not stored. The stored status remains "confirmed"
await rental.reload(); await rental.reload();
expect(rental.status).toBe('confirmed'); // Stored status is still 'confirmed' expect(rental.status).toBe("confirmed"); // Stored status is still 'confirmed'
// isActive() returns true because status='confirmed' and startDateTime is in the past // isActive() returns true because status='confirmed' and startDateTime is in the past
// Step 3: Owner marks rental as completed (via mark-return with status='returned') // Step 3: Owner marks rental as completed (via mark-return with status='returned')
response = await request(app) response = await request(app)
.post(`/rentals/${rental.id}/mark-return`) .post(`/rentals/${rental.id}/mark-return`)
.set('Cookie', [`accessToken=${ownerToken}`]) .set("Cookie", [`accessToken=${ownerToken}`])
.send({ status: 'returned' }) .send({ status: "returned" })
.expect(200); .expect(200);
expect(response.body.rental.status).toBe('completed'); expect(response.body.rental.status).toBe("completed");
// Verify final state // Verify final state
await rental.reload(); await rental.reload();
expect(rental.status).toBe('completed'); expect(rental.status).toBe("completed");
}); });
}); });
describe('Review System', () => { describe("Review System", () => {
let completedRental; let completedRental;
beforeEach(async () => { beforeEach(async () => {
completedRental = await createTestRental(item.id, renter.id, owner.id, { completedRental = await createTestRental(item.id, renter.id, owner.id, {
status: 'completed', status: "completed",
paymentStatus: 'paid', paymentStatus: "paid",
}); });
}); });
it('should allow renter to review item', async () => { it("should allow renter to review item", async () => {
const token = generateAuthToken(renter); const token = generateAuthToken(renter);
const response = await request(app) const response = await request(app)
.post(`/rentals/${completedRental.id}/review-item`) .post(`/rentals/${completedRental.id}/review-item`)
.set('Cookie', [`accessToken=${token}`]) .set("Cookie", [`accessToken=${token}`])
.send({ .send({
rating: 5, rating: 5,
review: 'Great item, worked perfectly!', review: "Great item, worked perfectly!",
}) })
.expect(200); .expect(200);
@@ -459,19 +462,19 @@ describe('Rental Integration Tests', () => {
// Verify in database // Verify in database
await completedRental.reload(); await completedRental.reload();
expect(completedRental.itemRating).toBe(5); expect(completedRental.itemRating).toBe(5);
expect(completedRental.itemReview).toBe('Great item, worked perfectly!'); expect(completedRental.itemReview).toBe("Great item, worked perfectly!");
expect(completedRental.itemReviewSubmittedAt).toBeDefined(); expect(completedRental.itemReviewSubmittedAt).toBeDefined();
}); });
it('should allow owner to review renter', async () => { it("should allow owner to review renter", async () => {
const token = generateAuthToken(owner); const token = generateAuthToken(owner);
const response = await request(app) const response = await request(app)
.post(`/rentals/${completedRental.id}/review-renter`) .post(`/rentals/${completedRental.id}/review-renter`)
.set('Cookie', [`accessToken=${token}`]) .set("Cookie", [`accessToken=${token}`])
.send({ .send({
rating: 4, rating: 4,
review: 'Good renter, returned on time.', review: "Good renter, returned on time.",
}) })
.expect(200); .expect(200);
@@ -480,33 +483,40 @@ describe('Rental Integration Tests', () => {
// Verify in database // Verify in database
await completedRental.reload(); await completedRental.reload();
expect(completedRental.renterRating).toBe(4); expect(completedRental.renterRating).toBe(4);
expect(completedRental.renterReview).toBe('Good renter, returned on time.'); expect(completedRental.renterReview).toBe(
"Good renter, returned on time.",
);
}); });
it('should not allow review of non-completed rental', async () => { it("should not allow review of non-completed rental", async () => {
const pendingRental = await createTestRental(item.id, renter.id, owner.id, { const pendingRental = await createTestRental(
status: 'pending', item.id,
}); renter.id,
owner.id,
{
status: "pending",
},
);
const token = generateAuthToken(renter); const token = generateAuthToken(renter);
const response = await request(app) const response = await request(app)
.post(`/rentals/${pendingRental.id}/review-item`) .post(`/rentals/${pendingRental.id}/review-item`)
.set('Cookie', [`accessToken=${token}`]) .set("Cookie", [`accessToken=${token}`])
.send({ .send({
rating: 5, rating: 5,
review: 'Cannot review yet', review: "Cannot review yet",
}) })
.expect(400); .expect(400);
expect(response.body.error).toBeDefined(); expect(response.body.error).toBeDefined();
}); });
it('should not allow duplicate reviews', async () => { it("should not allow duplicate reviews", async () => {
// First review // First review
await completedRental.update({ await completedRental.update({
itemRating: 5, itemRating: 5,
itemReview: 'First review', itemReview: "First review",
itemReviewSubmittedAt: new Date(), itemReviewSubmittedAt: new Date(),
}); });
@@ -514,31 +524,39 @@ describe('Rental Integration Tests', () => {
const response = await request(app) const response = await request(app)
.post(`/rentals/${completedRental.id}/review-item`) .post(`/rentals/${completedRental.id}/review-item`)
.set('Cookie', [`accessToken=${token}`]) .set("Cookie", [`accessToken=${token}`])
.send({ .send({
rating: 3, rating: 3,
review: 'Second review attempt', review: "Second review attempt",
}) })
.expect(400); .expect(400);
expect(response.body.error).toContain('already'); expect(response.body.error).toContain("already");
}); });
}); });
describe('Database Constraints', () => { describe("Database Constraints", () => {
it('should not allow rental with invalid item ID', async () => { it("should not allow rental with invalid item ID", async () => {
await expect( await expect(
createTestRental('00000000-0000-0000-0000-000000000000', renter.id, owner.id) createTestRental(
"00000000-0000-0000-0000-000000000000",
renter.id,
owner.id,
),
).rejects.toThrow(); ).rejects.toThrow();
}); });
it('should not allow rental with invalid user IDs', async () => { it("should not allow rental with invalid user IDs", async () => {
await expect( await expect(
createTestRental(item.id, '00000000-0000-0000-0000-000000000000', owner.id) createTestRental(
item.id,
"00000000-0000-0000-0000-000000000000",
owner.id,
),
).rejects.toThrow(); ).rejects.toThrow();
}); });
it('should cascade delete rentals when item is deleted', async () => { it("should cascade delete rentals when item is deleted", async () => {
const rental = await createTestRental(item.id, renter.id, owner.id); const rental = await createTestRental(item.id, renter.id, owner.id);
// Delete the item // Delete the item
@@ -550,10 +568,10 @@ describe('Rental Integration Tests', () => {
}); });
}); });
describe('Concurrent Operations', () => { describe("Concurrent Operations", () => {
it('should handle concurrent status updates (last write wins)', async () => { it("should handle concurrent status updates (last write wins)", async () => {
const rental = await createTestRental(item.id, renter.id, owner.id, { const rental = await createTestRental(item.id, renter.id, owner.id, {
status: 'pending', status: "pending",
}); });
const ownerToken = generateAuthToken(owner); const ownerToken = generateAuthToken(owner);
@@ -562,22 +580,22 @@ describe('Rental Integration Tests', () => {
const [confirmResult, declineResult] = await Promise.allSettled([ const [confirmResult, declineResult] = await Promise.allSettled([
request(app) request(app)
.put(`/rentals/${rental.id}/status`) .put(`/rentals/${rental.id}/status`)
.set('Cookie', [`accessToken=${ownerToken}`]) .set("Cookie", [`accessToken=${ownerToken}`])
.send({ status: 'confirmed' }), .send({ status: "confirmed" }),
request(app) request(app)
.put(`/rentals/${rental.id}/decline`) .put(`/rentals/${rental.id}/decline`)
.set('Cookie', [`accessToken=${ownerToken}`]) .set("Cookie", [`accessToken=${ownerToken}`])
.send({ reason: 'Declining instead' }), .send({ reason: "Declining instead" }),
]); ]);
// Both requests may succeed (no optimistic locking) // Both requests may succeed (no optimistic locking)
// Verify rental ends up in a valid state // Verify rental ends up in a valid state
await rental.reload(); await rental.reload();
expect(['confirmed', 'declined']).toContain(rental.status); expect(["confirmed", "declined"]).toContain(rental.status);
// At least one should have succeeded // At least one should have succeeded
const successes = [confirmResult, declineResult].filter( const successes = [confirmResult, declineResult].filter(
r => r.status === 'fulfilled' && r.value.status === 200 (r) => r.status === "fulfilled" && r.value.status === 200,
); );
expect(successes.length).toBeGreaterThanOrEqual(1); expect(successes.length).toBeGreaterThanOrEqual(1);
}); });

View File

@@ -1,14 +1,14 @@
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = "test";
process.env.JWT_SECRET = 'test-secret'; process.env.JWT_SECRET = "test-secret";
process.env.DATABASE_URL = 'postgresql://test'; process.env.DATABASE_URL = "db://test";
process.env.GOOGLE_MAPS_API_KEY = 'test-key'; process.env.GOOGLE_MAPS_API_KEY = "test-key";
process.env.STRIPE_SECRET_KEY = 'sk_test_key'; process.env.STRIPE_SECRET_KEY = "sk_test_key";
process.env.CSRF_SECRET = 'test-csrf-secret-that-is-at-least-32-chars-long'; process.env.CSRF_SECRET = "test-csrf-secret-that-is-at-least-32-chars-long";
// Silence console // Silence console
global.console = { global.console = {
...console, ...console,
log: jest.fn(), log: jest.fn(),
error: jest.fn(), error: jest.fn(),
warn: jest.fn() warn: jest.fn(),
}; };

View File

@@ -1,21 +1,21 @@
// Set CSRF_SECRET before requiring the middleware // Set CSRF_SECRET before requiring the middleware
process.env.CSRF_SECRET = 'test-csrf-secret-that-is-at-least-32-chars-long'; process.env.CSRF_SECRET = "test-csrf-secret";
const mockTokensInstance = { const mockTokensInstance = {
secretSync: jest.fn().mockReturnValue(process.env.CSRF_SECRET), secretSync: jest.fn().mockReturnValue(process.env.CSRF_SECRET),
create: jest.fn().mockReturnValue('mock-token-123'), create: jest.fn().mockReturnValue("mock-token-123"),
verify: jest.fn().mockReturnValue(true) verify: jest.fn().mockReturnValue(true),
}; };
jest.mock('csrf', () => { jest.mock("csrf", () => {
return jest.fn().mockImplementation(() => mockTokensInstance); return jest.fn().mockImplementation(() => mockTokensInstance);
}); });
jest.mock('cookie-parser', () => { jest.mock("cookie-parser", () => {
return jest.fn().mockReturnValue((req, res, next) => next()); return jest.fn().mockReturnValue((req, res, next) => next());
}); });
jest.mock('../../../utils/logger', () => ({ jest.mock("../../../utils/logger", () => ({
error: jest.fn(), error: jest.fn(),
info: jest.fn(), info: jest.fn(),
warn: jest.fn(), warn: jest.fn(),
@@ -26,18 +26,22 @@ jest.mock('../../../utils/logger', () => ({
})), })),
})); }));
const { csrfProtection, generateCSRFToken, getCSRFToken } = require('../../../middleware/csrf'); const {
csrfProtection,
generateCSRFToken,
getCSRFToken,
} = require("../../../middleware/csrf");
describe('CSRF Middleware', () => { describe("CSRF Middleware", () => {
let req, res, next; let req, res, next;
beforeEach(() => { beforeEach(() => {
req = { req = {
method: 'POST', method: "POST",
headers: {}, headers: {},
body: {}, body: {},
query: {}, query: {},
cookies: {} cookies: {},
}; };
res = { res = {
status: jest.fn().mockReturnThis(), status: jest.fn().mockReturnThis(),
@@ -45,16 +49,16 @@ describe('CSRF Middleware', () => {
send: jest.fn(), send: jest.fn(),
cookie: jest.fn(), cookie: jest.fn(),
set: jest.fn(), set: jest.fn(),
locals: {} locals: {},
}; };
next = jest.fn(); next = jest.fn();
jest.clearAllMocks(); jest.clearAllMocks();
}); });
describe('csrfProtection', () => { describe("csrfProtection", () => {
describe('Safe methods', () => { describe("Safe methods", () => {
it('should skip CSRF protection for GET requests', () => { it("should skip CSRF protection for GET requests", () => {
req.method = 'GET'; req.method = "GET";
csrfProtection(req, res, next); csrfProtection(req, res, next);
@@ -62,8 +66,8 @@ describe('CSRF Middleware', () => {
expect(res.status).not.toHaveBeenCalled(); expect(res.status).not.toHaveBeenCalled();
}); });
it('should skip CSRF protection for HEAD requests', () => { it("should skip CSRF protection for HEAD requests", () => {
req.method = 'HEAD'; req.method = "HEAD";
csrfProtection(req, res, next); csrfProtection(req, res, next);
@@ -71,8 +75,8 @@ describe('CSRF Middleware', () => {
expect(res.status).not.toHaveBeenCalled(); expect(res.status).not.toHaveBeenCalled();
}); });
it('should skip CSRF protection for OPTIONS requests', () => { it("should skip CSRF protection for OPTIONS requests", () => {
req.method = 'OPTIONS'; req.method = "OPTIONS";
csrfProtection(req, res, next); csrfProtection(req, res, next);
@@ -81,389 +85,427 @@ describe('CSRF Middleware', () => {
}); });
}); });
describe('Token validation', () => { describe("Token validation", () => {
beforeEach(() => { beforeEach(() => {
req.cookies = { 'csrf-token': 'mock-token-123' }; req.cookies = { "csrf-token": "mock-token-123" };
}); });
it('should validate token from x-csrf-token header', () => { it("should validate token from x-csrf-token header", () => {
req.headers['x-csrf-token'] = 'mock-token-123'; req.headers["x-csrf-token"] = "mock-token-123";
csrfProtection(req, res, next); csrfProtection(req, res, next);
expect(mockTokensInstance.verify).toHaveBeenCalledWith(process.env.CSRF_SECRET, 'mock-token-123'); expect(mockTokensInstance.verify).toHaveBeenCalledWith(
process.env.CSRF_SECRET,
"mock-token-123",
);
expect(next).toHaveBeenCalled(); expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled(); expect(res.status).not.toHaveBeenCalled();
}); });
it('should validate token from request body', () => { it("should validate token from request body", () => {
req.body.csrfToken = 'mock-token-123'; req.body.csrfToken = "mock-token-123";
csrfProtection(req, res, next); csrfProtection(req, res, next);
expect(mockTokensInstance.verify).toHaveBeenCalledWith(process.env.CSRF_SECRET, 'mock-token-123'); expect(mockTokensInstance.verify).toHaveBeenCalledWith(
process.env.CSRF_SECRET,
"mock-token-123",
);
expect(next).toHaveBeenCalled(); expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled(); expect(res.status).not.toHaveBeenCalled();
}); });
it('should prefer header token over body token', () => { it("should prefer header token over body token", () => {
req.headers['x-csrf-token'] = 'mock-token-123'; req.headers["x-csrf-token"] = "mock-token-123";
req.body.csrfToken = 'different-token'; req.body.csrfToken = "different-token";
csrfProtection(req, res, next); csrfProtection(req, res, next);
expect(mockTokensInstance.verify).toHaveBeenCalledWith(process.env.CSRF_SECRET, 'mock-token-123'); expect(mockTokensInstance.verify).toHaveBeenCalledWith(
process.env.CSRF_SECRET,
"mock-token-123",
);
expect(next).toHaveBeenCalled(); expect(next).toHaveBeenCalled();
}); });
}); });
describe('Missing tokens', () => { describe("Missing tokens", () => {
it('should return 403 when no token provided', () => { it("should return 403 when no token provided", () => {
req.cookies = { 'csrf-token': 'mock-token-123' }; req.cookies = { "csrf-token": "mock-token-123" };
csrfProtection(req, res, next); csrfProtection(req, res, next);
expect(res.status).toHaveBeenCalledWith(403); expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ expect(res.json).toHaveBeenCalledWith({
error: 'Invalid CSRF token', error: "Invalid CSRF token",
code: 'CSRF_TOKEN_MISMATCH' code: "CSRF_TOKEN_MISMATCH",
}); });
expect(next).not.toHaveBeenCalled(); expect(next).not.toHaveBeenCalled();
}); });
it('should return 403 when no cookie token provided', () => { it("should return 403 when no cookie token provided", () => {
req.headers['x-csrf-token'] = 'mock-token-123'; req.headers["x-csrf-token"] = "mock-token-123";
req.cookies = {}; req.cookies = {};
csrfProtection(req, res, next); csrfProtection(req, res, next);
expect(res.status).toHaveBeenCalledWith(403); expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ expect(res.json).toHaveBeenCalledWith({
error: 'Invalid CSRF token', error: "Invalid CSRF token",
code: 'CSRF_TOKEN_MISMATCH' code: "CSRF_TOKEN_MISMATCH",
}); });
expect(next).not.toHaveBeenCalled(); expect(next).not.toHaveBeenCalled();
}); });
it('should return 403 when cookies object is missing', () => { it("should return 403 when cookies object is missing", () => {
req.headers['x-csrf-token'] = 'mock-token-123'; req.headers["x-csrf-token"] = "mock-token-123";
req.cookies = undefined; req.cookies = undefined;
csrfProtection(req, res, next); csrfProtection(req, res, next);
expect(res.status).toHaveBeenCalledWith(403); expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ expect(res.json).toHaveBeenCalledWith({
error: 'Invalid CSRF token', error: "Invalid CSRF token",
code: 'CSRF_TOKEN_MISMATCH' code: "CSRF_TOKEN_MISMATCH",
}); });
expect(next).not.toHaveBeenCalled(); expect(next).not.toHaveBeenCalled();
}); });
it('should return 403 when both tokens are missing', () => { it("should return 403 when both tokens are missing", () => {
req.cookies = {}; req.cookies = {};
csrfProtection(req, res, next); csrfProtection(req, res, next);
expect(res.status).toHaveBeenCalledWith(403); expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ expect(res.json).toHaveBeenCalledWith({
error: 'Invalid CSRF token', error: "Invalid CSRF token",
code: 'CSRF_TOKEN_MISMATCH' code: "CSRF_TOKEN_MISMATCH",
}); });
expect(next).not.toHaveBeenCalled(); expect(next).not.toHaveBeenCalled();
}); });
}); });
describe('Token mismatch', () => { describe("Token mismatch", () => {
it('should return 403 when tokens do not match', () => { it("should return 403 when tokens do not match", () => {
req.headers['x-csrf-token'] = 'token-from-header'; req.headers["x-csrf-token"] = "token-from-header";
req.cookies = { 'csrf-token': 'token-from-cookie' }; req.cookies = { "csrf-token": "token-from-cookie" };
csrfProtection(req, res, next); csrfProtection(req, res, next);
expect(res.status).toHaveBeenCalledWith(403); expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ expect(res.json).toHaveBeenCalledWith({
error: 'Invalid CSRF token', error: "Invalid CSRF token",
code: 'CSRF_TOKEN_MISMATCH' code: "CSRF_TOKEN_MISMATCH",
}); });
expect(next).not.toHaveBeenCalled(); expect(next).not.toHaveBeenCalled();
}); });
it('should return 403 when header token is empty but cookie exists', () => { it("should return 403 when header token is empty but cookie exists", () => {
req.headers['x-csrf-token'] = ''; req.headers["x-csrf-token"] = "";
req.cookies = { 'csrf-token': 'mock-token-123' }; req.cookies = { "csrf-token": "mock-token-123" };
csrfProtection(req, res, next); csrfProtection(req, res, next);
expect(res.status).toHaveBeenCalledWith(403); expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ expect(res.json).toHaveBeenCalledWith({
error: 'Invalid CSRF token', error: "Invalid CSRF token",
code: 'CSRF_TOKEN_MISMATCH' code: "CSRF_TOKEN_MISMATCH",
}); });
expect(next).not.toHaveBeenCalled(); expect(next).not.toHaveBeenCalled();
}); });
it('should return 403 when cookie token is empty but header exists', () => { it("should return 403 when cookie token is empty but header exists", () => {
req.headers['x-csrf-token'] = 'mock-token-123'; req.headers["x-csrf-token"] = "mock-token-123";
req.cookies = { 'csrf-token': '' }; req.cookies = { "csrf-token": "" };
csrfProtection(req, res, next); csrfProtection(req, res, next);
expect(res.status).toHaveBeenCalledWith(403); expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ expect(res.json).toHaveBeenCalledWith({
error: 'Invalid CSRF token', error: "Invalid CSRF token",
code: 'CSRF_TOKEN_MISMATCH' code: "CSRF_TOKEN_MISMATCH",
}); });
expect(next).not.toHaveBeenCalled(); expect(next).not.toHaveBeenCalled();
}); });
}); });
describe('Token verification', () => { describe("Token verification", () => {
beforeEach(() => { beforeEach(() => {
req.headers['x-csrf-token'] = 'mock-token-123'; req.headers["x-csrf-token"] = "mock-token-123";
req.cookies = { 'csrf-token': 'mock-token-123' }; req.cookies = { "csrf-token": "mock-token-123" };
}); });
it('should return 403 when token verification fails', () => { it("should return 403 when token verification fails", () => {
mockTokensInstance.verify.mockReturnValue(false); mockTokensInstance.verify.mockReturnValue(false);
csrfProtection(req, res, next); csrfProtection(req, res, next);
expect(mockTokensInstance.verify).toHaveBeenCalledWith(process.env.CSRF_SECRET, 'mock-token-123'); expect(mockTokensInstance.verify).toHaveBeenCalledWith(
process.env.CSRF_SECRET,
"mock-token-123",
);
expect(res.status).toHaveBeenCalledWith(403); expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ expect(res.json).toHaveBeenCalledWith({
error: 'Invalid CSRF token', error: "Invalid CSRF token",
code: 'CSRF_TOKEN_INVALID' code: "CSRF_TOKEN_INVALID",
}); });
expect(next).not.toHaveBeenCalled(); expect(next).not.toHaveBeenCalled();
}); });
it('should call next when token verification succeeds', () => { it("should call next when token verification succeeds", () => {
mockTokensInstance.verify.mockReturnValue(true); mockTokensInstance.verify.mockReturnValue(true);
csrfProtection(req, res, next); csrfProtection(req, res, next);
expect(mockTokensInstance.verify).toHaveBeenCalledWith(process.env.CSRF_SECRET, 'mock-token-123'); expect(mockTokensInstance.verify).toHaveBeenCalledWith(
process.env.CSRF_SECRET,
"mock-token-123",
);
expect(next).toHaveBeenCalled(); expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled(); expect(res.status).not.toHaveBeenCalled();
}); });
}); });
describe('Edge cases', () => { describe("Edge cases", () => {
it('should handle case-insensitive HTTP methods', () => { it("should handle case-insensitive HTTP methods", () => {
req.method = 'post'; req.method = "post";
req.headers['x-csrf-token'] = 'mock-token-123'; req.headers["x-csrf-token"] = "mock-token-123";
req.cookies = { 'csrf-token': 'mock-token-123' }; req.cookies = { "csrf-token": "mock-token-123" };
csrfProtection(req, res, next); csrfProtection(req, res, next);
expect(mockTokensInstance.verify).toHaveBeenCalledWith(process.env.CSRF_SECRET, 'mock-token-123'); expect(mockTokensInstance.verify).toHaveBeenCalledWith(
process.env.CSRF_SECRET,
"mock-token-123",
);
expect(next).toHaveBeenCalled(); expect(next).toHaveBeenCalled();
}); });
it('should handle PUT requests', () => { it("should handle PUT requests", () => {
req.method = 'PUT'; req.method = "PUT";
req.headers['x-csrf-token'] = 'mock-token-123'; req.headers["x-csrf-token"] = "mock-token-123";
req.cookies = { 'csrf-token': 'mock-token-123' }; req.cookies = { "csrf-token": "mock-token-123" };
csrfProtection(req, res, next); csrfProtection(req, res, next);
expect(mockTokensInstance.verify).toHaveBeenCalledWith(process.env.CSRF_SECRET, 'mock-token-123'); expect(mockTokensInstance.verify).toHaveBeenCalledWith(
process.env.CSRF_SECRET,
"mock-token-123",
);
expect(next).toHaveBeenCalled(); expect(next).toHaveBeenCalled();
}); });
it('should handle DELETE requests', () => { it("should handle DELETE requests", () => {
req.method = 'DELETE'; req.method = "DELETE";
req.headers['x-csrf-token'] = 'mock-token-123'; req.headers["x-csrf-token"] = "mock-token-123";
req.cookies = { 'csrf-token': 'mock-token-123' }; req.cookies = { "csrf-token": "mock-token-123" };
csrfProtection(req, res, next); csrfProtection(req, res, next);
expect(mockTokensInstance.verify).toHaveBeenCalledWith(process.env.CSRF_SECRET, 'mock-token-123'); expect(mockTokensInstance.verify).toHaveBeenCalledWith(
process.env.CSRF_SECRET,
"mock-token-123",
);
expect(next).toHaveBeenCalled(); expect(next).toHaveBeenCalled();
}); });
it('should handle PATCH requests', () => { it("should handle PATCH requests", () => {
req.method = 'PATCH'; req.method = "PATCH";
req.headers['x-csrf-token'] = 'mock-token-123'; req.headers["x-csrf-token"] = "mock-token-123";
req.cookies = { 'csrf-token': 'mock-token-123' }; req.cookies = { "csrf-token": "mock-token-123" };
csrfProtection(req, res, next); csrfProtection(req, res, next);
expect(mockTokensInstance.verify).toHaveBeenCalledWith(process.env.CSRF_SECRET, 'mock-token-123'); expect(mockTokensInstance.verify).toHaveBeenCalledWith(
process.env.CSRF_SECRET,
"mock-token-123",
);
expect(next).toHaveBeenCalled(); expect(next).toHaveBeenCalled();
}); });
}); });
}); });
describe('generateCSRFToken', () => { describe("generateCSRFToken", () => {
it('should generate token and set cookie with proper options', () => { it("should generate token and set cookie with proper options", () => {
process.env.NODE_ENV = 'production'; process.env.NODE_ENV = "production";
generateCSRFToken(req, res, next); generateCSRFToken(req, res, next);
expect(mockTokensInstance.create).toHaveBeenCalledWith(process.env.CSRF_SECRET); expect(mockTokensInstance.create).toHaveBeenCalledWith(
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', { process.env.CSRF_SECRET,
);
expect(res.cookie).toHaveBeenCalledWith("csrf-token", "mock-token-123", {
httpOnly: true, httpOnly: true,
secure: true, secure: true,
sameSite: 'strict', sameSite: "strict",
maxAge: 60 * 60 * 1000 maxAge: 60 * 60 * 1000,
}); });
expect(next).toHaveBeenCalled(); expect(next).toHaveBeenCalled();
}); });
it('should set secure flag to false in dev environment', () => { it("should set secure flag to false in dev environment", () => {
process.env.NODE_ENV = 'dev'; process.env.NODE_ENV = "dev";
generateCSRFToken(req, res, next); generateCSRFToken(req, res, next);
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', { expect(res.cookie).toHaveBeenCalledWith("csrf-token", "mock-token-123", {
httpOnly: true, httpOnly: true,
secure: false, secure: false,
sameSite: 'strict', sameSite: "strict",
maxAge: 60 * 60 * 1000 maxAge: 60 * 60 * 1000,
}); });
}); });
it('should set secure flag to true in non-dev environment', () => { it("should set secure flag to true in non-dev environment", () => {
process.env.NODE_ENV = 'production'; process.env.NODE_ENV = "production";
generateCSRFToken(req, res, next); generateCSRFToken(req, res, next);
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', { expect(res.cookie).toHaveBeenCalledWith("csrf-token", "mock-token-123", {
httpOnly: true, httpOnly: true,
secure: true, secure: true,
sameSite: 'strict', sameSite: "strict",
maxAge: 60 * 60 * 1000 maxAge: 60 * 60 * 1000,
}); });
}); });
it('should set token in response header', () => { it("should set token in response header", () => {
generateCSRFToken(req, res, next); generateCSRFToken(req, res, next);
expect(res.set).toHaveBeenCalledWith('X-CSRF-Token', 'mock-token-123'); expect(res.set).toHaveBeenCalledWith("X-CSRF-Token", "mock-token-123");
}); });
it('should make token available in res.locals', () => { it("should make token available in res.locals", () => {
generateCSRFToken(req, res, next); generateCSRFToken(req, res, next);
expect(res.locals.csrfToken).toBe('mock-token-123'); expect(res.locals.csrfToken).toBe("mock-token-123");
}); });
it('should call next after setting up token', () => { it("should call next after setting up token", () => {
generateCSRFToken(req, res, next); generateCSRFToken(req, res, next);
expect(next).toHaveBeenCalled(); expect(next).toHaveBeenCalled();
}); });
it('should handle test environment', () => { it("should handle test environment", () => {
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = "test";
generateCSRFToken(req, res, next); generateCSRFToken(req, res, next);
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', { expect(res.cookie).toHaveBeenCalledWith("csrf-token", "mock-token-123", {
httpOnly: true, httpOnly: true,
secure: false, secure: false,
sameSite: 'strict', sameSite: "strict",
maxAge: 60 * 60 * 1000 maxAge: 60 * 60 * 1000,
}); });
}); });
it('should handle undefined NODE_ENV', () => { it("should handle undefined NODE_ENV", () => {
delete process.env.NODE_ENV; delete process.env.NODE_ENV;
generateCSRFToken(req, res, next); generateCSRFToken(req, res, next);
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', { expect(res.cookie).toHaveBeenCalledWith("csrf-token", "mock-token-123", {
httpOnly: true, httpOnly: true,
secure: false, secure: false,
sameSite: 'strict', sameSite: "strict",
maxAge: 60 * 60 * 1000 maxAge: 60 * 60 * 1000,
}); });
}); });
}); });
describe('getCSRFToken', () => { describe("getCSRFToken", () => {
it('should generate token and return it in response', () => { it("should generate token and return it in response", () => {
process.env.NODE_ENV = 'production'; process.env.NODE_ENV = "production";
getCSRFToken(req, res); getCSRFToken(req, res);
expect(mockTokensInstance.create).toHaveBeenCalledWith(process.env.CSRF_SECRET); expect(mockTokensInstance.create).toHaveBeenCalledWith(
process.env.CSRF_SECRET,
);
expect(res.status).toHaveBeenCalledWith(204); expect(res.status).toHaveBeenCalledWith(204);
expect(res.send).toHaveBeenCalled(); expect(res.send).toHaveBeenCalled();
}); });
it('should set token in cookie with proper options', () => { it("should set token in cookie with proper options", () => {
process.env.NODE_ENV = 'production'; process.env.NODE_ENV = "production";
getCSRFToken(req, res); getCSRFToken(req, res);
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', { expect(res.cookie).toHaveBeenCalledWith("csrf-token", "mock-token-123", {
httpOnly: true, httpOnly: true,
secure: true, secure: true,
sameSite: 'strict', sameSite: "strict",
maxAge: 60 * 60 * 1000 maxAge: 60 * 60 * 1000,
}); });
}); });
it('should set secure flag to false in dev environment', () => { it("should set secure flag to false in dev environment", () => {
process.env.NODE_ENV = 'dev'; process.env.NODE_ENV = "dev";
getCSRFToken(req, res); getCSRFToken(req, res);
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', { expect(res.cookie).toHaveBeenCalledWith("csrf-token", "mock-token-123", {
httpOnly: true, httpOnly: true,
secure: false, secure: false,
sameSite: 'strict', sameSite: "strict",
maxAge: 60 * 60 * 1000 maxAge: 60 * 60 * 1000,
}); });
}); });
it('should set secure flag to true in production environment', () => { it("should set secure flag to true in production environment", () => {
process.env.NODE_ENV = 'production'; process.env.NODE_ENV = "production";
getCSRFToken(req, res); getCSRFToken(req, res);
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', { expect(res.cookie).toHaveBeenCalledWith("csrf-token", "mock-token-123", {
httpOnly: true, httpOnly: true,
secure: true, secure: true,
sameSite: 'strict', sameSite: "strict",
maxAge: 60 * 60 * 1000 maxAge: 60 * 60 * 1000,
}); });
}); });
it('should handle test environment', () => { it("should handle test environment", () => {
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = "test";
getCSRFToken(req, res); getCSRFToken(req, res);
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', { expect(res.cookie).toHaveBeenCalledWith("csrf-token", "mock-token-123", {
httpOnly: true, httpOnly: true,
secure: false, secure: false,
sameSite: 'strict', sameSite: "strict",
maxAge: 60 * 60 * 1000 maxAge: 60 * 60 * 1000,
}); });
}); });
it('should generate new token each time', () => { it("should generate new token each time", () => {
mockTokensInstance.create mockTokensInstance.create
.mockReturnValueOnce('token-1') .mockReturnValueOnce("token-1")
.mockReturnValueOnce('token-2'); .mockReturnValueOnce("token-2");
getCSRFToken(req, res); getCSRFToken(req, res);
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'token-1', expect.any(Object)); expect(res.cookie).toHaveBeenCalledWith(
expect(res.set).toHaveBeenCalledWith('X-CSRF-Token', 'token-1'); "csrf-token",
"token-1",
expect.any(Object),
);
expect(res.set).toHaveBeenCalledWith("X-CSRF-Token", "token-1");
jest.clearAllMocks(); jest.clearAllMocks();
getCSRFToken(req, res); getCSRFToken(req, res);
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'token-2', expect.any(Object)); expect(res.cookie).toHaveBeenCalledWith(
expect(res.set).toHaveBeenCalledWith('X-CSRF-Token', 'token-2'); "csrf-token",
"token-2",
expect.any(Object),
);
expect(res.set).toHaveBeenCalledWith("X-CSRF-Token", "token-2");
}); });
}); });
describe('Integration scenarios', () => { describe("Integration scenarios", () => {
it('should handle complete CSRF flow', () => { it("should handle complete CSRF flow", () => {
// First, generate a token // First, generate a token
generateCSRFToken(req, res, next); generateCSRFToken(req, res, next);
const generatedToken = res.locals.csrfToken; const generatedToken = res.locals.csrfToken;
@@ -472,9 +514,9 @@ describe('CSRF Middleware', () => {
jest.clearAllMocks(); jest.clearAllMocks();
// Now test protection with the generated token // Now test protection with the generated token
req.method = 'POST'; req.method = "POST";
req.headers['x-csrf-token'] = generatedToken; req.headers["x-csrf-token"] = generatedToken;
req.cookies = { 'csrf-token': generatedToken }; req.cookies = { "csrf-token": generatedToken };
csrfProtection(req, res, next); csrfProtection(req, res, next);
@@ -482,16 +524,16 @@ describe('CSRF Middleware', () => {
expect(res.status).not.toHaveBeenCalled(); expect(res.status).not.toHaveBeenCalled();
}); });
it('should handle token generation endpoint flow', () => { it("should handle token generation endpoint flow", () => {
getCSRFToken(req, res); getCSRFToken(req, res);
const cookieCall = res.cookie.mock.calls[0]; const cookieCall = res.cookie.mock.calls[0];
const headerCall = res.set.mock.calls[0]; const headerCall = res.set.mock.calls[0];
expect(cookieCall[0]).toBe('csrf-token'); expect(cookieCall[0]).toBe("csrf-token");
expect(cookieCall[1]).toBe('mock-token-123'); expect(cookieCall[1]).toBe("mock-token-123");
expect(headerCall[0]).toBe('X-CSRF-Token'); expect(headerCall[0]).toBe("X-CSRF-Token");
expect(headerCall[1]).toBe('mock-token-123'); expect(headerCall[1]).toBe("mock-token-123");
expect(res.status).toHaveBeenCalledWith(204); expect(res.status).toHaveBeenCalledWith(204);
expect(res.send).toHaveBeenCalled(); expect(res.send).toHaveBeenCalled();
}); });

View File

@@ -98,7 +98,7 @@ describe("Stripe Routes", () => {
StripeService.getCheckoutSession.mockResolvedValue(mockSession); StripeService.getCheckoutSession.mockResolvedValue(mockSession);
const response = await request(app).get( const response = await request(app).get(
"/stripe/checkout-session/cs_123456789" "/stripe/checkout-session/cs_123456789",
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@@ -116,7 +116,7 @@ describe("Stripe Routes", () => {
}); });
expect(StripeService.getCheckoutSession).toHaveBeenCalledWith( expect(StripeService.getCheckoutSession).toHaveBeenCalledWith(
"cs_123456789" "cs_123456789",
); );
}); });
@@ -132,7 +132,7 @@ describe("Stripe Routes", () => {
StripeService.getCheckoutSession.mockResolvedValue(mockSession); StripeService.getCheckoutSession.mockResolvedValue(mockSession);
const response = await request(app).get( const response = await request(app).get(
"/stripe/checkout-session/cs_123456789" "/stripe/checkout-session/cs_123456789",
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
@@ -150,7 +150,7 @@ describe("Stripe Routes", () => {
StripeService.getCheckoutSession.mockRejectedValue(error); StripeService.getCheckoutSession.mockRejectedValue(error);
const response = await request(app).get( const response = await request(app).get(
"/stripe/checkout-session/invalid_session" "/stripe/checkout-session/invalid_session",
); );
expect(response.status).toBe(500); expect(response.status).toBe(500);
@@ -261,7 +261,6 @@ describe("Stripe Routes", () => {
expect(response.status).toBe(500); expect(response.status).toBe(500);
expect(response.body).toEqual({ error: "Invalid email address" }); expect(response.body).toEqual({ error: "Invalid email address" });
// Note: route uses logger instead of console.error
}); });
it("should handle database update errors", async () => { it("should handle database update errors", async () => {
@@ -313,7 +312,7 @@ describe("Stripe Routes", () => {
expect(StripeService.createAccountLink).toHaveBeenCalledWith( expect(StripeService.createAccountLink).toHaveBeenCalledWith(
"acct_123456789", "acct_123456789",
"http://localhost:3000/refresh", "http://localhost:3000/refresh",
"http://localhost:3000/return" "http://localhost:3000/return",
); );
}); });
@@ -413,7 +412,6 @@ describe("Stripe Routes", () => {
expect(response.status).toBe(500); expect(response.status).toBe(500);
expect(response.body).toEqual({ error: "Account not found" }); expect(response.body).toEqual({ error: "Account not found" });
// Note: route uses logger instead of console.error
}); });
}); });
@@ -466,7 +464,7 @@ describe("Stripe Routes", () => {
}); });
expect(StripeService.getAccountStatus).toHaveBeenCalledWith( expect(StripeService.getAccountStatus).toHaveBeenCalledWith(
"acct_123456789" "acct_123456789",
); );
}); });
@@ -516,7 +514,6 @@ describe("Stripe Routes", () => {
expect(response.status).toBe(500); expect(response.status).toBe(500);
expect(response.body).toEqual({ error: "Account not found" }); expect(response.body).toEqual({ error: "Account not found" });
// Note: route uses logger instead of console.error
}); });
}); });
@@ -682,7 +679,6 @@ describe("Stripe Routes", () => {
expect(response.status).toBe(500); expect(response.status).toBe(500);
expect(response.body).toEqual({ error: "Invalid email address" }); expect(response.body).toEqual({ error: "Invalid email address" });
// Note: route uses logger.withRequestId().error() instead of console.error
}); });
it("should handle database update errors", async () => { it("should handle database update errors", async () => {
@@ -785,7 +781,7 @@ describe("Stripe Routes", () => {
StripeService.getCheckoutSession.mockRejectedValue(error); StripeService.getCheckoutSession.mockRejectedValue(error);
const response = await request(app).get( const response = await request(app).get(
`/stripe/checkout-session/${longSessionId}` `/stripe/checkout-session/${longSessionId}`,
); );
expect(response.status).toBe(500); expect(response.status).toBe(500);

View File

@@ -1,10 +1,10 @@
const crypto = require('crypto'); const crypto = require("crypto");
const bcrypt = require('bcryptjs'); const bcrypt = require("bcryptjs");
const { authenticator } = require('otplib'); const { authenticator } = require("otplib");
const QRCode = require('qrcode'); const QRCode = require("qrcode");
// Mock dependencies // Mock dependencies
jest.mock('otplib', () => ({ jest.mock("otplib", () => ({
authenticator: { authenticator: {
generateSecret: jest.fn(), generateSecret: jest.fn(),
keyuri: jest.fn(), keyuri: jest.fn(),
@@ -12,34 +12,34 @@ jest.mock('otplib', () => ({
}, },
})); }));
jest.mock('qrcode', () => ({ jest.mock("qrcode", () => ({
toDataURL: jest.fn(), toDataURL: jest.fn(),
})); }));
jest.mock('bcryptjs', () => ({ jest.mock("bcryptjs", () => ({
hash: jest.fn(), hash: jest.fn(),
compare: jest.fn(), compare: jest.fn(),
})); }));
jest.mock('../../../utils/logger', () => ({ jest.mock("../../../utils/logger", () => ({
info: jest.fn(), info: jest.fn(),
error: jest.fn(), error: jest.fn(),
warn: jest.fn(), warn: jest.fn(),
})); }));
const TwoFactorService = require('../../../services/TwoFactorService'); const TwoFactorService = require("../../../services/TwoFactorService");
describe('TwoFactorService', () => { describe("TwoFactorService", () => {
const originalEnv = process.env; const originalEnv = process.env;
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
process.env = { process.env = {
...originalEnv, ...originalEnv,
TOTP_ENCRYPTION_KEY: 'a'.repeat(64), // 64 hex chars = 32 bytes TOTP_ENCRYPTION_KEY: "a".repeat(64),
TOTP_ISSUER: 'TestApp', TOTP_ISSUER: "TestApp",
TWO_FACTOR_EMAIL_OTP_EXPIRY_MINUTES: '10', TWO_FACTOR_EMAIL_OTP_EXPIRY_MINUTES: "10",
TWO_FACTOR_STEP_UP_VALIDITY_MINUTES: '5', TWO_FACTOR_STEP_UP_VALIDITY_MINUTES: "5",
}; };
}); });
@@ -47,91 +47,117 @@ describe('TwoFactorService', () => {
process.env = originalEnv; process.env = originalEnv;
}); });
describe('generateTotpSecret', () => { describe("generateTotpSecret", () => {
it('should generate TOTP secret with QR code', async () => { it("should generate TOTP secret with QR code", async () => {
authenticator.generateSecret.mockReturnValue('test-secret'); authenticator.generateSecret.mockReturnValue("test-secret");
authenticator.keyuri.mockReturnValue('otpauth://totp/VillageShare:test@example.com?secret=test-secret'); authenticator.keyuri.mockReturnValue(
QRCode.toDataURL.mockResolvedValue('data:image/png;base64,qrcode'); "otpauth://totp/VillageShare:test@example.com?secret=test-secret",
);
QRCode.toDataURL.mockResolvedValue("data:image/png;base64,qrcode");
const result = await TwoFactorService.generateTotpSecret('test@example.com'); const result =
await TwoFactorService.generateTotpSecret("test@example.com");
expect(result.qrCodeDataUrl).toBe('data:image/png;base64,qrcode'); expect(result.qrCodeDataUrl).toBe("data:image/png;base64,qrcode");
expect(result.encryptedSecret).toBeDefined(); expect(result.encryptedSecret).toBeDefined();
expect(result.encryptedSecretIv).toBeDefined(); expect(result.encryptedSecretIv).toBeDefined();
// The issuer is loaded at module load time, so it uses the default 'VillageShare' // The issuer is loaded at module load time, so it uses the default 'VillageShare'
expect(authenticator.keyuri).toHaveBeenCalledWith('test@example.com', 'VillageShare', 'test-secret'); expect(authenticator.keyuri).toHaveBeenCalledWith(
"test@example.com",
"VillageShare",
"test-secret",
);
}); });
it('should use issuer from environment', async () => { it("should use issuer from environment", async () => {
authenticator.generateSecret.mockReturnValue('test-secret'); authenticator.generateSecret.mockReturnValue("test-secret");
authenticator.keyuri.mockReturnValue('otpauth://totp/VillageShare:test@example.com'); authenticator.keyuri.mockReturnValue(
QRCode.toDataURL.mockResolvedValue('data:image/png;base64,qrcode'); "otpauth://totp/VillageShare:test@example.com",
);
QRCode.toDataURL.mockResolvedValue("data:image/png;base64,qrcode");
const result = await TwoFactorService.generateTotpSecret('test@example.com'); const result =
await TwoFactorService.generateTotpSecret("test@example.com");
expect(result.qrCodeDataUrl).toBeDefined(); expect(result.qrCodeDataUrl).toBeDefined();
expect(authenticator.keyuri).toHaveBeenCalled(); expect(authenticator.keyuri).toHaveBeenCalled();
}); });
}); });
describe('verifyTotpCode', () => { describe("verifyTotpCode", () => {
it('should return true for valid code', () => { it("should return true for valid code", () => {
authenticator.verify.mockReturnValue(true); authenticator.verify.mockReturnValue(true);
// Use actual encryption // Use actual encryption
const { encrypted, iv } = TwoFactorService._encryptSecret('test-secret'); const { encrypted, iv } = TwoFactorService._encryptSecret("test-secret");
const result = TwoFactorService.verifyTotpCode(encrypted, iv, '123456'); const result = TwoFactorService.verifyTotpCode(encrypted, iv, "123456");
expect(result).toBe(true); expect(result).toBe(true);
}); });
it('should return false for invalid code', () => { it("should return false for invalid code", () => {
authenticator.verify.mockReturnValue(false); authenticator.verify.mockReturnValue(false);
const { encrypted, iv } = TwoFactorService._encryptSecret('test-secret'); const { encrypted, iv } = TwoFactorService._encryptSecret("test-secret");
const result = TwoFactorService.verifyTotpCode(encrypted, iv, '654321'); const result = TwoFactorService.verifyTotpCode(encrypted, iv, "654321");
expect(result).toBe(false); expect(result).toBe(false);
}); });
it('should return false for non-6-digit code', () => { it("should return false for non-6-digit code", () => {
const result = TwoFactorService.verifyTotpCode('encrypted', 'iv', '12345'); const result = TwoFactorService.verifyTotpCode(
"encrypted",
"iv",
"12345",
);
expect(result).toBe(false); expect(result).toBe(false);
const result2 = TwoFactorService.verifyTotpCode('encrypted', 'iv', '1234567'); const result2 = TwoFactorService.verifyTotpCode(
"encrypted",
"iv",
"1234567",
);
expect(result2).toBe(false); expect(result2).toBe(false);
const result3 = TwoFactorService.verifyTotpCode('encrypted', 'iv', 'abcdef'); const result3 = TwoFactorService.verifyTotpCode(
"encrypted",
"iv",
"abcdef",
);
expect(result3).toBe(false); expect(result3).toBe(false);
}); });
it('should return false when decryption fails', () => { it("should return false when decryption fails", () => {
const result = TwoFactorService.verifyTotpCode('invalid-encrypted', 'invalid-iv', '123456'); const result = TwoFactorService.verifyTotpCode(
"invalid-encrypted",
"invalid-iv",
"123456",
);
expect(result).toBe(false); expect(result).toBe(false);
}); });
}); });
describe('generateEmailOtp', () => { describe("generateEmailOtp", () => {
it('should generate 6-digit code', () => { it("should generate 6-digit code", () => {
const result = TwoFactorService.generateEmailOtp(); const result = TwoFactorService.generateEmailOtp();
expect(result.code).toMatch(/^\d{6}$/); expect(result.code).toMatch(/^\d{6}$/);
}); });
it('should return hashed code', () => { it("should return hashed code", () => {
const result = TwoFactorService.generateEmailOtp(); const result = TwoFactorService.generateEmailOtp();
expect(result.hashedCode).toHaveLength(64); // SHA-256 hex expect(result.hashedCode).toHaveLength(64); // SHA-256 hex
}); });
it('should set expiry in the future', () => { it("should set expiry in the future", () => {
const result = TwoFactorService.generateEmailOtp(); const result = TwoFactorService.generateEmailOtp();
const now = new Date(); const now = new Date();
expect(result.expiry.getTime()).toBeGreaterThan(now.getTime()); expect(result.expiry.getTime()).toBeGreaterThan(now.getTime());
}); });
it('should generate different codes each time', () => { it("should generate different codes each time", () => {
const result1 = TwoFactorService.generateEmailOtp(); const result1 = TwoFactorService.generateEmailOtp();
const result2 = TwoFactorService.generateEmailOtp(); const result2 = TwoFactorService.generateEmailOtp();
@@ -140,10 +166,10 @@ describe('TwoFactorService', () => {
}); });
}); });
describe('verifyEmailOtp', () => { describe("verifyEmailOtp", () => {
it('should return true for valid code', () => { it("should return true for valid code", () => {
const code = '123456'; const code = "123456";
const hashedCode = crypto.createHash('sha256').update(code).digest('hex'); const hashedCode = crypto.createHash("sha256").update(code).digest("hex");
const expiry = new Date(Date.now() + 600000); // 10 minutes from now const expiry = new Date(Date.now() + 600000); // 10 minutes from now
const result = TwoFactorService.verifyEmailOtp(code, hashedCode, expiry); const result = TwoFactorService.verifyEmailOtp(code, hashedCode, expiry);
@@ -151,18 +177,25 @@ describe('TwoFactorService', () => {
expect(result).toBe(true); expect(result).toBe(true);
}); });
it('should return false for invalid code', () => { it("should return false for invalid code", () => {
const correctHash = crypto.createHash('sha256').update('123456').digest('hex'); const correctHash = crypto
.createHash("sha256")
.update("123456")
.digest("hex");
const expiry = new Date(Date.now() + 600000); const expiry = new Date(Date.now() + 600000);
const result = TwoFactorService.verifyEmailOtp('654321', correctHash, expiry); const result = TwoFactorService.verifyEmailOtp(
"654321",
correctHash,
expiry,
);
expect(result).toBe(false); expect(result).toBe(false);
}); });
it('should return false for expired code', () => { it("should return false for expired code", () => {
const code = '123456'; const code = "123456";
const hashedCode = crypto.createHash('sha256').update(code).digest('hex'); const hashedCode = crypto.createHash("sha256").update(code).digest("hex");
const expiry = new Date(Date.now() - 60000); // 1 minute ago const expiry = new Date(Date.now() - 60000); // 1 minute ago
const result = TwoFactorService.verifyEmailOtp(code, hashedCode, expiry); const result = TwoFactorService.verifyEmailOtp(code, hashedCode, expiry);
@@ -170,18 +203,27 @@ describe('TwoFactorService', () => {
expect(result).toBe(false); expect(result).toBe(false);
}); });
it('should return false for non-6-digit code', () => { it("should return false for non-6-digit code", () => {
const hashedCode = crypto.createHash('sha256').update('123456').digest('hex'); const hashedCode = crypto
.createHash("sha256")
.update("123456")
.digest("hex");
const expiry = new Date(Date.now() + 600000); const expiry = new Date(Date.now() + 600000);
expect(TwoFactorService.verifyEmailOtp('12345', hashedCode, expiry)).toBe(false); expect(TwoFactorService.verifyEmailOtp("12345", hashedCode, expiry)).toBe(
expect(TwoFactorService.verifyEmailOtp('1234567', hashedCode, expiry)).toBe(false); false,
expect(TwoFactorService.verifyEmailOtp('abcdef', hashedCode, expiry)).toBe(false); );
expect(
TwoFactorService.verifyEmailOtp("1234567", hashedCode, expiry),
).toBe(false);
expect(
TwoFactorService.verifyEmailOtp("abcdef", hashedCode, expiry),
).toBe(false);
}); });
it('should return false when no expiry provided', () => { it("should return false when no expiry provided", () => {
const code = '123456'; const code = "123456";
const hashedCode = crypto.createHash('sha256').update(code).digest('hex'); const hashedCode = crypto.createHash("sha256").update(code).digest("hex");
const result = TwoFactorService.verifyEmailOtp(code, hashedCode, null); const result = TwoFactorService.verifyEmailOtp(code, hashedCode, null);
@@ -189,9 +231,9 @@ describe('TwoFactorService', () => {
}); });
}); });
describe('generateRecoveryCodes', () => { describe("generateRecoveryCodes", () => {
it('should generate 10 recovery codes', async () => { it("should generate 10 recovery codes", async () => {
bcrypt.hash.mockResolvedValue('hashed-code'); bcrypt.hash.mockResolvedValue("hashed-code");
const result = await TwoFactorService.generateRecoveryCodes(); const result = await TwoFactorService.generateRecoveryCodes();
@@ -199,31 +241,31 @@ describe('TwoFactorService', () => {
expect(result.hashedCodes).toHaveLength(10); expect(result.hashedCodes).toHaveLength(10);
}); });
it('should generate codes in XXXX-XXXX format', async () => { it("should generate codes in XXXX-XXXX format", async () => {
bcrypt.hash.mockResolvedValue('hashed-code'); bcrypt.hash.mockResolvedValue("hashed-code");
const result = await TwoFactorService.generateRecoveryCodes(); const result = await TwoFactorService.generateRecoveryCodes();
result.codes.forEach(code => { result.codes.forEach((code) => {
expect(code).toMatch(/^[A-Z0-9]{4}-[A-Z0-9]{4}$/); expect(code).toMatch(/^[A-Z0-9]{4}-[A-Z0-9]{4}$/);
}); });
}); });
it('should exclude confusing characters', async () => { it("should exclude confusing characters", async () => {
bcrypt.hash.mockResolvedValue('hashed-code'); bcrypt.hash.mockResolvedValue("hashed-code");
const result = await TwoFactorService.generateRecoveryCodes(); const result = await TwoFactorService.generateRecoveryCodes();
const confusingChars = ['0', 'O', '1', 'I', 'L']; const confusingChars = ["0", "O", "1", "I", "L"];
result.codes.forEach(code => { result.codes.forEach((code) => {
confusingChars.forEach(char => { confusingChars.forEach((char) => {
expect(code).not.toContain(char); expect(code).not.toContain(char);
}); });
}); });
}); });
it('should hash each code with bcrypt', async () => { it("should hash each code with bcrypt", async () => {
bcrypt.hash.mockResolvedValue('hashed-code'); bcrypt.hash.mockResolvedValue("hashed-code");
await TwoFactorService.generateRecoveryCodes(); await TwoFactorService.generateRecoveryCodes();
@@ -231,104 +273,114 @@ describe('TwoFactorService', () => {
}); });
}); });
describe('verifyRecoveryCode', () => { describe("verifyRecoveryCode", () => {
it('should return valid for correct code (new format)', async () => { it("should return valid for correct code (new format)", async () => {
bcrypt.compare.mockResolvedValueOnce(false).mockResolvedValueOnce(true); bcrypt.compare.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
const recoveryData = { const recoveryData = {
version: 1, version: 1,
codes: [ codes: [
{ hash: 'hash1', used: false, index: 0 }, { hash: "hash1", used: false, index: 0 },
{ hash: 'hash2', used: false, index: 1 }, { hash: "hash2", used: false, index: 1 },
], ],
}; };
const result = await TwoFactorService.verifyRecoveryCode('XXXX-YYYY', recoveryData); const result = await TwoFactorService.verifyRecoveryCode(
"XXXX-YYYY",
recoveryData,
);
expect(result.valid).toBe(true); expect(result.valid).toBe(true);
expect(result.index).toBe(1); expect(result.index).toBe(1);
}); });
it('should return invalid for incorrect code', async () => { it("should return invalid for incorrect code", async () => {
bcrypt.compare.mockResolvedValue(false); bcrypt.compare.mockResolvedValue(false);
const recoveryData = { const recoveryData = {
version: 1, version: 1,
codes: [ codes: [{ hash: "hash1", used: false, index: 0 }],
{ hash: 'hash1', used: false, index: 0 },
],
}; };
const result = await TwoFactorService.verifyRecoveryCode('XXXX-YYYY', recoveryData); const result = await TwoFactorService.verifyRecoveryCode(
"XXXX-YYYY",
recoveryData,
);
expect(result.valid).toBe(false); expect(result.valid).toBe(false);
expect(result.index).toBe(-1); expect(result.index).toBe(-1);
}); });
it('should skip used codes', async () => { it("should skip used codes", async () => {
bcrypt.compare.mockResolvedValue(true); bcrypt.compare.mockResolvedValue(true);
const recoveryData = { const recoveryData = {
version: 1, version: 1,
codes: [ codes: [
{ hash: 'hash1', used: true, index: 0 }, { hash: "hash1", used: true, index: 0 },
{ hash: 'hash2', used: false, index: 1 }, { hash: "hash2", used: false, index: 1 },
], ],
}; };
await TwoFactorService.verifyRecoveryCode('XXXX-YYYY', recoveryData); await TwoFactorService.verifyRecoveryCode("XXXX-YYYY", recoveryData);
// Should only check the unused code // Should only check the unused code
expect(bcrypt.compare).toHaveBeenCalledTimes(1); expect(bcrypt.compare).toHaveBeenCalledTimes(1);
}); });
it('should normalize input code to uppercase', async () => { it("should normalize input code to uppercase", async () => {
bcrypt.compare.mockResolvedValue(true); bcrypt.compare.mockResolvedValue(true);
const recoveryData = { const recoveryData = {
version: 1, version: 1,
codes: [{ hash: 'hash1', used: false, index: 0 }], codes: [{ hash: "hash1", used: false, index: 0 }],
}; };
await TwoFactorService.verifyRecoveryCode('xxxx-yyyy', recoveryData); await TwoFactorService.verifyRecoveryCode("xxxx-yyyy", recoveryData);
expect(bcrypt.compare).toHaveBeenCalledWith('XXXX-YYYY', 'hash1'); expect(bcrypt.compare).toHaveBeenCalledWith("XXXX-YYYY", "hash1");
}); });
it('should return invalid for wrong format', async () => { it("should return invalid for wrong format", async () => {
const recoveryData = { const recoveryData = {
version: 1, version: 1,
codes: [{ hash: 'hash1', used: false, index: 0 }], codes: [{ hash: "hash1", used: false, index: 0 }],
}; };
const result = await TwoFactorService.verifyRecoveryCode('INVALID', recoveryData); const result = await TwoFactorService.verifyRecoveryCode(
"INVALID",
recoveryData,
);
expect(result.valid).toBe(false); expect(result.valid).toBe(false);
}); });
it('should handle legacy array format', async () => { it("should handle legacy array format", async () => {
bcrypt.compare.mockResolvedValueOnce(false).mockResolvedValueOnce(true); bcrypt.compare.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
const recoveryData = ['hash1', 'hash2', 'hash3']; const recoveryData = ["hash1", "hash2", "hash3"];
const result = await TwoFactorService.verifyRecoveryCode('XXXX-YYYY', recoveryData); const result = await TwoFactorService.verifyRecoveryCode(
"XXXX-YYYY",
recoveryData,
);
expect(result.valid).toBe(true); expect(result.valid).toBe(true);
}); });
it('should skip null entries in legacy format', async () => { it("should skip null entries in legacy format", async () => {
bcrypt.compare.mockResolvedValue(true); bcrypt.compare.mockResolvedValue(true);
const recoveryData = [null, 'hash2']; const recoveryData = [null, "hash2"];
await TwoFactorService.verifyRecoveryCode('XXXX-YYYY', recoveryData); await TwoFactorService.verifyRecoveryCode("XXXX-YYYY", recoveryData);
expect(bcrypt.compare).toHaveBeenCalledTimes(1); expect(bcrypt.compare).toHaveBeenCalledTimes(1);
}); });
}); });
describe('validateStepUpSession', () => { describe("validateStepUpSession", () => {
it('should return true for valid session', () => { it("should return true for valid session", () => {
const user = { const user = {
twoFactorVerifiedAt: new Date(Date.now() - 60000), // 1 minute ago twoFactorVerifiedAt: new Date(Date.now() - 60000), // 1 minute ago
}; };
@@ -338,7 +390,7 @@ describe('TwoFactorService', () => {
expect(result).toBe(true); expect(result).toBe(true);
}); });
it('should return false for expired session', () => { it("should return false for expired session", () => {
const user = { const user = {
twoFactorVerifiedAt: new Date(Date.now() - 600000), // 10 minutes ago twoFactorVerifiedAt: new Date(Date.now() - 600000), // 10 minutes ago
}; };
@@ -348,7 +400,7 @@ describe('TwoFactorService', () => {
expect(result).toBe(false); expect(result).toBe(false);
}); });
it('should return false when no verification timestamp', () => { it("should return false when no verification timestamp", () => {
const user = { const user = {
twoFactorVerifiedAt: null, twoFactorVerifiedAt: null,
}; };
@@ -358,7 +410,7 @@ describe('TwoFactorService', () => {
expect(result).toBe(false); expect(result).toBe(false);
}); });
it('should use custom max age when provided', () => { it("should use custom max age when provided", () => {
const user = { const user = {
twoFactorVerifiedAt: new Date(Date.now() - 1200000), // 20 minutes ago twoFactorVerifiedAt: new Date(Date.now() - 1200000), // 20 minutes ago
}; };
@@ -369,85 +421,88 @@ describe('TwoFactorService', () => {
}); });
}); });
describe('getRemainingRecoveryCodesCount', () => { describe("getRemainingRecoveryCodesCount", () => {
it('should return count for new format', () => { it("should return count for new format", () => {
const recoveryData = { const recoveryData = {
version: 1, version: 1,
codes: [ codes: [
{ hash: 'hash1', used: false }, { hash: "hash1", used: false },
{ hash: 'hash2', used: true }, { hash: "hash2", used: true },
{ hash: 'hash3', used: false }, { hash: "hash3", used: false },
], ],
}; };
const result = TwoFactorService.getRemainingRecoveryCodesCount(recoveryData); const result =
TwoFactorService.getRemainingRecoveryCodesCount(recoveryData);
expect(result).toBe(2); expect(result).toBe(2);
}); });
it('should return count for legacy array format', () => { it("should return count for legacy array format", () => {
const recoveryData = ['hash1', null, 'hash3', 'hash4', null]; const recoveryData = ["hash1", null, "hash3", "hash4", null];
const result = TwoFactorService.getRemainingRecoveryCodesCount(recoveryData); const result =
TwoFactorService.getRemainingRecoveryCodesCount(recoveryData);
expect(result).toBe(3); expect(result).toBe(3);
}); });
it('should return 0 for null data', () => { it("should return 0 for null data", () => {
const result = TwoFactorService.getRemainingRecoveryCodesCount(null); const result = TwoFactorService.getRemainingRecoveryCodesCount(null);
expect(result).toBe(0); expect(result).toBe(0);
}); });
it('should return 0 for undefined data', () => { it("should return 0 for undefined data", () => {
const result = TwoFactorService.getRemainingRecoveryCodesCount(undefined); const result = TwoFactorService.getRemainingRecoveryCodesCount(undefined);
expect(result).toBe(0); expect(result).toBe(0);
}); });
it('should handle empty array', () => { it("should handle empty array", () => {
const result = TwoFactorService.getRemainingRecoveryCodesCount([]); const result = TwoFactorService.getRemainingRecoveryCodesCount([]);
expect(result).toBe(0); expect(result).toBe(0);
}); });
it('should handle all used codes', () => { it("should handle all used codes", () => {
const recoveryData = { const recoveryData = {
version: 1, version: 1,
codes: [ codes: [
{ hash: 'hash1', used: true }, { hash: "hash1", used: true },
{ hash: 'hash2', used: true }, { hash: "hash2", used: true },
], ],
}; };
const result = TwoFactorService.getRemainingRecoveryCodesCount(recoveryData); const result =
TwoFactorService.getRemainingRecoveryCodesCount(recoveryData);
expect(result).toBe(0); expect(result).toBe(0);
}); });
}); });
describe('isEmailOtpLocked', () => { describe("isEmailOtpLocked", () => {
it('should return true when max attempts reached', () => { it("should return true when max attempts reached", () => {
const result = TwoFactorService.isEmailOtpLocked(3); const result = TwoFactorService.isEmailOtpLocked(3);
expect(result).toBe(true); expect(result).toBe(true);
}); });
it('should return true when over max attempts', () => { it("should return true when over max attempts", () => {
const result = TwoFactorService.isEmailOtpLocked(5); const result = TwoFactorService.isEmailOtpLocked(5);
expect(result).toBe(true); expect(result).toBe(true);
}); });
it('should return false when under max attempts', () => { it("should return false when under max attempts", () => {
const result = TwoFactorService.isEmailOtpLocked(2); const result = TwoFactorService.isEmailOtpLocked(2);
expect(result).toBe(false); expect(result).toBe(false);
}); });
it('should return false for zero attempts', () => { it("should return false for zero attempts", () => {
const result = TwoFactorService.isEmailOtpLocked(0); const result = TwoFactorService.isEmailOtpLocked(0);
expect(result).toBe(false); expect(result).toBe(false);
}); });
}); });
describe('_encryptSecret / _decryptSecret', () => { describe("_encryptSecret / _decryptSecret", () => {
it('should encrypt and decrypt correctly', () => { it("should encrypt and decrypt correctly", () => {
const secret = 'my-test-secret'; const secret = "my-test-secret";
const { encrypted, iv } = TwoFactorService._encryptSecret(secret); const { encrypted, iv } = TwoFactorService._encryptSecret(secret);
const decrypted = TwoFactorService._decryptSecret(encrypted, iv); const decrypted = TwoFactorService._decryptSecret(encrypted, iv);
@@ -455,16 +510,20 @@ describe('TwoFactorService', () => {
expect(decrypted).toBe(secret); expect(decrypted).toBe(secret);
}); });
it('should throw error when encryption key is missing', () => { it("should throw error when encryption key is missing", () => {
delete process.env.TOTP_ENCRYPTION_KEY; delete process.env.TOTP_ENCRYPTION_KEY;
expect(() => TwoFactorService._encryptSecret('test')).toThrow('TOTP_ENCRYPTION_KEY'); expect(() => TwoFactorService._encryptSecret("test")).toThrow(
"TOTP_ENCRYPTION_KEY",
);
}); });
it('should throw error when encryption key is wrong length', () => { it("should throw error when encryption key is wrong length", () => {
process.env.TOTP_ENCRYPTION_KEY = 'short'; process.env.TOTP_ENCRYPTION_KEY = "short";
expect(() => TwoFactorService._encryptSecret('test')).toThrow('64-character hex string'); expect(() => TwoFactorService._encryptSecret("test")).toThrow(
"64-character hex string",
);
}); });
}); });
}); });

View File

@@ -1,34 +1,34 @@
// Mock AWS SDK before requiring modules // Mock AWS SDK before requiring modules
jest.mock('@aws-sdk/client-ses', () => ({ jest.mock("@aws-sdk/client-ses", () => ({
SESClient: jest.fn().mockImplementation(() => ({ SESClient: jest.fn().mockImplementation(() => ({
send: jest.fn(), send: jest.fn(),
})), })),
SendEmailCommand: jest.fn(), SendEmailCommand: jest.fn(),
})); }));
jest.mock('../../../../config/aws', () => ({ jest.mock("../../../../config/aws", () => ({
getAWSConfig: jest.fn(() => ({ region: 'us-east-1' })), getAWSConfig: jest.fn(() => ({ region: "us-east-1" })),
})); }));
jest.mock('../../../../services/email/core/emailUtils', () => ({ jest.mock("../../../../services/email/core/emailUtils", () => ({
htmlToPlainText: jest.fn((html) => html.replace(/<[^>]*>/g, '')), htmlToPlainText: jest.fn((html) => html.replace(/<[^>]*>/g, "")),
})); }));
// Clear singleton between tests // Clear singleton between tests
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
// Reset the singleton instance // Reset the singleton instance
const EmailClient = require('../../../../services/email/core/EmailClient'); const EmailClient = require("../../../../services/email/core/EmailClient");
EmailClient.instance = null; EmailClient.instance = null;
}); });
describe('EmailClient', () => { describe("EmailClient", () => {
const { SESClient, SendEmailCommand } = require('@aws-sdk/client-ses'); const { SESClient, SendEmailCommand } = require("@aws-sdk/client-ses");
const { getAWSConfig } = require('../../../../config/aws'); const { getAWSConfig } = require("../../../../config/aws");
describe('constructor', () => { describe("constructor", () => {
it('should create a new instance', () => { it("should create a new instance", () => {
const EmailClient = require('../../../../services/email/core/EmailClient'); const EmailClient = require("../../../../services/email/core/EmailClient");
EmailClient.instance = null; EmailClient.instance = null;
const client = new EmailClient(); const client = new EmailClient();
expect(client).toBeDefined(); expect(client).toBeDefined();
@@ -36,8 +36,8 @@ describe('EmailClient', () => {
expect(client.initialized).toBe(false); expect(client.initialized).toBe(false);
}); });
it('should return existing instance (singleton pattern)', () => { it("should return existing instance (singleton pattern)", () => {
const EmailClient = require('../../../../services/email/core/EmailClient'); const EmailClient = require("../../../../services/email/core/EmailClient");
EmailClient.instance = null; EmailClient.instance = null;
const client1 = new EmailClient(); const client1 = new EmailClient();
const client2 = new EmailClient(); const client2 = new EmailClient();
@@ -45,21 +45,21 @@ describe('EmailClient', () => {
}); });
}); });
describe('initialize', () => { describe("initialize", () => {
it('should initialize SES client with AWS config', async () => { it("should initialize SES client with AWS config", async () => {
const EmailClient = require('../../../../services/email/core/EmailClient'); const EmailClient = require("../../../../services/email/core/EmailClient");
EmailClient.instance = null; EmailClient.instance = null;
const client = new EmailClient(); const client = new EmailClient();
await client.initialize(); await client.initialize();
expect(getAWSConfig).toHaveBeenCalled(); expect(getAWSConfig).toHaveBeenCalled();
expect(SESClient).toHaveBeenCalledWith({ region: 'us-east-1' }); expect(SESClient).toHaveBeenCalledWith({ region: "us-east-1" });
expect(client.initialized).toBe(true); expect(client.initialized).toBe(true);
}); });
it('should not re-initialize if already initialized', async () => { it("should not re-initialize if already initialized", async () => {
const EmailClient = require('../../../../services/email/core/EmailClient'); const EmailClient = require("../../../../services/email/core/EmailClient");
EmailClient.instance = null; EmailClient.instance = null;
const client = new EmailClient(); const client = new EmailClient();
@@ -69,8 +69,8 @@ describe('EmailClient', () => {
expect(SESClient).toHaveBeenCalledTimes(1); expect(SESClient).toHaveBeenCalledTimes(1);
}); });
it('should wait for existing initialization if in progress', async () => { it("should wait for existing initialization if in progress", async () => {
const EmailClient = require('../../../../services/email/core/EmailClient'); const EmailClient = require("../../../../services/email/core/EmailClient");
EmailClient.instance = null; EmailClient.instance = null;
const client = new EmailClient(); const client = new EmailClient();
@@ -83,28 +83,28 @@ describe('EmailClient', () => {
expect(SESClient).toHaveBeenCalledTimes(1); expect(SESClient).toHaveBeenCalledTimes(1);
}); });
it('should throw error if AWS config fails', async () => { it("should throw error if AWS config fails", async () => {
getAWSConfig.mockImplementationOnce(() => { getAWSConfig.mockImplementationOnce(() => {
throw new Error('AWS config error'); throw new Error("AWS config error");
}); });
const EmailClient = require('../../../../services/email/core/EmailClient'); const EmailClient = require("../../../../services/email/core/EmailClient");
EmailClient.instance = null; EmailClient.instance = null;
const client = new EmailClient(); const client = new EmailClient();
await expect(client.initialize()).rejects.toThrow('AWS config error'); await expect(client.initialize()).rejects.toThrow("AWS config error");
}); });
}); });
describe('sendEmail', () => { describe("sendEmail", () => {
const originalEnv = process.env; const originalEnv = process.env;
beforeEach(() => { beforeEach(() => {
process.env = { process.env = {
...originalEnv, ...originalEnv,
EMAIL_ENABLED: 'true', EMAIL_ENABLED: "true",
SES_FROM_EMAIL: 'noreply@villageshare.app', SES_FROM_EMAIL: "noreply@email.com",
SES_FROM_NAME: 'Village Share', SES_FROM_NAME: "Village Share",
}; };
}); });
@@ -112,114 +112,114 @@ describe('EmailClient', () => {
process.env = originalEnv; process.env = originalEnv;
}); });
it('should return early if EMAIL_ENABLED is not true', async () => { it("should return early if EMAIL_ENABLED is not true", async () => {
process.env.EMAIL_ENABLED = 'false'; process.env.EMAIL_ENABLED = "false";
const EmailClient = require('../../../../services/email/core/EmailClient'); const EmailClient = require("../../../../services/email/core/EmailClient");
EmailClient.instance = null; EmailClient.instance = null;
const client = new EmailClient(); const client = new EmailClient();
const result = await client.sendEmail( const result = await client.sendEmail(
'test@example.com', "test@example.com",
'Test Subject', "Test Subject",
'<p>Hello</p>' "<p>Hello</p>",
); );
expect(result).toEqual({ success: true, messageId: 'disabled' }); expect(result).toEqual({ success: true, messageId: "disabled" });
}); });
it('should return early if EMAIL_ENABLED is not set', async () => { it("should return early if EMAIL_ENABLED is not set", async () => {
delete process.env.EMAIL_ENABLED; delete process.env.EMAIL_ENABLED;
const EmailClient = require('../../../../services/email/core/EmailClient'); const EmailClient = require("../../../../services/email/core/EmailClient");
EmailClient.instance = null; EmailClient.instance = null;
const client = new EmailClient(); const client = new EmailClient();
const result = await client.sendEmail( const result = await client.sendEmail(
'test@example.com', "test@example.com",
'Test Subject', "Test Subject",
'<p>Hello</p>' "<p>Hello</p>",
); );
expect(result).toEqual({ success: true, messageId: 'disabled' }); expect(result).toEqual({ success: true, messageId: "disabled" });
}); });
it('should send email with correct parameters', async () => { it("should send email with correct parameters", async () => {
const mockSend = jest.fn().mockResolvedValue({ MessageId: 'msg-123' }); const mockSend = jest.fn().mockResolvedValue({ MessageId: "msg-123" });
SESClient.mockImplementation(() => ({ send: mockSend })); SESClient.mockImplementation(() => ({ send: mockSend }));
const EmailClient = require('../../../../services/email/core/EmailClient'); const EmailClient = require("../../../../services/email/core/EmailClient");
EmailClient.instance = null; EmailClient.instance = null;
const client = new EmailClient(); const client = new EmailClient();
const result = await client.sendEmail( const result = await client.sendEmail(
'test@example.com', "test@example.com",
'Test Subject', "Test Subject",
'<p>Hello World</p>' "<p>Hello World</p>",
); );
expect(SendEmailCommand).toHaveBeenCalledWith({ expect(SendEmailCommand).toHaveBeenCalledWith({
Source: 'Village Share <noreply@villageshare.app>', Source: "Village Share <noreply@email.com>",
Destination: { Destination: {
ToAddresses: ['test@example.com'], ToAddresses: ["test@example.com"],
}, },
Message: { Message: {
Subject: { Subject: {
Data: 'Test Subject', Data: "Test Subject",
Charset: 'UTF-8', Charset: "UTF-8",
}, },
Body: { Body: {
Html: { Html: {
Data: '<p>Hello World</p>', Data: "<p>Hello World</p>",
Charset: 'UTF-8', Charset: "UTF-8",
}, },
Text: { Text: {
Data: expect.any(String), Data: expect.any(String),
Charset: 'UTF-8', Charset: "UTF-8",
}, },
}, },
}, },
}); });
expect(result).toEqual({ success: true, messageId: 'msg-123' }); expect(result).toEqual({ success: true, messageId: "msg-123" });
}); });
it('should send to multiple recipients', async () => { it("should send to multiple recipients", async () => {
const mockSend = jest.fn().mockResolvedValue({ MessageId: 'msg-456' }); const mockSend = jest.fn().mockResolvedValue({ MessageId: "msg-456" });
SESClient.mockImplementation(() => ({ send: mockSend })); SESClient.mockImplementation(() => ({ send: mockSend }));
const EmailClient = require('../../../../services/email/core/EmailClient'); const EmailClient = require("../../../../services/email/core/EmailClient");
EmailClient.instance = null; EmailClient.instance = null;
const client = new EmailClient(); const client = new EmailClient();
await client.sendEmail( await client.sendEmail(
['user1@example.com', 'user2@example.com'], ["user1@example.com", "user2@example.com"],
'Test Subject', "Test Subject",
'<p>Hello</p>' "<p>Hello</p>",
); );
expect(SendEmailCommand).toHaveBeenCalledWith( expect(SendEmailCommand).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
Destination: { Destination: {
ToAddresses: ['user1@example.com', 'user2@example.com'], ToAddresses: ["user1@example.com", "user2@example.com"],
}, },
}) }),
); );
}); });
it('should use provided text content', async () => { it("should use provided text content", async () => {
const mockSend = jest.fn().mockResolvedValue({ MessageId: 'msg-789' }); const mockSend = jest.fn().mockResolvedValue({ MessageId: "msg-789" });
SESClient.mockImplementation(() => ({ send: mockSend })); SESClient.mockImplementation(() => ({ send: mockSend }));
const EmailClient = require('../../../../services/email/core/EmailClient'); const EmailClient = require("../../../../services/email/core/EmailClient");
EmailClient.instance = null; EmailClient.instance = null;
const client = new EmailClient(); const client = new EmailClient();
await client.sendEmail( await client.sendEmail(
'test@example.com', "test@example.com",
'Test Subject', "Test Subject",
'<p>Hello</p>', "<p>Hello</p>",
'Custom plain text' "Custom plain text",
); );
expect(SendEmailCommand).toHaveBeenCalledWith( expect(SendEmailCommand).toHaveBeenCalledWith(
@@ -227,68 +227,70 @@ describe('EmailClient', () => {
Message: expect.objectContaining({ Message: expect.objectContaining({
Body: expect.objectContaining({ Body: expect.objectContaining({
Text: { Text: {
Data: 'Custom plain text', Data: "Custom plain text",
Charset: 'UTF-8', Charset: "UTF-8",
}, },
}), }),
}), }),
}) }),
); );
}); });
it('should add reply-to address if configured', async () => { it("should add reply-to address if configured", async () => {
process.env.SES_REPLY_TO_EMAIL = 'support@villageshare.app'; process.env.SES_REPLY_TO_EMAIL = "support@email.com";
const mockSend = jest.fn().mockResolvedValue({ MessageId: 'msg-000' }); const mockSend = jest.fn().mockResolvedValue({ MessageId: "msg-000" });
SESClient.mockImplementation(() => ({ send: mockSend })); SESClient.mockImplementation(() => ({ send: mockSend }));
const EmailClient = require('../../../../services/email/core/EmailClient'); const EmailClient = require("../../../../services/email/core/EmailClient");
EmailClient.instance = null; EmailClient.instance = null;
const client = new EmailClient(); const client = new EmailClient();
await client.sendEmail( await client.sendEmail(
'test@example.com', "test@example.com",
'Test Subject', "Test Subject",
'<p>Hello</p>' "<p>Hello</p>",
); );
expect(SendEmailCommand).toHaveBeenCalledWith( expect(SendEmailCommand).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
ReplyToAddresses: ['support@villageshare.app'], ReplyToAddresses: ["support@email.com"],
}) }),
); );
}); });
it('should return error if send fails', async () => { it("should return error if send fails", async () => {
const mockSend = jest.fn().mockRejectedValue(new Error('SES send failed')); const mockSend = jest
.fn()
.mockRejectedValue(new Error("SES send failed"));
SESClient.mockImplementation(() => ({ send: mockSend })); SESClient.mockImplementation(() => ({ send: mockSend }));
const EmailClient = require('../../../../services/email/core/EmailClient'); const EmailClient = require("../../../../services/email/core/EmailClient");
EmailClient.instance = null; EmailClient.instance = null;
const client = new EmailClient(); const client = new EmailClient();
const result = await client.sendEmail( const result = await client.sendEmail(
'test@example.com', "test@example.com",
'Test Subject', "Test Subject",
'<p>Hello</p>' "<p>Hello</p>",
); );
expect(result).toEqual({ success: false, error: 'SES send failed' }); expect(result).toEqual({ success: false, error: "SES send failed" });
}); });
it('should auto-initialize if not initialized', async () => { it("should auto-initialize if not initialized", async () => {
const mockSend = jest.fn().mockResolvedValue({ MessageId: 'msg-auto' }); const mockSend = jest.fn().mockResolvedValue({ MessageId: "msg-auto" });
SESClient.mockImplementation(() => ({ send: mockSend })); SESClient.mockImplementation(() => ({ send: mockSend }));
const EmailClient = require('../../../../services/email/core/EmailClient'); const EmailClient = require("../../../../services/email/core/EmailClient");
EmailClient.instance = null; EmailClient.instance = null;
const client = new EmailClient(); const client = new EmailClient();
expect(client.initialized).toBe(false); expect(client.initialized).toBe(false);
await client.sendEmail( await client.sendEmail(
'test@example.com', "test@example.com",
'Test Subject', "Test Subject",
'<p>Hello</p>' "<p>Hello</p>",
); );
expect(client.initialized).toBe(true); expect(client.initialized).toBe(true);

View File

@@ -1,27 +1,32 @@
// Mock dependencies // Mock dependencies
jest.mock('../../../../../services/email/core/EmailClient', () => { jest.mock("../../../../../services/email/core/EmailClient", () => {
return jest.fn().mockImplementation(() => ({ return jest.fn().mockImplementation(() => ({
initialize: jest.fn().mockResolvedValue(), initialize: jest.fn().mockResolvedValue(),
sendEmail: jest.fn().mockResolvedValue({ success: true, messageId: 'msg-123' }), sendEmail: jest
.fn()
.mockResolvedValue({ success: true, messageId: "msg-123" }),
})); }));
}); });
jest.mock('../../../../../services/email/core/TemplateManager', () => { jest.mock("../../../../../services/email/core/TemplateManager", () => {
return jest.fn().mockImplementation(() => ({ return jest.fn().mockImplementation(() => ({
initialize: jest.fn().mockResolvedValue(), initialize: jest.fn().mockResolvedValue(),
renderTemplate: jest.fn().mockResolvedValue('<html>Test</html>'), renderTemplate: jest.fn().mockResolvedValue("<html>Test</html>"),
})); }));
}); });
const FeedbackEmailService = require('../../../../../services/email/domain/FeedbackEmailService'); const FeedbackEmailService = require("../../../../../services/email/domain/FeedbackEmailService");
describe('FeedbackEmailService', () => { describe("FeedbackEmailService", () => {
let service; let service;
const originalEnv = process.env; const originalEnv = process.env;
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
process.env = { ...originalEnv, FEEDBACK_EMAIL: 'feedback@example.com' }; process.env = {
...originalEnv,
CUSTOMER_SUPPORT_EMAIL: "feedback@example.com",
};
service = new FeedbackEmailService(); service = new FeedbackEmailService();
}); });
@@ -29,8 +34,8 @@ describe('FeedbackEmailService', () => {
process.env = originalEnv; process.env = originalEnv;
}); });
describe('initialize', () => { describe("initialize", () => {
it('should initialize only once', async () => { it("should initialize only once", async () => {
await service.initialize(); await service.initialize();
await service.initialize(); await service.initialize();
@@ -39,11 +44,11 @@ describe('FeedbackEmailService', () => {
}); });
}); });
describe('sendFeedbackConfirmation', () => { describe("sendFeedbackConfirmation", () => {
it('should send feedback confirmation to user', async () => { it("should send feedback confirmation to user", async () => {
const user = { firstName: 'John', email: 'john@example.com' }; const user = { firstName: "John", email: "john@example.com" };
const feedback = { const feedback = {
feedbackText: 'Great app!', feedbackText: "Great app!",
createdAt: new Date(), createdAt: new Date(),
}; };
@@ -51,115 +56,122 @@ describe('FeedbackEmailService', () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
'feedbackConfirmationToUser', "feedbackConfirmationToUser",
expect.objectContaining({ expect.objectContaining({
userName: 'John', userName: "John",
userEmail: 'john@example.com', userEmail: "john@example.com",
feedbackText: 'Great app!', feedbackText: "Great app!",
}) }),
); );
expect(service.emailClient.sendEmail).toHaveBeenCalledWith( expect(service.emailClient.sendEmail).toHaveBeenCalledWith(
'john@example.com', "john@example.com",
'Thank You for Your Feedback - Village Share', "Thank You for Your Feedback - Village Share",
expect.any(String) expect.any(String),
); );
}); });
it('should use default name when firstName is missing', async () => { it("should use default name when firstName is missing", async () => {
const user = { email: 'john@example.com' }; const user = { email: "john@example.com" };
const feedback = { const feedback = {
feedbackText: 'Great app!', feedbackText: "Great app!",
createdAt: new Date(), createdAt: new Date(),
}; };
await service.sendFeedbackConfirmation(user, feedback); await service.sendFeedbackConfirmation(user, feedback);
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
'feedbackConfirmationToUser', "feedbackConfirmationToUser",
expect.objectContaining({ userName: 'there' }) expect.objectContaining({ userName: "there" }),
); );
}); });
}); });
describe('sendFeedbackNotificationToAdmin', () => { describe("sendFeedbackNotificationToAdmin", () => {
it('should send feedback notification to admin', async () => { it("should send feedback notification to admin", async () => {
const user = { const user = {
id: 'user-123', id: "user-123",
firstName: 'John', firstName: "John",
lastName: 'Doe', lastName: "Doe",
email: 'john@example.com', email: "john@example.com",
}; };
const feedback = { const feedback = {
id: 'feedback-123', id: "feedback-123",
feedbackText: 'Great app!', feedbackText: "Great app!",
url: 'https://example.com/page', url: "https://example.com/page",
userAgent: 'Mozilla/5.0', userAgent: "Mozilla/5.0",
createdAt: new Date(), createdAt: new Date(),
}; };
const result = await service.sendFeedbackNotificationToAdmin(user, feedback); const result = await service.sendFeedbackNotificationToAdmin(
user,
feedback,
);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
'feedbackNotificationToAdmin', "feedbackNotificationToAdmin",
expect.objectContaining({ expect.objectContaining({
userName: 'John Doe', userName: "John Doe",
userEmail: 'john@example.com', userEmail: "john@example.com",
userId: 'user-123', userId: "user-123",
feedbackText: 'Great app!', feedbackText: "Great app!",
feedbackId: 'feedback-123', feedbackId: "feedback-123",
url: 'https://example.com/page', url: "https://example.com/page",
userAgent: 'Mozilla/5.0', userAgent: "Mozilla/5.0",
}) }),
); );
expect(service.emailClient.sendEmail).toHaveBeenCalledWith( expect(service.emailClient.sendEmail).toHaveBeenCalledWith(
'feedback@example.com', "feedback@example.com",
'New Feedback from John Doe', "New Feedback from John Doe",
expect.any(String) expect.any(String),
); );
}); });
it('should return error when no admin email configured', async () => { it("should return error when no admin email configured", async () => {
delete process.env.FEEDBACK_EMAIL;
delete process.env.CUSTOMER_SUPPORT_EMAIL; delete process.env.CUSTOMER_SUPPORT_EMAIL;
const user = { id: 'user-123', firstName: 'John', lastName: 'Doe', email: 'john@example.com' }; const user = {
const feedback = { id: 'feedback-123', feedbackText: 'Test', createdAt: new Date() }; id: "user-123",
firstName: "John",
lastName: "Doe",
email: "john@example.com",
};
const feedback = {
id: "feedback-123",
feedbackText: "Test",
createdAt: new Date(),
};
const result = await service.sendFeedbackNotificationToAdmin(user, feedback); const result = await service.sendFeedbackNotificationToAdmin(
user,
feedback,
);
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain('No admin email configured'); expect(result.error).toContain("No admin email configured");
}); });
it('should use CUSTOMER_SUPPORT_EMAIL when FEEDBACK_EMAIL not set', async () => { it("should use default values for optional fields", async () => {
delete process.env.FEEDBACK_EMAIL; const user = {
process.env.CUSTOMER_SUPPORT_EMAIL = 'support@example.com'; id: "user-123",
firstName: "John",
const user = { id: 'user-123', firstName: 'John', lastName: 'Doe', email: 'john@example.com' }; lastName: "Doe",
const feedback = { id: 'feedback-123', feedbackText: 'Test', createdAt: new Date() }; email: "john@example.com",
};
await service.sendFeedbackNotificationToAdmin(user, feedback); const feedback = {
id: "feedback-123",
expect(service.emailClient.sendEmail).toHaveBeenCalledWith( feedbackText: "Test",
'support@example.com', createdAt: new Date(),
expect.any(String), };
expect.any(String)
);
});
it('should use default values for optional fields', async () => {
const user = { id: 'user-123', firstName: 'John', lastName: 'Doe', email: 'john@example.com' };
const feedback = { id: 'feedback-123', feedbackText: 'Test', createdAt: new Date() };
await service.sendFeedbackNotificationToAdmin(user, feedback); await service.sendFeedbackNotificationToAdmin(user, feedback);
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
'feedbackNotificationToAdmin', "feedbackNotificationToAdmin",
expect.objectContaining({ expect.objectContaining({
url: 'Not provided', url: "Not provided",
userAgent: 'Not provided', userAgent: "Not provided",
}) }),
); );
}); });
}); });

View File

@@ -1,27 +1,29 @@
// Mock dependencies // Mock dependencies
jest.mock('../../../../../services/email/core/EmailClient', () => { jest.mock("../../../../../services/email/core/EmailClient", () => {
return jest.fn().mockImplementation(() => ({ return jest.fn().mockImplementation(() => ({
initialize: jest.fn().mockResolvedValue(), initialize: jest.fn().mockResolvedValue(),
sendEmail: jest.fn().mockResolvedValue({ success: true, messageId: 'msg-123' }), sendEmail: jest
.fn()
.mockResolvedValue({ success: true, messageId: "msg-123" }),
})); }));
}); });
jest.mock('../../../../../services/email/core/TemplateManager', () => { jest.mock("../../../../../services/email/core/TemplateManager", () => {
return jest.fn().mockImplementation(() => ({ return jest.fn().mockImplementation(() => ({
initialize: jest.fn().mockResolvedValue(), initialize: jest.fn().mockResolvedValue(),
renderTemplate: jest.fn().mockResolvedValue('<html>Test</html>'), renderTemplate: jest.fn().mockResolvedValue("<html>Test</html>"),
})); }));
}); });
jest.mock('../../../../../utils/logger', () => ({ jest.mock("../../../../../utils/logger", () => ({
info: jest.fn(), info: jest.fn(),
error: jest.fn(), error: jest.fn(),
warn: jest.fn(), warn: jest.fn(),
})); }));
const PaymentEmailService = require('../../../../../services/email/domain/PaymentEmailService'); const PaymentEmailService = require("../../../../../services/email/domain/PaymentEmailService");
describe('PaymentEmailService', () => { describe("PaymentEmailService", () => {
let service; let service;
const originalEnv = process.env; const originalEnv = process.env;
@@ -29,8 +31,8 @@ describe('PaymentEmailService', () => {
jest.clearAllMocks(); jest.clearAllMocks();
process.env = { process.env = {
...originalEnv, ...originalEnv,
FRONTEND_URL: 'http://localhost:3000', FRONTEND_URL: "http://localhost:3000",
ADMIN_EMAIL: 'admin@example.com', CUSTOMER_SUPPORT_EMAIL: "admin@example.com",
}; };
service = new PaymentEmailService(); service = new PaymentEmailService();
}); });
@@ -39,8 +41,8 @@ describe('PaymentEmailService', () => {
process.env = originalEnv; process.env = originalEnv;
}); });
describe('initialize', () => { describe("initialize", () => {
it('should initialize only once', async () => { it("should initialize only once", async () => {
await service.initialize(); await service.initialize();
await service.initialize(); await service.initialize();
@@ -48,196 +50,222 @@ describe('PaymentEmailService', () => {
}); });
}); });
describe('sendPaymentDeclinedNotification', () => { describe("sendPaymentDeclinedNotification", () => {
it('should send payment declined notification to renter', async () => { it("should send payment declined notification to renter", async () => {
const result = await service.sendPaymentDeclinedNotification('renter@example.com', { const result = await service.sendPaymentDeclinedNotification(
renterFirstName: 'John', "renter@example.com",
itemName: 'Test Item', {
declineReason: 'Card declined', renterFirstName: "John",
updatePaymentUrl: 'http://localhost:3000/update-payment', itemName: "Test Item",
}); declineReason: "Card declined",
updatePaymentUrl: "http://localhost:3000/update-payment",
},
);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
'paymentDeclinedToRenter', "paymentDeclinedToRenter",
expect.objectContaining({ expect.objectContaining({
renterFirstName: 'John', renterFirstName: "John",
itemName: 'Test Item', itemName: "Test Item",
declineReason: 'Card declined', declineReason: "Card declined",
}) }),
); );
}); });
it('should use default values for missing params', async () => { it("should use default values for missing params", async () => {
await service.sendPaymentDeclinedNotification('renter@example.com', {}); await service.sendPaymentDeclinedNotification("renter@example.com", {});
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
'paymentDeclinedToRenter', "paymentDeclinedToRenter",
expect.objectContaining({ expect.objectContaining({
renterFirstName: 'there', renterFirstName: "there",
itemName: 'the item', itemName: "the item",
}) }),
); );
}); });
it('should handle errors gracefully', async () => { it("should handle errors gracefully", async () => {
service.templateManager.renderTemplate.mockRejectedValue(new Error('Template error')); service.templateManager.renderTemplate.mockRejectedValue(
new Error("Template error"),
);
const result = await service.sendPaymentDeclinedNotification('test@example.com', {}); const result = await service.sendPaymentDeclinedNotification(
"test@example.com",
{},
);
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain('Template error'); expect(result.error).toContain("Template error");
}); });
}); });
describe('sendPaymentMethodUpdatedNotification', () => { describe("sendPaymentMethodUpdatedNotification", () => {
it('should send payment method updated notification to owner', async () => { it("should send payment method updated notification to owner", async () => {
const result = await service.sendPaymentMethodUpdatedNotification('owner@example.com', { const result = await service.sendPaymentMethodUpdatedNotification(
ownerFirstName: 'Jane', "owner@example.com",
itemName: 'Test Item', {
approvalUrl: 'http://localhost:3000/approve', ownerFirstName: "Jane",
}); itemName: "Test Item",
approvalUrl: "http://localhost:3000/approve",
},
);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(service.emailClient.sendEmail).toHaveBeenCalledWith( expect(service.emailClient.sendEmail).toHaveBeenCalledWith(
'owner@example.com', "owner@example.com",
'Payment Method Updated - Test Item', "Payment Method Updated - Test Item",
expect.any(String) expect.any(String),
); );
}); });
}); });
describe('sendPayoutFailedNotification', () => { describe("sendPayoutFailedNotification", () => {
it('should send payout failed notification to owner', async () => { it("should send payout failed notification to owner", async () => {
const result = await service.sendPayoutFailedNotification('owner@example.com', { const result = await service.sendPayoutFailedNotification(
ownerName: 'John', "owner@example.com",
payoutAmount: 50.00, {
failureMessage: 'Bank account closed', ownerName: "John",
actionRequired: 'Please update your bank account', payoutAmount: 50.0,
failureCode: 'account_closed', failureMessage: "Bank account closed",
actionRequired: "Please update your bank account",
failureCode: "account_closed",
requiresBankUpdate: true, requiresBankUpdate: true,
}); },
);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
'payoutFailedToOwner', "payoutFailedToOwner",
expect.objectContaining({ expect.objectContaining({
ownerName: 'John', ownerName: "John",
payoutAmount: '50.00', payoutAmount: "50.00",
failureCode: 'account_closed', failureCode: "account_closed",
requiresBankUpdate: true, requiresBankUpdate: true,
}) }),
); );
}); });
}); });
describe('sendAccountDisconnectedEmail', () => { describe("sendAccountDisconnectedEmail", () => {
it('should send account disconnected notification', async () => { it("should send account disconnected notification", async () => {
const result = await service.sendAccountDisconnectedEmail('owner@example.com', { const result = await service.sendAccountDisconnectedEmail(
ownerName: 'John', "owner@example.com",
{
ownerName: "John",
hasPendingPayouts: true, hasPendingPayouts: true,
pendingPayoutCount: 3, pendingPayoutCount: 3,
}); },
);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
'accountDisconnectedToOwner', "accountDisconnectedToOwner",
expect.objectContaining({ expect.objectContaining({
hasPendingPayouts: true, hasPendingPayouts: true,
pendingPayoutCount: 3, pendingPayoutCount: 3,
}) }),
); );
}); });
it('should use default values for missing params', async () => { it("should use default values for missing params", async () => {
await service.sendAccountDisconnectedEmail('owner@example.com', {}); await service.sendAccountDisconnectedEmail("owner@example.com", {});
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
'accountDisconnectedToOwner', "accountDisconnectedToOwner",
expect.objectContaining({ expect.objectContaining({
ownerName: 'there', ownerName: "there",
hasPendingPayouts: false, hasPendingPayouts: false,
pendingPayoutCount: 0, pendingPayoutCount: 0,
}) }),
); );
}); });
}); });
describe('sendPayoutsDisabledEmail', () => { describe("sendPayoutsDisabledEmail", () => {
it('should send payouts disabled notification', async () => { it("should send payouts disabled notification", async () => {
const result = await service.sendPayoutsDisabledEmail('owner@example.com', { const result = await service.sendPayoutsDisabledEmail(
ownerName: 'John', "owner@example.com",
disabledReason: 'Verification required', {
}); ownerName: "John",
disabledReason: "Verification required",
},
);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(service.emailClient.sendEmail).toHaveBeenCalledWith( expect(service.emailClient.sendEmail).toHaveBeenCalledWith(
'owner@example.com', "owner@example.com",
'Action Required: Your payouts have been paused - Village Share', "Action Required: Your payouts have been paused - Village Share",
expect.any(String) expect.any(String),
); );
}); });
}); });
describe('sendDisputeAlertEmail', () => { describe("sendDisputeAlertEmail", () => {
it('should send dispute alert to admin', async () => { it("should send dispute alert to admin", async () => {
const result = await service.sendDisputeAlertEmail({ const result = await service.sendDisputeAlertEmail({
rentalId: 'rental-123', rentalId: "rental-123",
amount: 50.00, amount: 50.0,
reason: 'fraudulent', reason: "fraudulent",
evidenceDueBy: new Date(), evidenceDueBy: new Date(),
renterName: 'Renter Name', renterName: "Renter Name",
renterEmail: 'renter@example.com', renterEmail: "renter@example.com",
ownerName: 'Owner Name', ownerName: "Owner Name",
ownerEmail: 'owner@example.com', ownerEmail: "owner@example.com",
itemName: 'Test Item', itemName: "Test Item",
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(service.emailClient.sendEmail).toHaveBeenCalledWith( expect(service.emailClient.sendEmail).toHaveBeenCalledWith(
'admin@example.com', "admin@example.com",
'URGENT: Payment Dispute - Rental #rental-123', "URGENT: Payment Dispute - Rental #rental-123",
expect.any(String) expect.any(String),
); );
}); });
}); });
describe('sendDisputeLostAlertEmail', () => { describe("sendDisputeLostAlertEmail", () => {
it('should send dispute lost alert to admin', async () => { it("should send dispute lost alert to admin", async () => {
const result = await service.sendDisputeLostAlertEmail({ const result = await service.sendDisputeLostAlertEmail({
rentalId: 'rental-123', rentalId: "rental-123",
amount: 50.00, amount: 50.0,
ownerPayoutAmount: 45.00, ownerPayoutAmount: 45.0,
ownerName: 'Owner Name', ownerName: "Owner Name",
ownerEmail: 'owner@example.com', ownerEmail: "owner@example.com",
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
'disputeLostAlertToAdmin', "disputeLostAlertToAdmin",
expect.objectContaining({ expect.objectContaining({
rentalId: 'rental-123', rentalId: "rental-123",
amount: '50.00', amount: "50.00",
ownerPayoutAmount: '45.00', ownerPayoutAmount: "45.00",
}) }),
); );
}); });
}); });
describe('formatDisputeReason', () => { describe("formatDisputeReason", () => {
it('should format known dispute reasons', () => { it("should format known dispute reasons", () => {
expect(service.formatDisputeReason('fraudulent')).toBe('Fraudulent transaction'); expect(service.formatDisputeReason("fraudulent")).toBe(
expect(service.formatDisputeReason('product_not_received')).toBe('Product not received'); "Fraudulent transaction",
expect(service.formatDisputeReason('duplicate')).toBe('Duplicate charge'); );
expect(service.formatDisputeReason("product_not_received")).toBe(
"Product not received",
);
expect(service.formatDisputeReason("duplicate")).toBe("Duplicate charge");
}); });
it('should return original reason for unknown reasons', () => { it("should return original reason for unknown reasons", () => {
expect(service.formatDisputeReason('unknown_reason')).toBe('unknown_reason'); expect(service.formatDisputeReason("unknown_reason")).toBe(
"unknown_reason",
);
}); });
it('should return "Unknown reason" for null/undefined', () => { it('should return "Unknown reason" for null/undefined', () => {
expect(service.formatDisputeReason(null)).toBe('Unknown reason'); expect(service.formatDisputeReason(null)).toBe("Unknown reason");
expect(service.formatDisputeReason(undefined)).toBe('Unknown reason'); expect(service.formatDisputeReason(undefined)).toBe("Unknown reason");
}); });
}); });
}); });

View File

@@ -1,27 +1,29 @@
// Mock dependencies // Mock dependencies
jest.mock('../../../../../services/email/core/EmailClient', () => { jest.mock("../../../../../services/email/core/EmailClient", () => {
return jest.fn().mockImplementation(() => ({ return jest.fn().mockImplementation(() => ({
initialize: jest.fn().mockResolvedValue(), initialize: jest.fn().mockResolvedValue(),
sendEmail: jest.fn().mockResolvedValue({ success: true, messageId: 'msg-123' }), sendEmail: jest
.fn()
.mockResolvedValue({ success: true, messageId: "msg-123" }),
})); }));
}); });
jest.mock('../../../../../services/email/core/TemplateManager', () => { jest.mock("../../../../../services/email/core/TemplateManager", () => {
return jest.fn().mockImplementation(() => ({ return jest.fn().mockImplementation(() => ({
initialize: jest.fn().mockResolvedValue(), initialize: jest.fn().mockResolvedValue(),
renderTemplate: jest.fn().mockResolvedValue('<html>Test</html>'), renderTemplate: jest.fn().mockResolvedValue("<html>Test</html>"),
})); }));
}); });
jest.mock('../../../../../utils/logger', () => ({ jest.mock("../../../../../utils/logger", () => ({
info: jest.fn(), info: jest.fn(),
error: jest.fn(), error: jest.fn(),
warn: jest.fn(), warn: jest.fn(),
})); }));
const UserEngagementEmailService = require('../../../../../services/email/domain/UserEngagementEmailService'); const UserEngagementEmailService = require("../../../../../services/email/domain/UserEngagementEmailService");
describe('UserEngagementEmailService', () => { describe("UserEngagementEmailService", () => {
let service; let service;
const originalEnv = process.env; const originalEnv = process.env;
@@ -29,8 +31,8 @@ describe('UserEngagementEmailService', () => {
jest.clearAllMocks(); jest.clearAllMocks();
process.env = { process.env = {
...originalEnv, ...originalEnv,
FRONTEND_URL: 'http://localhost:3000', FRONTEND_URL: "http://localhost:3000",
SUPPORT_EMAIL: 'support@villageshare.com', CUSTOMER_SUPPORT_EMAIL: "support@email.com",
}; };
service = new UserEngagementEmailService(); service = new UserEngagementEmailService();
}); });
@@ -39,8 +41,8 @@ describe('UserEngagementEmailService', () => {
process.env = originalEnv; process.env = originalEnv;
}); });
describe('initialize', () => { describe("initialize", () => {
it('should initialize only once', async () => { it("should initialize only once", async () => {
await service.initialize(); await service.initialize();
await service.initialize(); await service.initialize();
@@ -49,148 +51,176 @@ describe('UserEngagementEmailService', () => {
}); });
}); });
describe('sendFirstListingCelebrationEmail', () => { describe("sendFirstListingCelebrationEmail", () => {
const owner = { firstName: 'John', email: 'john@example.com' }; const owner = { firstName: "John", email: "john@example.com" };
const item = { id: 123, name: 'Power Drill' }; const item = { id: 123, name: "Power Drill" };
it('should send first listing celebration email with correct variables', async () => { it("should send first listing celebration email with correct variables", async () => {
const result = await service.sendFirstListingCelebrationEmail(owner, item); const result = await service.sendFirstListingCelebrationEmail(
owner,
item,
);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
'firstListingCelebrationToOwner', "firstListingCelebrationToOwner",
expect.objectContaining({ expect.objectContaining({
ownerName: 'John', ownerName: "John",
itemName: 'Power Drill', itemName: "Power Drill",
itemId: 123, itemId: 123,
viewItemUrl: 'http://localhost:3000/items/123', viewItemUrl: "http://localhost:3000/items/123",
}) }),
); );
expect(service.emailClient.sendEmail).toHaveBeenCalledWith( expect(service.emailClient.sendEmail).toHaveBeenCalledWith(
'john@example.com', "john@example.com",
'Congratulations! Your first item is live on Village Share', "Congratulations! Your first item is live on Village Share",
expect.any(String) expect.any(String),
); );
}); });
it('should use default name when firstName is missing', async () => { it("should use default name when firstName is missing", async () => {
const ownerNoName = { email: 'john@example.com' }; const ownerNoName = { email: "john@example.com" };
await service.sendFirstListingCelebrationEmail(ownerNoName, item); await service.sendFirstListingCelebrationEmail(ownerNoName, item);
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
'firstListingCelebrationToOwner', "firstListingCelebrationToOwner",
expect.objectContaining({ ownerName: 'there' }) expect.objectContaining({ ownerName: "there" }),
); );
}); });
it('should handle errors gracefully', async () => { it("should handle errors gracefully", async () => {
service.templateManager.renderTemplate.mockRejectedValueOnce(new Error('Template error')); service.templateManager.renderTemplate.mockRejectedValueOnce(
new Error("Template error"),
);
const result = await service.sendFirstListingCelebrationEmail(owner, item); const result = await service.sendFirstListingCelebrationEmail(
owner,
item,
);
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toBe('Template error'); expect(result.error).toBe("Template error");
}); });
}); });
describe('sendItemDeletionNotificationToOwner', () => { describe("sendItemDeletionNotificationToOwner", () => {
const owner = { firstName: 'John', email: 'john@example.com' }; const owner = { firstName: "John", email: "john@example.com" };
const item = { id: 123, name: 'Power Drill' }; const item = { id: 123, name: "Power Drill" };
const deletionReason = 'Violated community guidelines'; const deletionReason = "Violated community guidelines";
it('should send item deletion notification with correct variables', async () => { it("should send item deletion notification with correct variables", async () => {
const result = await service.sendItemDeletionNotificationToOwner( const result = await service.sendItemDeletionNotificationToOwner(
owner, owner,
item, item,
deletionReason deletionReason,
); );
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
'itemDeletionToOwner', "itemDeletionToOwner",
expect.objectContaining({ expect.objectContaining({
ownerName: 'John', ownerName: "John",
itemName: 'Power Drill', itemName: "Power Drill",
deletionReason: 'Violated community guidelines', deletionReason: "Violated community guidelines",
supportEmail: 'support@villageshare.com', supportEmail: "support@email.com",
dashboardUrl: 'http://localhost:3000/owning', dashboardUrl: "http://localhost:3000/owning",
}) }),
); );
expect(service.emailClient.sendEmail).toHaveBeenCalledWith( expect(service.emailClient.sendEmail).toHaveBeenCalledWith(
'john@example.com', "john@example.com",
'Important: Your listing "Power Drill" has been removed', 'Important: Your listing "Power Drill" has been removed',
expect.any(String) expect.any(String),
); );
}); });
it('should use default name when firstName is missing', async () => { it("should use default name when firstName is missing", async () => {
const ownerNoName = { email: 'john@example.com' }; const ownerNoName = { email: "john@example.com" };
await service.sendItemDeletionNotificationToOwner(ownerNoName, item, deletionReason); await service.sendItemDeletionNotificationToOwner(
ownerNoName,
item,
deletionReason,
);
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
'itemDeletionToOwner', "itemDeletionToOwner",
expect.objectContaining({ ownerName: 'there' }) expect.objectContaining({ ownerName: "there" }),
); );
}); });
it('should handle errors gracefully', async () => { it("should handle errors gracefully", async () => {
service.emailClient.sendEmail.mockRejectedValueOnce(new Error('Send error')); service.emailClient.sendEmail.mockRejectedValueOnce(
new Error("Send error"),
);
const result = await service.sendItemDeletionNotificationToOwner( const result = await service.sendItemDeletionNotificationToOwner(
owner, owner,
item, item,
deletionReason deletionReason,
); );
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toBe('Send error'); expect(result.error).toBe("Send error");
}); });
}); });
describe('sendUserBannedNotification', () => { describe("sendUserBannedNotification", () => {
const bannedUser = { firstName: 'John', email: 'john@example.com' }; const bannedUser = { firstName: "John", email: "john@example.com" };
const admin = { firstName: 'Admin', lastName: 'User' }; const admin = { firstName: "Admin", lastName: "User" };
const banReason = 'Multiple policy violations'; const banReason = "Multiple policy violations";
it('should send user banned notification with correct variables', async () => { it("should send user banned notification with correct variables", async () => {
const result = await service.sendUserBannedNotification(bannedUser, admin, banReason); const result = await service.sendUserBannedNotification(
bannedUser,
admin,
banReason,
);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
'userBannedNotification', "userBannedNotification",
expect.objectContaining({ expect.objectContaining({
userName: 'John', userName: "John",
banReason: 'Multiple policy violations', banReason: "Multiple policy violations",
supportEmail: 'support@villageshare.com', supportEmail: "support@email.com",
}) }),
); );
expect(service.emailClient.sendEmail).toHaveBeenCalledWith( expect(service.emailClient.sendEmail).toHaveBeenCalledWith(
'john@example.com', "john@example.com",
'Important: Your Village Share Account Has Been Suspended', "Important: Your Village Share Account Has Been Suspended",
expect.any(String) expect.any(String),
); );
}); });
it('should use default name when firstName is missing', async () => { it("should use default name when firstName is missing", async () => {
const bannedUserNoName = { email: 'john@example.com' }; const bannedUserNoName = { email: "john@example.com" };
await service.sendUserBannedNotification(bannedUserNoName, admin, banReason); await service.sendUserBannedNotification(
bannedUserNoName,
admin,
banReason,
);
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
'userBannedNotification', "userBannedNotification",
expect.objectContaining({ userName: 'there' }) expect.objectContaining({ userName: "there" }),
); );
}); });
it('should handle errors gracefully', async () => { it("should handle errors gracefully", async () => {
service.templateManager.renderTemplate.mockRejectedValueOnce(new Error('Template error')); service.templateManager.renderTemplate.mockRejectedValueOnce(
new Error("Template error"),
);
const result = await service.sendUserBannedNotification(bannedUser, admin, banReason); const result = await service.sendUserBannedNotification(
bannedUser,
admin,
banReason,
);
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toBe('Template error'); expect(result.error).toBe("Template error");
}); });
}); });
}); });

View File

@@ -1,46 +0,0 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).

View File

@@ -5,19 +5,25 @@
* automatic token verification and manual code entry. * automatic token verification and manual code entry.
*/ */
import React from 'react'; import React from "react";
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'; import {
import userEvent from '@testing-library/user-event'; render,
import { vi, type MockedFunction } from 'vitest'; screen,
import { MemoryRouter, Routes, Route } from 'react-router'; waitFor,
import VerifyEmail from '../../pages/VerifyEmail'; fireEvent,
import { useAuth } from '../../contexts/AuthContext'; act,
import { authAPI } from '../../services/api'; } from "@testing-library/react";
import { mockUser, mockUnverifiedUser } from '../../mocks/handlers'; import userEvent from "@testing-library/user-event";
import { vi, type MockedFunction } from "vitest";
import { MemoryRouter, Routes, Route } from "react-router";
import VerifyEmail from "../../pages/VerifyEmail";
import { useAuth } from "../../contexts/AuthContext";
import { authAPI } from "../../services/api";
import { mockUser, mockUnverifiedUser } from "../../mocks/handlers";
// Mock dependencies // Mock dependencies
vi.mock('../../contexts/AuthContext'); vi.mock("../../contexts/AuthContext");
vi.mock('../../services/api', () => ({ vi.mock("../../services/api", () => ({
authAPI: { authAPI: {
verifyEmail: vi.fn(), verifyEmail: vi.fn(),
resendVerification: vi.fn(), resendVerification: vi.fn(),
@@ -25,8 +31,8 @@ vi.mock('../../services/api', () => ({
})); }));
const mockNavigate = vi.fn(); const mockNavigate = vi.fn();
vi.mock('react-router', async (importOriginal) => { vi.mock("react-router", async (importOriginal) => {
const actual = await importOriginal<typeof import('react-router')>(); const actual = await importOriginal<typeof import("react-router")>();
return { return {
...actual, ...actual,
useNavigate: () => mockNavigate, useNavigate: () => mockNavigate,
@@ -34,16 +40,23 @@ vi.mock('react-router', async (importOriginal) => {
}); });
const mockedUseAuth = useAuth as MockedFunction<typeof useAuth>; const mockedUseAuth = useAuth as MockedFunction<typeof useAuth>;
const mockedVerifyEmail = authAPI.verifyEmail as MockedFunction<typeof authAPI.verifyEmail>; const mockedVerifyEmail = authAPI.verifyEmail as MockedFunction<
const mockedResendVerification = authAPI.resendVerification as MockedFunction<typeof authAPI.resendVerification>; typeof authAPI.verifyEmail
>;
const mockedResendVerification = authAPI.resendVerification as MockedFunction<
typeof authAPI.resendVerification
>;
// Helper to render VerifyEmail with route params // Helper to render VerifyEmail with route params
const renderVerifyEmail = (searchParams: string = '', authOverrides: Partial<ReturnType<typeof useAuth>> = {}) => { const renderVerifyEmail = (
searchParams: string = "",
authOverrides: Partial<ReturnType<typeof useAuth>> = {},
) => {
mockedUseAuth.mockReturnValue({ mockedUseAuth.mockReturnValue({
user: mockUnverifiedUser, user: mockUnverifiedUser,
loading: false, loading: false,
showAuthModal: false, showAuthModal: false,
authModalMode: 'login', authModalMode: "login",
login: vi.fn(), login: vi.fn(),
register: vi.fn(), register: vi.fn(),
googleLogin: vi.fn(), googleLogin: vi.fn(),
@@ -60,11 +73,11 @@ const renderVerifyEmail = (searchParams: string = '', authOverrides: Partial<Ret
<Routes> <Routes>
<Route path="/verify-email" element={<VerifyEmail />} /> <Route path="/verify-email" element={<VerifyEmail />} />
</Routes> </Routes>
</MemoryRouter> </MemoryRouter>,
); );
}; };
describe('VerifyEmail', () => { describe("VerifyEmail", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.useFakeTimers({ shouldAdvanceTime: true }); vi.useFakeTimers({ shouldAdvanceTime: true });
@@ -76,77 +89,85 @@ describe('VerifyEmail', () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
describe('Initial Loading State', () => { describe("Initial Loading State", () => {
it('shows loading state while auth is initializing', async () => { it("shows loading state while auth is initializing", async () => {
renderVerifyEmail('', { loading: true }); renderVerifyEmail("", { loading: true });
expect(screen.getByRole('status')).toBeInTheDocument(); expect(screen.getByRole("status")).toBeInTheDocument();
// There are multiple "Loading..." elements - the spinner's visually-hidden text and the heading // There are multiple "Loading..." elements - the spinner's visually-hidden text and the heading
expect(screen.getAllByText('Loading...').length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText("Loading...").length).toBeGreaterThanOrEqual(
}); 1,
});
describe('Authentication Check', () => {
it('redirects unauthenticated users to login', async () => {
renderVerifyEmail('', { user: null });
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith(
expect.stringContaining('/?login=true&redirect='),
{ replace: true }
); );
}); });
}); });
it('redirects unauthenticated users with token to login with return URL', async () => { describe("Authentication Check", () => {
renderVerifyEmail('?token=test-token-123', { user: null }); it("redirects unauthenticated users to login", async () => {
renderVerifyEmail("", { user: null });
await waitFor(() => { await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith( expect(mockNavigate).toHaveBeenCalledWith(
expect.stringContaining('login=true'), expect.stringContaining("/?login=true&redirect="),
{ replace: true } { replace: true },
);
});
});
it("redirects unauthenticated users with token to login with return URL", async () => {
renderVerifyEmail("?token=test-token-123", { user: null });
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith(
expect.stringContaining("login=true"),
{ replace: true },
); );
expect(mockNavigate).toHaveBeenCalledWith( expect(mockNavigate).toHaveBeenCalledWith(
expect.stringContaining('verify-email'), expect.stringContaining("verify-email"),
{ replace: true } { replace: true },
); );
}); });
}); });
}); });
describe('Auto-Verification with Token', () => { describe("Auto-Verification with Token", () => {
it('auto-verifies when token present in URL', async () => { it("auto-verifies when token present in URL", async () => {
renderVerifyEmail('?token=valid-token-123'); renderVerifyEmail("?token=valid-token-123");
await waitFor(() => { await waitFor(() => {
expect(mockedVerifyEmail).toHaveBeenCalledWith('valid-token-123'); expect(mockedVerifyEmail).toHaveBeenCalledWith("valid-token-123");
}); });
}); });
it('shows success state after successful verification', async () => { it("shows success state after successful verification", async () => {
const mockCheckAuth = vi.fn(); const mockCheckAuth = vi.fn();
renderVerifyEmail('?token=valid-token', { checkAuth: mockCheckAuth }); renderVerifyEmail("?token=valid-token", { checkAuth: mockCheckAuth });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Email Verified Successfully!')).toBeInTheDocument(); expect(
screen.getByText("Email Verified Successfully!"),
).toBeInTheDocument();
}); });
expect(mockCheckAuth).toHaveBeenCalled(); expect(mockCheckAuth).toHaveBeenCalled();
}); });
it('shows success state immediately for already verified user', async () => { it("shows success state immediately for already verified user", async () => {
renderVerifyEmail('', { user: mockUser }); renderVerifyEmail("", { user: mockUser });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Email Verified Successfully!')).toBeInTheDocument(); expect(
screen.getByText("Email Verified Successfully!"),
).toBeInTheDocument();
}); });
}); });
it('auto-redirects to home after successful verification', async () => { it("auto-redirects to home after successful verification", async () => {
renderVerifyEmail('?token=valid-token'); renderVerifyEmail("?token=valid-token");
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Email Verified Successfully!')).toBeInTheDocument(); expect(
screen.getByText("Email Verified Successfully!"),
).toBeInTheDocument();
}); });
// Advance timers to trigger auto-redirect (3 seconds) // Advance timers to trigger auto-redirect (3 seconds)
@@ -155,215 +176,228 @@ describe('VerifyEmail', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true }); expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true });
}); });
}); });
}); });
describe('Manual Code Entry', () => { describe("Manual Code Entry", () => {
it('shows manual code entry form when no token in URL', async () => { it("shows manual code entry form when no token in URL", async () => {
renderVerifyEmail(); renderVerifyEmail();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument(); expect(screen.getByText("Enter Verification Code")).toBeInTheDocument();
expect(screen.getByText('Enter the 6-digit code sent to your email')).toBeInTheDocument(); expect(
screen.getByText("Enter the 6-digit code sent to your email"),
).toBeInTheDocument();
}); });
// Check for 6 input fields // Check for 6 input fields
const inputs = screen.getAllByRole('textbox'); const inputs = screen.getAllByRole("textbox");
expect(inputs).toHaveLength(6); expect(inputs).toHaveLength(6);
}); });
it('handles 6-digit input with auto-focus', async () => { it("handles 6-digit input with auto-focus", async () => {
renderVerifyEmail(); renderVerifyEmail();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument(); expect(screen.getByText("Enter Verification Code")).toBeInTheDocument();
}); });
const inputs = screen.getAllByRole('textbox'); const inputs = screen.getAllByRole("textbox");
// Type first digit using fireEvent // Type first digit using fireEvent
fireEvent.change(inputs[0], { target: { value: '1' } }); fireEvent.change(inputs[0], { target: { value: "1" } });
expect(inputs[0]).toHaveValue('1'); expect(inputs[0]).toHaveValue("1");
// Focus should auto-move to next input // Focus should auto-move to next input
// (Note: actual focus behavior may depend on DOM focus events) // (actual focus behavior may depend on DOM focus events)
}); });
it('filters non-numeric input', async () => { it("filters non-numeric input", async () => {
renderVerifyEmail(); renderVerifyEmail();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument(); expect(screen.getByText("Enter Verification Code")).toBeInTheDocument();
}); });
const inputs = screen.getAllByRole('textbox'); const inputs = screen.getAllByRole("textbox");
// Try typing letters // Try typing letters
fireEvent.change(inputs[0], { target: { value: 'a' } }); fireEvent.change(inputs[0], { target: { value: "a" } });
expect(inputs[0]).toHaveValue(''); expect(inputs[0]).toHaveValue("");
// Try typing numbers // Try typing numbers
fireEvent.change(inputs[0], { target: { value: '5' } }); fireEvent.change(inputs[0], { target: { value: "5" } });
expect(inputs[0]).toHaveValue('5'); expect(inputs[0]).toHaveValue("5");
}); });
it('handles paste of 6-digit code', async () => { it("handles paste of 6-digit code", async () => {
renderVerifyEmail(); renderVerifyEmail();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument(); expect(screen.getByText("Enter Verification Code")).toBeInTheDocument();
}); });
const container = document.querySelector('.d-flex.justify-content-center.gap-2'); const container = document.querySelector(
".d-flex.justify-content-center.gap-2",
);
// Simulate paste event // Simulate paste event
const pasteEvent = new Event('paste', { bubbles: true, cancelable: true }) as any; const pasteEvent = new Event("paste", {
bubbles: true,
cancelable: true,
}) as any;
pasteEvent.clipboardData = { pasteEvent.clipboardData = {
getData: () => '123456', getData: () => "123456",
}; };
fireEvent(container!, pasteEvent); fireEvent(container!, pasteEvent);
await waitFor(() => { await waitFor(() => {
expect(mockedVerifyEmail).toHaveBeenCalledWith('123456'); expect(mockedVerifyEmail).toHaveBeenCalledWith("123456");
}); });
}); });
it('submits manual code on button click', async () => { it("submits manual code on button click", async () => {
// Make the verification hang to test the button state // Make the verification hang to test the button state
mockedVerifyEmail.mockImplementation(() => new Promise(() => {})); mockedVerifyEmail.mockImplementation(() => new Promise(() => {}));
renderVerifyEmail(); renderVerifyEmail();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument(); expect(screen.getByText("Enter Verification Code")).toBeInTheDocument();
}); });
const inputs = screen.getAllByRole('textbox'); const inputs = screen.getAllByRole("textbox");
// Fill in 5 digits (not 6 to avoid auto-submit) // Fill in 5 digits (not 6 to avoid auto-submit)
fireEvent.change(inputs[0], { target: { value: '1' } }); fireEvent.change(inputs[0], { target: { value: "1" } });
fireEvent.change(inputs[1], { target: { value: '2' } }); fireEvent.change(inputs[1], { target: { value: "2" } });
fireEvent.change(inputs[2], { target: { value: '3' } }); fireEvent.change(inputs[2], { target: { value: "3" } });
fireEvent.change(inputs[3], { target: { value: '4' } }); fireEvent.change(inputs[3], { target: { value: "4" } });
fireEvent.change(inputs[4], { target: { value: '5' } }); fireEvent.change(inputs[4], { target: { value: "5" } });
// Button should be disabled with only 5 digits // Button should be disabled with only 5 digits
const verifyButton = screen.getByRole('button', { name: /verify email/i }); const verifyButton = screen.getByRole("button", {
name: /verify email/i,
});
expect(verifyButton).toBeDisabled(); expect(verifyButton).toBeDisabled();
// Now fill in the 6th digit - this will auto-submit // Now fill in the 6th digit - this will auto-submit
fireEvent.change(inputs[5], { target: { value: '6' } }); fireEvent.change(inputs[5], { target: { value: "6" } });
// The component auto-submits when 6 digits are entered // The component auto-submits when 6 digits are entered
await waitFor(() => { await waitFor(() => {
expect(mockedVerifyEmail).toHaveBeenCalledWith('123456'); expect(mockedVerifyEmail).toHaveBeenCalledWith("123456");
}); });
}); });
it('disables verify button when code incomplete', async () => { it("disables verify button when code incomplete", async () => {
renderVerifyEmail(); renderVerifyEmail();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument(); expect(screen.getByText("Enter Verification Code")).toBeInTheDocument();
}); });
const verifyButton = screen.getByRole('button', { name: /verify email/i }); const verifyButton = screen.getByRole("button", {
name: /verify email/i,
});
expect(verifyButton).toBeDisabled(); expect(verifyButton).toBeDisabled();
}); });
it('backspace moves focus to previous input', async () => { it("backspace moves focus to previous input", async () => {
renderVerifyEmail(); renderVerifyEmail();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument(); expect(screen.getByText("Enter Verification Code")).toBeInTheDocument();
}); });
const inputs = screen.getAllByRole('textbox'); const inputs = screen.getAllByRole("textbox");
// Fill first input and move to second // Fill first input and move to second
fireEvent.change(inputs[0], { target: { value: '1' } }); fireEvent.change(inputs[0], { target: { value: "1" } });
fireEvent.change(inputs[1], { target: { value: '2' } }); fireEvent.change(inputs[1], { target: { value: "2" } });
// Clear second input and press backspace // Clear second input and press backspace
fireEvent.change(inputs[1], { target: { value: '' } }); fireEvent.change(inputs[1], { target: { value: "" } });
fireEvent.keyDown(inputs[1], { key: 'Backspace' }); fireEvent.keyDown(inputs[1], { key: "Backspace" });
// The component handles this by focusing previous input // The component handles this by focusing previous input
}); });
}); });
describe('Error Handling', () => { describe("Error Handling", () => {
it('displays EXPIRED error message', async () => { it("displays EXPIRED error message", async () => {
mockedVerifyEmail.mockRejectedValue({ mockedVerifyEmail.mockRejectedValue({
response: { data: { code: 'VERIFICATION_EXPIRED' } }, response: { data: { code: "VERIFICATION_EXPIRED" } },
}); });
renderVerifyEmail('?token=expired-token'); renderVerifyEmail("?token=expired-token");
await waitFor(() => { await waitFor(() => {
expect(screen.getByText(/verification code has expired/i)).toBeInTheDocument(); expect(
screen.getByText(/verification code has expired/i),
).toBeInTheDocument();
}); });
}); });
it('displays INVALID error message', async () => { it("displays INVALID error message", async () => {
mockedVerifyEmail.mockRejectedValue({ mockedVerifyEmail.mockRejectedValue({
response: { data: { code: 'VERIFICATION_INVALID' } }, response: { data: { code: "VERIFICATION_INVALID" } },
}); });
renderVerifyEmail('?token=invalid-token'); renderVerifyEmail("?token=invalid-token");
await waitFor(() => { await waitFor(() => {
expect(screen.getByText(/code didn't match/i)).toBeInTheDocument(); expect(screen.getByText(/code didn't match/i)).toBeInTheDocument();
}); });
}); });
it('displays TOO_MANY_ATTEMPTS error message', async () => { it("displays TOO_MANY_ATTEMPTS error message", async () => {
mockedVerifyEmail.mockRejectedValue({ mockedVerifyEmail.mockRejectedValue({
response: { data: { code: 'TOO_MANY_ATTEMPTS' } }, response: { data: { code: "TOO_MANY_ATTEMPTS" } },
}); });
renderVerifyEmail('?token=blocked-token'); renderVerifyEmail("?token=blocked-token");
await waitFor(() => { await waitFor(() => {
expect(screen.getByText(/too many attempts/i)).toBeInTheDocument(); expect(screen.getByText(/too many attempts/i)).toBeInTheDocument();
}); });
}); });
it('displays ALREADY_VERIFIED error message', async () => { it("displays ALREADY_VERIFIED error message", async () => {
mockedVerifyEmail.mockRejectedValue({ mockedVerifyEmail.mockRejectedValue({
response: { data: { code: 'ALREADY_VERIFIED' } }, response: { data: { code: "ALREADY_VERIFIED" } },
}); });
renderVerifyEmail('?token=already-verified-token'); renderVerifyEmail("?token=already-verified-token");
await waitFor(() => { await waitFor(() => {
expect(screen.getByText(/already verified/i)).toBeInTheDocument(); expect(screen.getByText(/already verified/i)).toBeInTheDocument();
}); });
}); });
it('clears input on error', async () => { it("clears input on error", async () => {
mockedVerifyEmail.mockRejectedValue({ mockedVerifyEmail.mockRejectedValue({
response: { data: { code: 'VERIFICATION_INVALID' } }, response: { data: { code: "VERIFICATION_INVALID" } },
}); });
renderVerifyEmail(); renderVerifyEmail();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument(); expect(screen.getByText("Enter Verification Code")).toBeInTheDocument();
}); });
const inputs = screen.getAllByRole('textbox'); const inputs = screen.getAllByRole("textbox");
// Fill all digits - this will auto-submit due to the component behavior // Fill all digits - this will auto-submit due to the component behavior
fireEvent.change(inputs[0], { target: { value: '1' } }); fireEvent.change(inputs[0], { target: { value: "1" } });
fireEvent.change(inputs[1], { target: { value: '2' } }); fireEvent.change(inputs[1], { target: { value: "2" } });
fireEvent.change(inputs[2], { target: { value: '3' } }); fireEvent.change(inputs[2], { target: { value: "3" } });
fireEvent.change(inputs[3], { target: { value: '4' } }); fireEvent.change(inputs[3], { target: { value: "4" } });
fireEvent.change(inputs[4], { target: { value: '5' } }); fireEvent.change(inputs[4], { target: { value: "5" } });
fireEvent.change(inputs[5], { target: { value: '6' } }); fireEvent.change(inputs[5], { target: { value: "6" } });
// Wait for error message to appear // Wait for error message to appear
await waitFor(() => { await waitFor(() => {
@@ -372,33 +406,35 @@ describe('VerifyEmail', () => {
// Inputs should be cleared after error // Inputs should be cleared after error
await waitFor(() => { await waitFor(() => {
const updatedInputs = screen.getAllByRole('textbox'); const updatedInputs = screen.getAllByRole("textbox");
updatedInputs.forEach((input) => { updatedInputs.forEach((input) => {
expect(input).toHaveValue(''); expect(input).toHaveValue("");
}); });
}); });
}); });
}); });
describe('Resend Verification', () => { describe("Resend Verification", () => {
it('shows resend button', async () => { it("shows resend button", async () => {
renderVerifyEmail(); renderVerifyEmail();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument(); expect(screen.getByText("Enter Verification Code")).toBeInTheDocument();
}); });
expect(screen.getByRole('button', { name: /send code/i })).toBeInTheDocument(); expect(
screen.getByRole("button", { name: /send code/i }),
).toBeInTheDocument();
}); });
it('starts 60-second cooldown after resend', async () => { it("starts 60-second cooldown after resend", async () => {
renderVerifyEmail(); renderVerifyEmail();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument(); expect(screen.getByText("Enter Verification Code")).toBeInTheDocument();
}); });
const resendButton = screen.getByRole('button', { name: /send code/i }); const resendButton = screen.getByRole("button", { name: /send code/i });
fireEvent.click(resendButton); fireEvent.click(resendButton);
await waitFor(() => { await waitFor(() => {
@@ -408,32 +444,32 @@ describe('VerifyEmail', () => {
expect(mockedResendVerification).toHaveBeenCalled(); expect(mockedResendVerification).toHaveBeenCalled();
}); });
it('disables resend during cooldown', async () => { it("disables resend during cooldown", async () => {
renderVerifyEmail(); renderVerifyEmail();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument(); expect(screen.getByText("Enter Verification Code")).toBeInTheDocument();
}); });
const resendButton = screen.getByRole('button', { name: /send code/i }); const resendButton = screen.getByRole("button", { name: /send code/i });
fireEvent.click(resendButton); fireEvent.click(resendButton);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText(/resend in 60s/i)).toBeInTheDocument(); expect(screen.getByText(/resend in 60s/i)).toBeInTheDocument();
}); });
const cooldownButton = screen.getByRole('button', { name: /resend in/i }); const cooldownButton = screen.getByRole("button", { name: /resend in/i });
expect(cooldownButton).toBeDisabled(); expect(cooldownButton).toBeDisabled();
}); });
it('shows success message after resend', async () => { it("shows success message after resend", async () => {
renderVerifyEmail(); renderVerifyEmail();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument(); expect(screen.getByText("Enter Verification Code")).toBeInTheDocument();
}); });
const resendButton = screen.getByRole('button', { name: /send code/i }); const resendButton = screen.getByRole("button", { name: /send code/i });
fireEvent.click(resendButton); fireEvent.click(resendButton);
await waitFor(() => { await waitFor(() => {
@@ -441,14 +477,14 @@ describe('VerifyEmail', () => {
}); });
}); });
it('counts down timer', async () => { it("counts down timer", async () => {
renderVerifyEmail(); renderVerifyEmail();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument(); expect(screen.getByText("Enter Verification Code")).toBeInTheDocument();
}); });
const resendButton = screen.getByRole('button', { name: /send code/i }); const resendButton = screen.getByRole("button", { name: /send code/i });
fireEvent.click(resendButton); fireEvent.click(resendButton);
await waitFor(() => { await waitFor(() => {
@@ -457,25 +493,30 @@ describe('VerifyEmail', () => {
// With shouldAdvanceTime: true, the timer will automatically count down // With shouldAdvanceTime: true, the timer will automatically count down
// Wait for the countdown to show a lower value // Wait for the countdown to show a lower value
await waitFor(() => { await waitFor(
() => {
// Timer should have counted down from 60s to something less // Timer should have counted down from 60s to something less
const resendText = screen.getByRole('button', { name: /resend in \d+s/i }).textContent; const resendText = screen.getByRole("button", {
name: /resend in \d+s/i,
}).textContent;
expect(resendText).toMatch(/Resend in [0-5][0-9]s/); expect(resendText).toMatch(/Resend in [0-5][0-9]s/);
}, { timeout: 3000 }); },
{ timeout: 3000 },
);
}); });
it('handles resend error for already verified', async () => { it("handles resend error for already verified", async () => {
mockedResendVerification.mockRejectedValue({ mockedResendVerification.mockRejectedValue({
response: { data: { code: 'ALREADY_VERIFIED' } }, response: { data: { code: "ALREADY_VERIFIED" } },
}); });
renderVerifyEmail(); renderVerifyEmail();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument(); expect(screen.getByText("Enter Verification Code")).toBeInTheDocument();
}); });
const resendButton = screen.getByRole('button', { name: /send code/i }); const resendButton = screen.getByRole("button", { name: /send code/i });
fireEvent.click(resendButton); fireEvent.click(resendButton);
await waitFor(() => { await waitFor(() => {
@@ -483,7 +524,7 @@ describe('VerifyEmail', () => {
}); });
}); });
it('handles rate limit error (429)', async () => { it("handles rate limit error (429)", async () => {
mockedResendVerification.mockRejectedValue({ mockedResendVerification.mockRejectedValue({
response: { status: 429 }, response: { status: 429 },
}); });
@@ -491,39 +532,43 @@ describe('VerifyEmail', () => {
renderVerifyEmail(); renderVerifyEmail();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument(); expect(screen.getByText("Enter Verification Code")).toBeInTheDocument();
}); });
const resendButton = screen.getByRole('button', { name: /send code/i }); const resendButton = screen.getByRole("button", { name: /send code/i });
fireEvent.click(resendButton); fireEvent.click(resendButton);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText(/please wait before requesting/i)).toBeInTheDocument(); expect(
screen.getByText(/please wait before requesting/i),
).toBeInTheDocument();
}); });
}); });
}); });
describe('Navigation', () => { describe("Navigation", () => {
it('has return to home link', async () => { it("has return to home link", async () => {
renderVerifyEmail(); renderVerifyEmail();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument(); expect(screen.getByText("Enter Verification Code")).toBeInTheDocument();
}); });
const homeLink = screen.getByRole('link', { name: /return to home/i }); const homeLink = screen.getByRole("link", { name: /return to home/i });
expect(homeLink).toHaveAttribute('href', '/'); expect(homeLink).toHaveAttribute("href", "/");
}); });
it('has go to home link on success', async () => { it("has go to home link on success", async () => {
renderVerifyEmail('?token=valid-token'); renderVerifyEmail("?token=valid-token");
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Email Verified Successfully!')).toBeInTheDocument(); expect(
screen.getByText("Email Verified Successfully!"),
).toBeInTheDocument();
}); });
const homeLink = screen.getByRole('link', { name: /go to home page/i }); const homeLink = screen.getByRole("link", { name: /go to home page/i });
expect(homeLink).toHaveAttribute('href', '/'); expect(homeLink).toHaveAttribute("href", "/");
}); });
}); });
}); });

View File

@@ -5,8 +5,8 @@
* direct uploads, and signed URL generation for private content. * direct uploads, and signed URL generation for private content.
*/ */
import { vi, type Mocked } from 'vitest'; import { vi, type Mocked } from "vitest";
import api from '../../services/api'; import api from "../../services/api";
import { import {
getPublicImageUrl, getPublicImageUrl,
getPresignedUrl, getPresignedUrl,
@@ -16,10 +16,10 @@ import {
uploadFile, uploadFile,
getSignedUrl, getSignedUrl,
PresignedUrlResponse, PresignedUrlResponse,
} from '../../services/uploadService'; } from "../../services/uploadService";
// Mock the api module // Mock the api module
vi.mock('../../services/api'); vi.mock("../../services/api");
const mockedApi = api as Mocked<typeof api>; const mockedApi = api as Mocked<typeof api>;
@@ -29,16 +29,22 @@ class MockXMLHttpRequest {
status = 200; status = 200;
readyState = 4; readyState = 4;
responseText = ''; responseText = "";
upload = { upload = {
onprogress: null as ((e: { lengthComputable: boolean; loaded: number; total: number }) => void) | null, onprogress: null as
| ((e: {
lengthComputable: boolean;
loaded: number;
total: number;
}) => void)
| null,
}; };
onload: (() => void) | null = null; onload: (() => void) | null = null;
onerror: (() => void) | null = null; onerror: (() => void) | null = null;
private headers: Record<string, string> = {}; private headers: Record<string, string> = {};
private method = ''; private method = "";
private url = ''; private url = "";
constructor() { constructor() {
MockXMLHttpRequest.instances.push(this); MockXMLHttpRequest.instances.push(this);
@@ -58,8 +64,16 @@ class MockXMLHttpRequest {
// This allows promises to resolve without real delays // This allows promises to resolve without real delays
Promise.resolve().then(() => { Promise.resolve().then(() => {
if (this.upload.onprogress) { if (this.upload.onprogress) {
this.upload.onprogress({ lengthComputable: true, loaded: 50, total: 100 }); this.upload.onprogress({
this.upload.onprogress({ lengthComputable: true, loaded: 100, total: 100 }); lengthComputable: true,
loaded: 50,
total: 100,
});
this.upload.onprogress({
lengthComputable: true,
loaded: 100,
total: 100,
});
} }
if (this.onload) { if (this.onload) {
this.onload(); this.onload();
@@ -84,181 +98,222 @@ class MockXMLHttpRequest {
} }
static getLastInstance() { static getLastInstance() {
return MockXMLHttpRequest.instances[MockXMLHttpRequest.instances.length - 1]; return MockXMLHttpRequest.instances[
MockXMLHttpRequest.instances.length - 1
];
} }
} }
// Store original XMLHttpRequest // Store original XMLHttpRequest
const originalXMLHttpRequest = global.XMLHttpRequest; const originalXMLHttpRequest = global.XMLHttpRequest;
describe('Upload Service', () => { describe("Upload Service", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
MockXMLHttpRequest.reset(); MockXMLHttpRequest.reset();
// Reset environment variables using stubEnv for Vitest // Reset environment variables using stubEnv for Vitest
vi.stubEnv('VITE_S3_BUCKET', 'test-bucket'); vi.stubEnv("VITE_S3_BUCKET", "test-bucket");
vi.stubEnv('VITE_AWS_REGION', 'us-east-1'); vi.stubEnv("VITE_AWS_REGION", "us-east-1");
// Mock XMLHttpRequest globally // Mock XMLHttpRequest globally
(global as unknown as { XMLHttpRequest: typeof MockXMLHttpRequest }).XMLHttpRequest = MockXMLHttpRequest; (
global as unknown as { XMLHttpRequest: typeof MockXMLHttpRequest }
).XMLHttpRequest = MockXMLHttpRequest;
}); });
afterEach(() => { afterEach(() => {
// Restore original XMLHttpRequest // Restore original XMLHttpRequest
(global as unknown as { XMLHttpRequest: typeof XMLHttpRequest }).XMLHttpRequest = originalXMLHttpRequest; (
global as unknown as { XMLHttpRequest: typeof XMLHttpRequest }
).XMLHttpRequest = originalXMLHttpRequest;
}); });
describe('getPublicImageUrl', () => { describe("getPublicImageUrl", () => {
it('should return empty string for null input', () => { it("should return empty string for null input", () => {
expect(getPublicImageUrl(null)).toBe(''); expect(getPublicImageUrl(null)).toBe("");
}); });
it('should return empty string for undefined input', () => { it("should return empty string for undefined input", () => {
expect(getPublicImageUrl(undefined)).toBe(''); expect(getPublicImageUrl(undefined)).toBe("");
}); });
it('should return empty string for empty string input', () => { it("should return empty string for empty string input", () => {
expect(getPublicImageUrl('')).toBe(''); expect(getPublicImageUrl("")).toBe("");
}); });
it('should return full S3 URL unchanged', () => { it("should return full S3 URL unchanged", () => {
const fullUrl = 'https://bucket.s3.us-east-1.amazonaws.com/items/uuid.jpg'; const fullUrl =
"https://bucket.s3.us-east-1.amazonaws.com/items/uuid.jpg";
expect(getPublicImageUrl(fullUrl)).toBe(fullUrl); expect(getPublicImageUrl(fullUrl)).toBe(fullUrl);
}); });
it('should construct S3 URL from key', () => { it("should construct S3 URL from key", () => {
const key = 'items/550e8400-e29b-41d4-a716-446655440000.jpg'; const key = "items/550e8400-e29b-41d4-a716-446655440000.jpg";
const expectedUrl = 'https://test-bucket.s3.us-east-1.amazonaws.com/items/550e8400-e29b-41d4-a716-446655440000.jpg'; const expectedUrl =
"https://test-bucket.s3.us-east-1.amazonaws.com/items/550e8400-e29b-41d4-a716-446655440000.jpg";
expect(getPublicImageUrl(key)).toBe(expectedUrl); expect(getPublicImageUrl(key)).toBe(expectedUrl);
}); });
it('should handle profiles folder', () => { it("should handle profiles folder", () => {
const key = 'profiles/550e8400-e29b-41d4-a716-446655440000.jpg'; const key = "profiles/550e8400-e29b-41d4-a716-446655440000.jpg";
expect(getPublicImageUrl(key)).toContain('profiles/'); expect(getPublicImageUrl(key)).toContain("profiles/");
}); });
it('should handle forum folder', () => { it("should handle forum folder", () => {
const key = 'forum/550e8400-e29b-41d4-a716-446655440000.jpg'; const key = "forum/550e8400-e29b-41d4-a716-446655440000.jpg";
expect(getPublicImageUrl(key)).toContain('forum/'); expect(getPublicImageUrl(key)).toContain("forum/");
}); });
}); });
describe('getPresignedUrl', () => { describe("getPresignedUrl", () => {
const mockFile = new File(['test content'], 'photo.jpg', { type: 'image/jpeg' }); const mockFile = new File(["test content"], "photo.jpg", {
type: "image/jpeg",
});
const mockResponse: PresignedUrlResponse = { const mockResponse: PresignedUrlResponse = {
uploadUrl: 'https://presigned-url.s3.amazonaws.com/items/uuid.jpg?signature=abc', uploadUrl:
key: 'items/550e8400-e29b-41d4-a716-446655440000.jpg', "https://presigned-url.s3.amazonaws.com/items/uuid.jpg?signature=abc",
key: "items/550e8400-e29b-41d4-a716-446655440000.jpg",
stagingKey: null, stagingKey: null,
publicUrl: 'https://bucket.s3.amazonaws.com/items/550e8400-e29b-41d4-a716-446655440000.jpg', publicUrl:
"https://bucket.s3.amazonaws.com/items/550e8400-e29b-41d4-a716-446655440000.jpg",
expiresAt: new Date().toISOString(), expiresAt: new Date().toISOString(),
}; };
it('should request presigned URL with correct parameters', async () => { it("should request presigned URL with correct parameters", async () => {
mockedApi.post.mockResolvedValue({ data: mockResponse }); mockedApi.post.mockResolvedValue({ data: mockResponse });
const result = await getPresignedUrl('item', mockFile); const result = await getPresignedUrl("item", mockFile);
expect(mockedApi.post).toHaveBeenCalledWith('/upload/presign', { expect(mockedApi.post).toHaveBeenCalledWith("/upload/presign", {
uploadType: 'item', uploadType: "item",
contentType: 'image/jpeg', contentType: "image/jpeg",
fileName: 'photo.jpg', fileName: "photo.jpg",
fileSize: mockFile.size, fileSize: mockFile.size,
}); });
expect(result).toEqual(mockResponse); expect(result).toEqual(mockResponse);
}); });
it('should handle different upload types', async () => { it("should handle different upload types", async () => {
mockedApi.post.mockResolvedValue({ data: mockResponse }); mockedApi.post.mockResolvedValue({ data: mockResponse });
await getPresignedUrl('profile', mockFile); await getPresignedUrl("profile", mockFile);
expect(mockedApi.post).toHaveBeenCalledWith('/upload/presign', expect.objectContaining({ expect(mockedApi.post).toHaveBeenCalledWith(
uploadType: 'profile', "/upload/presign",
})); expect.objectContaining({
uploadType: "profile",
}),
);
await getPresignedUrl('message', mockFile); await getPresignedUrl("message", mockFile);
expect(mockedApi.post).toHaveBeenCalledWith('/upload/presign', expect.objectContaining({ expect(mockedApi.post).toHaveBeenCalledWith(
uploadType: 'message', "/upload/presign",
})); expect.objectContaining({
uploadType: "message",
}),
);
await getPresignedUrl('forum', mockFile); await getPresignedUrl("forum", mockFile);
expect(mockedApi.post).toHaveBeenCalledWith('/upload/presign', expect.objectContaining({ expect(mockedApi.post).toHaveBeenCalledWith(
uploadType: 'forum', "/upload/presign",
})); expect.objectContaining({
uploadType: "forum",
}),
);
await getPresignedUrl('condition-check', mockFile); await getPresignedUrl("condition-check", mockFile);
expect(mockedApi.post).toHaveBeenCalledWith('/upload/presign', expect.objectContaining({ expect(mockedApi.post).toHaveBeenCalledWith(
uploadType: 'condition-check', "/upload/presign",
})); expect.objectContaining({
uploadType: "condition-check",
}),
);
}); });
it('should propagate API errors', async () => { it("should propagate API errors", async () => {
const error = new Error('API error'); const error = new Error("API error");
mockedApi.post.mockRejectedValue(error); mockedApi.post.mockRejectedValue(error);
await expect(getPresignedUrl('item', mockFile)).rejects.toThrow('API error'); await expect(getPresignedUrl("item", mockFile)).rejects.toThrow(
"API error",
);
}); });
}); });
describe('getPresignedUrls', () => { describe("getPresignedUrls", () => {
const mockFiles = [ const mockFiles = [
new File(['test1'], 'photo1.jpg', { type: 'image/jpeg' }), new File(["test1"], "photo1.jpg", { type: "image/jpeg" }),
new File(['test2'], 'photo2.png', { type: 'image/png' }), new File(["test2"], "photo2.png", { type: "image/png" }),
]; ];
const mockResponses: PresignedUrlResponse[] = [ const mockResponses: PresignedUrlResponse[] = [
{ {
uploadUrl: 'https://presigned-url1.s3.amazonaws.com', uploadUrl: "https://presigned-url1.s3.amazonaws.com",
key: 'items/uuid1.jpg', key: "items/uuid1.jpg",
stagingKey: null, stagingKey: null,
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid1.jpg', publicUrl: "https://bucket.s3.amazonaws.com/items/uuid1.jpg",
expiresAt: new Date().toISOString(), expiresAt: new Date().toISOString(),
}, },
{ {
uploadUrl: 'https://presigned-url2.s3.amazonaws.com', uploadUrl: "https://presigned-url2.s3.amazonaws.com",
key: 'items/uuid2.png', key: "items/uuid2.png",
stagingKey: null, stagingKey: null,
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid2.png', publicUrl: "https://bucket.s3.amazonaws.com/items/uuid2.png",
expiresAt: new Date().toISOString(), expiresAt: new Date().toISOString(),
}, },
]; ];
it('should request batch presigned URLs', async () => { it("should request batch presigned URLs", async () => {
mockedApi.post.mockResolvedValue({ data: { uploads: mockResponses, baseKey: 'base-key' } }); mockedApi.post.mockResolvedValue({
data: { uploads: mockResponses, baseKey: "base-key" },
});
const result = await getPresignedUrls('item', mockFiles); const result = await getPresignedUrls("item", mockFiles);
expect(mockedApi.post).toHaveBeenCalledWith('/upload/presign-batch', { expect(mockedApi.post).toHaveBeenCalledWith("/upload/presign-batch", {
uploadType: 'item', uploadType: "item",
files: [ files: [
{ contentType: 'image/jpeg', fileName: 'photo1.jpg', fileSize: mockFiles[0].size }, {
{ contentType: 'image/png', fileName: 'photo2.png', fileSize: mockFiles[1].size }, contentType: "image/jpeg",
fileName: "photo1.jpg",
fileSize: mockFiles[0].size,
},
{
contentType: "image/png",
fileName: "photo2.png",
fileSize: mockFiles[1].size,
},
], ],
}); });
expect(result).toEqual({ uploads: mockResponses, baseKey: 'base-key' }); expect(result).toEqual({ uploads: mockResponses, baseKey: "base-key" });
}); });
it('should handle empty file array', async () => { it("should handle empty file array", async () => {
mockedApi.post.mockResolvedValue({ data: { uploads: [], baseKey: undefined } }); mockedApi.post.mockResolvedValue({
data: { uploads: [], baseKey: undefined },
});
const result = await getPresignedUrls('item', []); const result = await getPresignedUrls("item", []);
expect(result).toEqual({ uploads: [], baseKey: undefined }); expect(result).toEqual({ uploads: [], baseKey: undefined });
}); });
}); });
describe('uploadToS3', () => { describe("uploadToS3", () => {
const mockFile = new File(['test content'], 'photo.jpg', { type: 'image/jpeg' }); const mockFile = new File(["test content"], "photo.jpg", {
const mockUploadUrl = 'https://presigned-url.s3.amazonaws.com/items/uuid.jpg?signature=abc'; type: "image/jpeg",
});
const mockUploadUrl =
"https://presigned-url.s3.amazonaws.com/items/uuid.jpg?signature=abc";
it('should upload file successfully', async () => { it("should upload file successfully", async () => {
await uploadToS3(mockFile, mockUploadUrl); await uploadToS3(mockFile, mockUploadUrl);
const instance = MockXMLHttpRequest.getLastInstance(); const instance = MockXMLHttpRequest.getLastInstance();
expect(instance.getMethod()).toBe('PUT'); expect(instance.getMethod()).toBe("PUT");
expect(instance.getUrl()).toBe(mockUploadUrl); expect(instance.getUrl()).toBe(mockUploadUrl);
expect(instance.getHeaders()['Content-Type']).toBe('image/jpeg'); expect(instance.getHeaders()["Content-Type"]).toBe("image/jpeg");
}); });
it('should call onProgress callback during upload', async () => { it("should call onProgress callback during upload", async () => {
const onProgress = vi.fn(); const onProgress = vi.fn();
await uploadToS3(mockFile, mockUploadUrl, { onProgress }); await uploadToS3(mockFile, mockUploadUrl, { onProgress });
@@ -269,37 +324,37 @@ describe('Upload Service', () => {
expect(onProgress).toHaveBeenCalledWith(expect.any(Number)); expect(onProgress).toHaveBeenCalledWith(expect.any(Number));
}); });
it('should export uploadToS3 function with correct signature', () => { it("should export uploadToS3 function with correct signature", () => {
expect(typeof uploadToS3).toBe('function'); expect(typeof uploadToS3).toBe("function");
// Function accepts file, url, and optional options // Function accepts file, url, and optional options
expect(uploadToS3.length).toBeGreaterThanOrEqual(2); expect(uploadToS3.length).toBeGreaterThanOrEqual(2);
}); });
it('should set correct content-type header', async () => { it("should set correct content-type header", async () => {
const pngFile = new File(['test'], 'image.png', { type: 'image/png' }); const pngFile = new File(["test"], "image.png", { type: "image/png" });
await uploadToS3(pngFile, mockUploadUrl); await uploadToS3(pngFile, mockUploadUrl);
const instance = MockXMLHttpRequest.getLastInstance(); const instance = MockXMLHttpRequest.getLastInstance();
expect(instance.getHeaders()['Content-Type']).toBe('image/png'); expect(instance.getHeaders()["Content-Type"]).toBe("image/png");
}); });
}); });
describe('confirmUploads', () => { describe("confirmUploads", () => {
it('should confirm uploaded keys', async () => { it("should confirm uploaded keys", async () => {
const keys = ['items/uuid1.jpg', 'items/uuid2.jpg']; const keys = ["items/uuid1.jpg", "items/uuid2.jpg"];
const mockResponse = { confirmed: keys, total: 2 }; const mockResponse = { confirmed: keys, total: 2 };
mockedApi.post.mockResolvedValue({ data: mockResponse }); mockedApi.post.mockResolvedValue({ data: mockResponse });
const result = await confirmUploads(keys); const result = await confirmUploads(keys);
expect(mockedApi.post).toHaveBeenCalledWith('/upload/confirm', { keys }); expect(mockedApi.post).toHaveBeenCalledWith("/upload/confirm", { keys });
expect(result).toEqual(mockResponse); expect(result).toEqual(mockResponse);
}); });
it('should handle partial confirmation', async () => { it("should handle partial confirmation", async () => {
const keys = ['items/uuid1.jpg', 'items/uuid2.jpg']; const keys = ["items/uuid1.jpg", "items/uuid2.jpg"];
const mockResponse = { confirmed: ['items/uuid1.jpg'], total: 2 }; const mockResponse = { confirmed: ["items/uuid1.jpg"], total: 2 };
mockedApi.post.mockResolvedValue({ data: mockResponse }); mockedApi.post.mockResolvedValue({ data: mockResponse });
@@ -310,17 +365,19 @@ describe('Upload Service', () => {
}); });
}); });
describe('uploadFile', () => { describe("uploadFile", () => {
const mockFile = new File(['test content'], 'photo.jpg', { type: 'image/jpeg' }); const mockFile = new File(["test content"], "photo.jpg", {
type: "image/jpeg",
});
const presignResponse: PresignedUrlResponse = { const presignResponse: PresignedUrlResponse = {
uploadUrl: 'https://presigned.s3.amazonaws.com/items/uuid.jpg', uploadUrl: "https://presigned.s3.amazonaws.com/items/uuid.jpg",
key: 'items/uuid.jpg', key: "items/uuid.jpg",
stagingKey: null, stagingKey: null,
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid.jpg', publicUrl: "https://bucket.s3.amazonaws.com/items/uuid.jpg",
expiresAt: new Date().toISOString(), expiresAt: new Date().toISOString(),
}; };
it('should complete full upload flow successfully', async () => { it("should complete full upload flow successfully", async () => {
// Mock presign response // Mock presign response
mockedApi.post.mockResolvedValueOnce({ data: presignResponse }); mockedApi.post.mockResolvedValueOnce({ data: presignResponse });
// Mock confirm response // Mock confirm response
@@ -328,7 +385,7 @@ describe('Upload Service', () => {
data: { confirmed: [presignResponse.key], total: 1 }, data: { confirmed: [presignResponse.key], total: 1 },
}); });
const result = await uploadFile('item', mockFile); const result = await uploadFile("item", mockFile);
expect(result).toEqual({ expect(result).toEqual({
key: presignResponse.key, key: presignResponse.key,
@@ -336,30 +393,32 @@ describe('Upload Service', () => {
}); });
// Verify presign was called // Verify presign was called
expect(mockedApi.post).toHaveBeenCalledWith('/upload/presign', { expect(mockedApi.post).toHaveBeenCalledWith("/upload/presign", {
uploadType: 'item', uploadType: "item",
contentType: 'image/jpeg', contentType: "image/jpeg",
fileName: 'photo.jpg', fileName: "photo.jpg",
fileSize: mockFile.size, fileSize: mockFile.size,
}); });
// Verify confirm was called // Verify confirm was called
expect(mockedApi.post).toHaveBeenCalledWith('/upload/confirm', { expect(mockedApi.post).toHaveBeenCalledWith("/upload/confirm", {
keys: [presignResponse.key], keys: [presignResponse.key],
}); });
}); });
it('should throw error when upload verification fails', async () => { it("should throw error when upload verification fails", async () => {
mockedApi.post.mockResolvedValueOnce({ data: presignResponse }); mockedApi.post.mockResolvedValueOnce({ data: presignResponse });
// Mock confirm returning empty confirmed array // Mock confirm returning empty confirmed array
mockedApi.post.mockResolvedValueOnce({ mockedApi.post.mockResolvedValueOnce({
data: { confirmed: [], total: 1 }, data: { confirmed: [], total: 1 },
}); });
await expect(uploadFile('item', mockFile)).rejects.toThrow('Upload verification failed'); await expect(uploadFile("item", mockFile)).rejects.toThrow(
"Upload verification failed",
);
}); });
it('should pass onProgress to uploadToS3', async () => { it("should pass onProgress to uploadToS3", async () => {
const onProgress = vi.fn(); const onProgress = vi.fn();
mockedApi.post.mockResolvedValueOnce({ data: presignResponse }); mockedApi.post.mockResolvedValueOnce({ data: presignResponse });
@@ -367,16 +426,16 @@ describe('Upload Service', () => {
data: { confirmed: [presignResponse.key], total: 1 }, data: { confirmed: [presignResponse.key], total: 1 },
}); });
await uploadFile('item', mockFile, { onProgress }); await uploadFile("item", mockFile, { onProgress });
// onProgress should have been called during XHR upload // onProgress should have been called during XHR upload
expect(onProgress).toHaveBeenCalled(); expect(onProgress).toHaveBeenCalled();
}); });
it('should work with different upload types', async () => { it("should work with different upload types", async () => {
const messagePresignResponse = { const messagePresignResponse = {
...presignResponse, ...presignResponse,
key: 'messages/uuid.jpg', key: "messages/uuid.jpg",
publicUrl: null, // Messages are private publicUrl: null, // Messages are private
}; };
@@ -385,49 +444,54 @@ describe('Upload Service', () => {
data: { confirmed: [messagePresignResponse.key], total: 1 }, data: { confirmed: [messagePresignResponse.key], total: 1 },
}); });
const result = await uploadFile('message', mockFile); const result = await uploadFile("message", mockFile);
expect(result.key).toBe('messages/uuid.jpg'); expect(result.key).toBe("messages/uuid.jpg");
expect(mockedApi.post).toHaveBeenCalledWith('/upload/presign', expect.objectContaining({ expect(mockedApi.post).toHaveBeenCalledWith(
uploadType: 'message', "/upload/presign",
})); expect.objectContaining({
uploadType: "message",
}),
);
}); });
}); });
// Note: uploadFiles function was removed from uploadService and replaced with uploadImagesWithVariants describe("getSignedUrl", () => {
// Tests for batch uploads would need to be updated to test the new function it("should request signed URL for private content", async () => {
const key = "messages/uuid.jpg";
describe('getSignedUrl', () => { const signedUrl =
it('should request signed URL for private content', async () => { "https://bucket.s3.amazonaws.com/messages/uuid.jpg?signature=abc";
const key = 'messages/uuid.jpg';
const signedUrl = 'https://bucket.s3.amazonaws.com/messages/uuid.jpg?signature=abc';
mockedApi.get.mockResolvedValue({ data: { url: signedUrl } }); mockedApi.get.mockResolvedValue({ data: { url: signedUrl } });
const result = await getSignedUrl(key); const result = await getSignedUrl(key);
expect(mockedApi.get).toHaveBeenCalledWith(`/upload/signed-url/${encodeURIComponent(key)}`); expect(mockedApi.get).toHaveBeenCalledWith(
`/upload/signed-url/${encodeURIComponent(key)}`,
);
expect(result).toBe(signedUrl); expect(result).toBe(signedUrl);
}); });
it('should encode key in URL', async () => { it("should encode key in URL", async () => {
const key = 'condition-checks/uuid with spaces.jpg'; const key = "condition-checks/uuid with spaces.jpg";
const signedUrl = 'https://bucket.s3.amazonaws.com/signed'; const signedUrl = "https://bucket.s3.amazonaws.com/signed";
mockedApi.get.mockResolvedValue({ data: { url: signedUrl } }); mockedApi.get.mockResolvedValue({ data: { url: signedUrl } });
await getSignedUrl(key); await getSignedUrl(key);
expect(mockedApi.get).toHaveBeenCalledWith( expect(mockedApi.get).toHaveBeenCalledWith(
`/upload/signed-url/${encodeURIComponent(key)}` `/upload/signed-url/${encodeURIComponent(key)}`,
); );
}); });
it('should propagate API errors', async () => { it("should propagate API errors", async () => {
const error = new Error('Unauthorized'); const error = new Error("Unauthorized");
mockedApi.get.mockRejectedValue(error); mockedApi.get.mockRejectedValue(error);
await expect(getSignedUrl('messages/uuid.jpg')).rejects.toThrow('Unauthorized'); await expect(getSignedUrl("messages/uuid.jpg")).rejects.toThrow(
"Unauthorized",
);
}); });
}); });
}); });

View File

@@ -34,7 +34,7 @@ const AlphaGate: React.FC = () => {
const response = await axios.post( const response = await axios.post(
`${API_URL}/alpha/validate-code`, `${API_URL}/alpha/validate-code`,
{ code: fullCode }, { code: fullCode },
{ withCredentials: true } { withCredentials: true },
); );
if (response.data.success) { if (response.data.success) {
@@ -115,7 +115,7 @@ const AlphaGate: React.FC = () => {
<p className="text-center text-muted small mb-0"> <p className="text-center text-muted small mb-0">
Have an alpha code? Get started below! <br></br> Want to join?{" "} Have an alpha code? Get started below! <br></br> Want to join?{" "}
<a <a
href="mailto:support@villageshare.app?subject=Alpha Access Request" href="mailto:community-support@village-share.com?subject=Alpha Access Request"
className="text-decoration-none" className="text-decoration-none"
style={{ color: "#667eea" }} style={{ color: "#667eea" }}
> >

View File

@@ -77,9 +77,13 @@ const Owning: React.FC = () => {
const [itemToDelete, setItemToDelete] = useState<Item | null>(null); const [itemToDelete, setItemToDelete] = useState<Item | null>(null);
const [showPaymentFailedModal, setShowPaymentFailedModal] = useState(false); const [showPaymentFailedModal, setShowPaymentFailedModal] = useState(false);
const [paymentFailedError, setPaymentFailedError] = useState<any>(null); const [paymentFailedError, setPaymentFailedError] = useState<any>(null);
const [paymentFailedRental, setPaymentFailedRental] = useState<Rental | null>(null); const [paymentFailedRental, setPaymentFailedRental] = useState<Rental | null>(
null,
);
const [showAuthRequiredModal, setShowAuthRequiredModal] = useState(false); const [showAuthRequiredModal, setShowAuthRequiredModal] = useState(false);
const [authRequiredRental, setAuthRequiredRental] = useState<Rental | null>(null); const [authRequiredRental, setAuthRequiredRental] = useState<Rental | null>(
null,
);
useEffect(() => { useEffect(() => {
fetchListings(); fetchListings();
@@ -89,7 +93,7 @@ const Owning: React.FC = () => {
useEffect(() => { useEffect(() => {
// Only fetch condition checks for rentals that will be displayed (pending/confirmed/active) // Only fetch condition checks for rentals that will be displayed (pending/confirmed/active)
const displayedRentals = ownerRentals.filter((r) => const displayedRentals = ownerRentals.filter((r) =>
["pending", "confirmed", "active"].includes(r.displayStatus || r.status) ["pending", "confirmed", "active"].includes(r.displayStatus || r.status),
); );
if (displayedRentals.length > 0) { if (displayedRentals.length > 0) {
const rentalIds = displayedRentals.map((r) => r.id); const rentalIds = displayedRentals.map((r) => r.id);
@@ -111,7 +115,7 @@ const Owning: React.FC = () => {
// Filter items to only show ones owned by current user // Filter items to only show ones owned by current user
const myItems = response.data.items.filter( const myItems = response.data.items.filter(
(item: Item) => item.ownerId === user.id (item: Item) => item.ownerId === user.id,
); );
setListings(myItems); setListings(myItems);
} catch (err: any) { } catch (err: any) {
@@ -152,8 +156,8 @@ const Owning: React.FC = () => {
}); });
setListings( setListings(
listings.map((i) => listings.map((i) =>
i.id === item.id ? { ...i, isAvailable: !i.isAvailable } : i i.id === item.id ? { ...i, isAvailable: !i.isAvailable } : i,
) ),
); );
} catch (err: any) { } catch (err: any) {
alert("Failed to update availability"); alert("Failed to update availability");
@@ -189,7 +193,8 @@ const Owning: React.FC = () => {
return; return;
} }
const rentalIds = rentalsToFetch.map((r) => r.id); const rentalIds = rentalsToFetch.map((r) => r.id);
const response = await conditionCheckAPI.getBatchConditionChecks(rentalIds); const response =
await conditionCheckAPI.getBatchConditionChecks(rentalIds);
setConditionChecks(response.data.conditionChecks || []); setConditionChecks(response.data.conditionChecks || []);
} catch (err) { } catch (err) {
console.error("Failed to fetch condition checks:", err); console.error("Failed to fetch condition checks:", err);
@@ -203,7 +208,7 @@ const Owning: React.FC = () => {
setIsProcessingPayment(rentalId); setIsProcessingPayment(rentalId);
const response = await rentalAPI.updateRentalStatus( const response = await rentalAPI.updateRentalStatus(
rentalId, rentalId,
"confirmed" "confirmed",
); );
// Check if payment processing was successful // Check if payment processing was successful
@@ -216,7 +221,6 @@ const Owning: React.FC = () => {
} }
fetchOwnerRentals(); fetchOwnerRentals();
// Note: fetchAvailableChecks() removed - it will be triggered via ownerRentals useEffect
// Notify Navbar to update pending count // Notify Navbar to update pending count
window.dispatchEvent(new CustomEvent("rentalStatusChanged")); window.dispatchEvent(new CustomEvent("rentalStatusChanged"));
@@ -246,7 +250,7 @@ const Owning: React.FC = () => {
alert( alert(
err.response?.data?.error || err.response?.data?.error ||
err.response?.data?.details || err.response?.data?.details ||
"Failed to accept rental request" "Failed to accept rental request",
); );
} }
} finally { } finally {
@@ -263,8 +267,8 @@ const Owning: React.FC = () => {
// Update the rental in the owner rentals list // Update the rental in the owner rentals list
setOwnerRentals((prev) => setOwnerRentals((prev) =>
prev.map((rental) => prev.map((rental) =>
rental.id === updatedRental.id ? updatedRental : rental rental.id === updatedRental.id ? updatedRental : rental,
) ),
); );
setShowDeclineModal(false); setShowDeclineModal(false);
setRentalToDecline(null); setRentalToDecline(null);
@@ -279,8 +283,8 @@ const Owning: React.FC = () => {
// Update the rental in the list // Update the rental in the list
setOwnerRentals((prev) => setOwnerRentals((prev) =>
prev.map((rental) => prev.map((rental) =>
rental.id === updatedRental.id ? updatedRental : rental rental.id === updatedRental.id ? updatedRental : rental,
) ),
); );
// Close the return status modal // Close the return status modal
@@ -306,8 +310,8 @@ const Owning: React.FC = () => {
// Update the rental in the owner rentals list // Update the rental in the owner rentals list
setOwnerRentals((prev) => setOwnerRentals((prev) =>
prev.map((rental) => prev.map((rental) =>
rental.id === updatedRental.id ? updatedRental : rental rental.id === updatedRental.id ? updatedRental : rental,
) ),
); );
setShowCancelModal(false); setShowCancelModal(false);
setRentalToCancel(null); setRentalToCancel(null);
@@ -321,7 +325,7 @@ const Owning: React.FC = () => {
const handleConditionCheckSuccess = () => { const handleConditionCheckSuccess = () => {
// Refetch condition checks for displayed rentals // Refetch condition checks for displayed rentals
const displayedRentals = ownerRentals.filter((r) => const displayedRentals = ownerRentals.filter((r) =>
["pending", "confirmed", "active"].includes(r.displayStatus || r.status) ["pending", "confirmed", "active"].includes(r.displayStatus || r.status),
); );
const rentalIds = displayedRentals.map((r) => r.id); const rentalIds = displayedRentals.map((r) => r.id);
fetchAvailableChecks(rentalIds); fetchAvailableChecks(rentalIds);
@@ -337,7 +341,7 @@ const Owning: React.FC = () => {
if (!Array.isArray(availableChecks)) return []; if (!Array.isArray(availableChecks)) return [];
return availableChecks.filter( return availableChecks.filter(
(check) => (check) =>
check.rentalId === rentalId && check.checkType === "pre_rental_owner" // Only pre-rental; post-rental is in return modal check.rentalId === rentalId && check.checkType === "pre_rental_owner", // Only pre-rental; post-rental is in return modal
); );
}; };
@@ -349,7 +353,9 @@ const Owning: React.FC = () => {
// Filter owner rentals - exclude cancelled (shown in Rental History) // Filter owner rentals - exclude cancelled (shown in Rental History)
// Use displayStatus for filtering/sorting as it includes computed "active" status // Use displayStatus for filtering/sorting as it includes computed "active" status
const allOwnerRentals = ownerRentals const allOwnerRentals = ownerRentals
.filter((r) => ["pending", "confirmed", "active"].includes(r.displayStatus || r.status)) .filter((r) =>
["pending", "confirmed", "active"].includes(r.displayStatus || r.status),
)
.sort((a, b) => { .sort((a, b) => {
const statusOrder = { pending: 0, confirmed: 1, active: 2 }; const statusOrder = { pending: 0, confirmed: 1, active: 2 };
const aStatus = a.displayStatus || a.status; const aStatus = a.displayStatus || a.status;
@@ -396,14 +402,20 @@ const Owning: React.FC = () => {
{rental.item?.imageFilenames && {rental.item?.imageFilenames &&
rental.item.imageFilenames[0] && ( rental.item.imageFilenames[0] && (
<img <img
src={getImageUrl(rental.item.imageFilenames[0], 'thumbnail')} src={getImageUrl(
rental.item.imageFilenames[0],
"thumbnail",
)}
className="card-img-top" className="card-img-top"
alt={rental.item.name} alt={rental.item.name}
onError={(e) => { onError={(e) => {
const target = e.currentTarget; const target = e.currentTarget;
if (!target.dataset.fallback && rental.item) { if (!target.dataset.fallback && rental.item) {
target.dataset.fallback = 'true'; target.dataset.fallback = "true";
target.src = getImageUrl(rental.item.imageFilenames[0], 'original'); target.src = getImageUrl(
rental.item.imageFilenames[0],
"original",
);
} }
}} }}
style={{ style={{
@@ -435,14 +447,18 @@ const Owning: React.FC = () => {
className={`badge ${ className={`badge ${
(rental.displayStatus || rental.status) === "active" (rental.displayStatus || rental.status) === "active"
? "bg-success" ? "bg-success"
: (rental.displayStatus || rental.status) === "pending" : (rental.displayStatus || rental.status) ===
"pending"
? "bg-warning" ? "bg-warning"
: (rental.displayStatus || rental.status) === "confirmed" : (rental.displayStatus || rental.status) ===
"confirmed"
? "bg-info" ? "bg-info"
: "bg-danger" : "bg-danger"
}`} }`}
> >
{(rental.displayStatus || rental.status).charAt(0).toUpperCase() + {(rental.displayStatus || rental.status)
.charAt(0)
.toUpperCase() +
(rental.displayStatus || rental.status).slice(1)} (rental.displayStatus || rental.status).slice(1)}
</span> </span>
</div> </div>
@@ -478,7 +494,7 @@ const Owning: React.FC = () => {
<small className="d-block text-muted mt-1"> <small className="d-block text-muted mt-1">
Processed:{" "} Processed:{" "}
{new Date( {new Date(
rental.refundProcessedAt rental.refundProcessedAt,
).toLocaleDateString()} ).toLocaleDateString()}
</small> </small>
)} )}
@@ -556,7 +572,8 @@ const Owning: React.FC = () => {
</button> </button>
</> </>
)} )}
{(rental.displayStatus || rental.status) === "confirmed" && ( {(rental.displayStatus || rental.status) ===
"confirmed" && (
<button <button
className="btn btn-sm btn-outline-danger" className="btn btn-sm btn-outline-danger"
onClick={() => handleCancelClick(rental)} onClick={() => handleCancelClick(rental)}
@@ -564,7 +581,8 @@ const Owning: React.FC = () => {
Cancel Cancel
</button> </button>
)} )}
{(rental.displayStatus || rental.status) === "active" && ( {(rental.displayStatus || rental.status) ===
"active" && (
<button <button
className="btn btn-sm btn-success" className="btn btn-sm btn-success"
onClick={() => handleCompleteClick(rental)} onClick={() => handleCompleteClick(rental)}
@@ -593,11 +611,11 @@ const Owning: React.FC = () => {
: "Post-Rental Condition"} : "Post-Rental Condition"}
<small className="text-muted ms-2"> <small className="text-muted ms-2">
{new Date( {new Date(
check.createdAt check.createdAt,
).toLocaleDateString()} ).toLocaleDateString()}
</small> </small>
</button> </button>
) ),
)} )}
</div> </div>
)} )}
@@ -660,14 +678,17 @@ const Owning: React.FC = () => {
> >
{item.imageFilenames && item.imageFilenames[0] && ( {item.imageFilenames && item.imageFilenames[0] && (
<img <img
src={getImageUrl(item.imageFilenames[0], 'thumbnail')} src={getImageUrl(item.imageFilenames[0], "thumbnail")}
className="card-img-top" className="card-img-top"
alt={item.name} alt={item.name}
onError={(e) => { onError={(e) => {
const target = e.currentTarget; const target = e.currentTarget;
if (!target.dataset.fallback) { if (!target.dataset.fallback) {
target.dataset.fallback = 'true'; target.dataset.fallback = "true";
target.src = getImageUrl(item.imageFilenames[0], 'original'); target.src = getImageUrl(
item.imageFilenames[0],
"original",
);
} }
}} }}
style={{ style={{

View File

@@ -15,7 +15,7 @@ let failedQueue: Array<{
const processQueue = ( const processQueue = (
error: AxiosError | null, error: AxiosError | null,
token: string | null = null token: string | null = null,
) => { ) => {
failedQueue.forEach((prom) => { failedQueue.forEach((prom) => {
if (error) { if (error) {
@@ -95,7 +95,7 @@ api.interceptors.response.use(
methods: errorData.methods, methods: errorData.methods,
originalRequest, originalRequest,
}, },
}) }),
); );
return Promise.reject(error); return Promise.reject(error);
} }
@@ -128,7 +128,6 @@ api.interceptors.response.use(
const errorData = error.response?.data as any; const errorData = error.response?.data as any;
// Try to refresh for token errors // Try to refresh for token errors
// Note: We can't check refresh token from JS (httpOnly cookies)
// The backend will determine if refresh is possible // The backend will determine if refresh is possible
if ( if (
(errorData?.code === "TOKEN_EXPIRED" || (errorData?.code === "TOKEN_EXPIRED" ||
@@ -167,7 +166,7 @@ api.interceptors.response.use(
} }
return Promise.reject(error); return Promise.reject(error);
} },
); );
export const authAPI = { export const authAPI = {
@@ -279,7 +278,7 @@ export const rentalAPI = {
// Return status marking // Return status marking
markReturn: ( markReturn: (
id: string, id: string,
data: { status: string; actualReturnDateTime?: string } data: { status: string; actualReturnDateTime?: string },
) => api.post(`/rentals/${id}/mark-return`, data), ) => api.post(`/rentals/${id}/mark-return`, data),
reportDamage: (id: string, data: any) => reportDamage: (id: string, data: any) =>
api.post(`/rentals/${id}/report-damage`, data), api.post(`/rentals/${id}/report-damage`, data),
@@ -338,7 +337,7 @@ export const forumAPI = {
content: string; content: string;
parentId?: string; parentId?: string;
imageFilenames?: string[]; imageFilenames?: string[];
} },
) => api.post(`/forum/posts/${postId}/comments`, data), ) => api.post(`/forum/posts/${postId}/comments`, data),
updateComment: (commentId: string, data: any) => updateComment: (commentId: string, data: any) =>
api.put(`/forum/comments/${commentId}`, data), api.put(`/forum/comments/${commentId}`, data),
@@ -388,7 +387,7 @@ export const mapsAPI = {
export const conditionCheckAPI = { export const conditionCheckAPI = {
submitConditionCheck: ( submitConditionCheck: (
rentalId: string, rentalId: string,
data: { checkType: string; imageFilenames: string[]; notes?: string } data: { checkType: string; imageFilenames: string[]; notes?: string },
) => api.post(`/condition-checks/${rentalId}`, data), ) => api.post(`/condition-checks/${rentalId}`, data),
getBatchConditionChecks: (rentalIds: string[]) => getBatchConditionChecks: (rentalIds: string[]) =>
api.get(`/condition-checks/batch`, { api.get(`/condition-checks/batch`, {

View File

@@ -1,63 +0,0 @@
# Rentall Infrastructure
AWS CDK infrastructure for Rentall Lambda functions.
## Prerequisites
- Node.js 20+
- AWS CLI configured with appropriate credentials
- AWS CDK CLI (`npm install -g aws-cdk`)
## Setup
```bash
cd infrastructure/cdk
npm install
```
## Deploy
### Staging
```bash
npm run deploy:staging
```
### Production
```bash
npm run deploy:prod
```
## Environment Variables
The following environment variables should be set before deployment:
- `DATABASE_URL` - PostgreSQL connection string
- `CDK_DEFAULT_ACCOUNT` - AWS account ID
- `CDK_DEFAULT_REGION` - AWS region (defaults to us-east-1)
## Stacks
### ConditionCheckLambdaStack
Creates:
- Lambda function for condition check reminders
- EventBridge Scheduler group for per-rental schedules
- IAM roles for Lambda execution and Scheduler invocation
- Dead letter queue for failed invocations
## Outputs
After deployment, the following values are exported:
- `ConditionCheckLambdaArn-{env}` - Lambda function ARN
- `ConditionCheckScheduleGroup-{env}` - Schedule group name
- `ConditionCheckSchedulerRoleArn-{env}` - Scheduler IAM role ARN
- `ConditionCheckDLQUrl-{env}` - Dead letter queue URL
## Useful Commands
- `npm run synth` - Synthesize CloudFormation template
- `npm run diff` - Compare deployed stack with current state
- `npm run destroy` - Destroy all stacks

View File

@@ -1,118 +0,0 @@
#!/usr/bin/env node
import "source-map-support/register";
import * as cdk from "aws-cdk-lib";
import { ConditionCheckLambdaStack } from "../lib/condition-check-lambda-stack";
import { ImageProcessorLambdaStack } from "../lib/image-processor-lambda-stack";
import { VpcStack } from "../lib/vpc-stack";
const app = new cdk.App();
// Get environment from context or default to staging
const environment = app.node.tryGetContext("env") || "staging";
// Environment-specific configurations
const envConfig: Record<
string,
{
databaseUrl: string;
frontendUrl: string;
sesFromEmail: string;
emailEnabled: boolean;
natGateways: number;
}
> = {
staging: {
// These should be passed via CDK context or SSM parameters in production
databaseUrl:
process.env.DATABASE_URL ||
"postgresql://user:password@localhost:5432/rentall_staging",
frontendUrl: "https://staging.villageshare.app",
sesFromEmail: "noreply@villageshare.app",
emailEnabled: true,
natGateways: 1, // Single NAT gateway for cost optimization in staging
},
prod: {
databaseUrl:
process.env.DATABASE_URL ||
"postgresql://user:password@localhost:5432/rentall_prod",
frontendUrl: "https://villageshare.app",
sesFromEmail: "noreply@villageshare.app",
emailEnabled: true,
natGateways: 2, // Multi-AZ NAT gateways for high availability in production
},
};
const config = envConfig[environment];
if (!config) {
throw new Error(`Unknown environment: ${environment}`);
}
const envProps = {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION || "us-east-1",
},
};
// Create the VPC stack first (other stacks depend on it)
const vpcStack = new VpcStack(app, `VpcStack-${environment}`, {
environment,
natGateways: config.natGateways,
maxAzs: 2,
...envProps,
description: `VPC infrastructure with private subnets and VPC endpoints (${environment})`,
tags: {
Environment: environment,
Project: "village-share",
Service: "networking",
},
});
// Create the Condition Check Lambda stack
const conditionCheckStack = new ConditionCheckLambdaStack(
app,
`ConditionCheckLambdaStack-${environment}`,
{
environment,
databaseUrl: config.databaseUrl,
frontendUrl: config.frontendUrl,
sesFromEmail: config.sesFromEmail,
emailEnabled: config.emailEnabled,
vpc: vpcStack.vpc,
lambdaSecurityGroup: vpcStack.lambdaSecurityGroup,
...envProps,
description: `Condition Check Reminder Lambda infrastructure (${environment})`,
tags: {
Environment: environment,
Project: "village-share",
Service: "condition-check-reminder",
},
}
);
// Add dependency on VPC stack
conditionCheckStack.addDependency(vpcStack);
// Create the Image Processor Lambda stack
const imageProcessorStack = new ImageProcessorLambdaStack(
app,
`ImageProcessorLambdaStack-${environment}`,
{
environment,
databaseUrl: config.databaseUrl,
frontendUrl: config.frontendUrl,
vpc: vpcStack.vpc,
lambdaSecurityGroup: vpcStack.lambdaSecurityGroup,
...envProps,
description: `Image Processor Lambda infrastructure (${environment})`,
tags: {
Environment: environment,
Project: "village-share",
Service: "image-processor",
},
}
);
// Add dependency on VPC stack
imageProcessorStack.addDependency(vpcStack);

View File

@@ -1,21 +0,0 @@
{
"app": "npx ts-node --prefer-ts-exts bin/app.ts",
"watch": {
"include": ["**"],
"exclude": [
"README.md",
"cdk*.json",
"**/*.d.ts",
"**/*.js",
"tsconfig.json",
"package*.json",
"node_modules",
"test"
]
},
"context": {
"@aws-cdk/aws-lambda:recognizeLayerVersion": true,
"@aws-cdk/core:checkSecretUsage": true,
"@aws-cdk/core:target-partitions": ["aws"]
}
}

View File

@@ -1,256 +0,0 @@
import * as cdk from "aws-cdk-lib";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as iam from "aws-cdk-lib/aws-iam";
import * as scheduler from "aws-cdk-lib/aws-scheduler";
import * as sqs from "aws-cdk-lib/aws-sqs";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import { Construct } from "constructs";
import * as path from "path";
interface ConditionCheckLambdaStackProps extends cdk.StackProps {
/**
* Environment name (staging, prod)
*/
environment: string;
/**
* Database URL for the Lambda
*/
databaseUrl: string;
/**
* Frontend URL for email links
*/
frontendUrl: string;
/**
* SES sender email
*/
sesFromEmail: string;
/**
* SES sender name
*/
sesFromName?: string;
/**
* Whether emails are enabled
*/
emailEnabled?: boolean;
/**
* VPC for Lambda function (required for network isolation)
*/
vpc: ec2.IVpc;
/**
* Security group for Lambda function
*/
lambdaSecurityGroup: ec2.ISecurityGroup;
}
export class ConditionCheckLambdaStack extends cdk.Stack {
/**
* The Lambda function for condition check reminders
*/
public readonly lambdaFunction: lambda.Function;
/**
* The EventBridge Scheduler group for condition check schedules
*/
public readonly scheduleGroup: scheduler.CfnScheduleGroup;
/**
* Dead letter queue for failed Lambda invocations
*/
public readonly deadLetterQueue: sqs.Queue;
/**
* IAM role for EventBridge Scheduler to invoke Lambda
*/
public readonly schedulerRole: iam.Role;
constructor(
scope: Construct,
id: string,
props: ConditionCheckLambdaStackProps
) {
super(scope, id, props);
const {
environment,
databaseUrl,
frontendUrl,
sesFromEmail,
sesFromName = "Village Share",
emailEnabled = true,
vpc,
lambdaSecurityGroup,
} = props;
// Dead Letter Queue for failed Lambda invocations
this.deadLetterQueue = new sqs.Queue(this, "ConditionCheckDLQ", {
queueName: `condition-check-reminder-dlq-${environment}`,
retentionPeriod: cdk.Duration.days(14),
});
// Lambda execution role
const lambdaRole = new iam.Role(this, "ConditionCheckLambdaRole", {
roleName: `condition-check-lambda-role-${environment}`,
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
description: "Execution role for Condition Check Reminder Lambda",
});
// CloudWatch Logs permissions - scoped to this Lambda's log group
lambdaRole.addToPolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
],
resources: [
`arn:aws:logs:${this.region}:${this.account}:log-group:/aws/lambda/condition-check-reminder-${environment}`,
`arn:aws:logs:${this.region}:${this.account}:log-group:/aws/lambda/condition-check-reminder-${environment}:*`,
],
})
);
// SES permissions for sending emails - scoped to verified identity
lambdaRole.addToPolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ["ses:SendEmail", "ses:SendRawEmail"],
resources: [
`arn:aws:ses:${this.region}:${this.account}:identity/${sesFromEmail}`,
],
})
);
// EventBridge Scheduler permissions (for self-cleanup)
lambdaRole.addToPolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ["scheduler:DeleteSchedule"],
resources: [
`arn:aws:scheduler:${this.region}:${this.account}:schedule/condition-check-reminders-${environment}/*`,
],
})
);
// VPC permissions - use AWS managed policy for Lambda VPC access
lambdaRole.addManagedPolicy(
iam.ManagedPolicy.fromAwsManagedPolicyName(
"service-role/AWSLambdaVPCAccessExecutionRole"
)
);
// Lambda function
this.lambdaFunction = new lambda.Function(
this,
"ConditionCheckReminderLambda",
{
functionName: `condition-check-reminder-${environment}`,
runtime: lambda.Runtime.NODEJS_20_X,
handler: "index.handler",
code: lambda.Code.fromAsset(
path.join(__dirname, "../../../lambdas/conditionCheckReminder"),
{
bundling: {
image: lambda.Runtime.NODEJS_20_X.bundlingImage,
command: [
"bash",
"-c",
[
"cp -r /asset-input/* /asset-output/",
"cd /asset-output",
"npm install --omit=dev",
// Copy shared modules
"mkdir -p shared",
"cp -r /asset-input/../shared/* shared/",
"cd shared && npm install --omit=dev",
].join(" && "),
],
},
}
),
role: lambdaRole,
timeout: cdk.Duration.seconds(30),
memorySize: 256,
environment: {
NODE_ENV: environment,
DATABASE_URL: databaseUrl,
FRONTEND_URL: frontendUrl,
SES_FROM_EMAIL: sesFromEmail,
SES_FROM_NAME: sesFromName,
EMAIL_ENABLED: emailEnabled ? "true" : "false",
SCHEDULE_GROUP_NAME: `condition-check-reminders-${environment}`,
AWS_REGION: this.region,
},
deadLetterQueue: this.deadLetterQueue,
retryAttempts: 2,
description: "Sends condition check reminder emails for rentals",
// VPC configuration for network isolation
vpc,
vpcSubnets: {
subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
},
securityGroups: [lambdaSecurityGroup],
}
);
// EventBridge Scheduler group
this.scheduleGroup = new scheduler.CfnScheduleGroup(
this,
"ConditionCheckScheduleGroup",
{
name: `condition-check-reminders-${environment}`,
}
);
// IAM role for EventBridge Scheduler to invoke Lambda
this.schedulerRole = new iam.Role(this, "SchedulerRole", {
roleName: `condition-check-scheduler-role-${environment}`,
assumedBy: new iam.ServicePrincipal("scheduler.amazonaws.com"),
description: "Role for EventBridge Scheduler to invoke Lambda",
});
// Allow scheduler to invoke the Lambda
this.schedulerRole.addToPolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ["lambda:InvokeFunction"],
resources: [
this.lambdaFunction.functionArn,
`${this.lambdaFunction.functionArn}:*`,
],
})
);
// Outputs
new cdk.CfnOutput(this, "LambdaFunctionArn", {
value: this.lambdaFunction.functionArn,
description: "ARN of the Condition Check Reminder Lambda",
exportName: `ConditionCheckLambdaArn-${environment}`,
});
new cdk.CfnOutput(this, "ScheduleGroupName", {
value: this.scheduleGroup.name!,
description: "Name of the EventBridge Scheduler group",
exportName: `ConditionCheckScheduleGroup-${environment}`,
});
new cdk.CfnOutput(this, "SchedulerRoleArn", {
value: this.schedulerRole.roleArn,
description: "ARN of the EventBridge Scheduler IAM role",
exportName: `ConditionCheckSchedulerRoleArn-${environment}`,
});
new cdk.CfnOutput(this, "DLQUrl", {
value: this.deadLetterQueue.queueUrl,
description: "URL of the Dead Letter Queue",
exportName: `ConditionCheckDLQUrl-${environment}`,
});
}
}

View File

@@ -1,246 +0,0 @@
import * as cdk from "aws-cdk-lib";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as iam from "aws-cdk-lib/aws-iam";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as s3n from "aws-cdk-lib/aws-s3-notifications";
import * as sqs from "aws-cdk-lib/aws-sqs";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import { Construct } from "constructs";
import * as path from "path";
interface ImageProcessorLambdaStackProps extends cdk.StackProps {
/**
* Environment name (staging, prod)
*/
environment: string;
/**
* Database URL for the Lambda
*/
databaseUrl: string;
/**
* Frontend URL for CORS configuration
*/
frontendUrl: string;
/**
* VPC for Lambda function (required for network isolation)
*/
vpc: ec2.IVpc;
/**
* Security group for Lambda function
*/
lambdaSecurityGroup: ec2.ISecurityGroup;
}
export class ImageProcessorLambdaStack extends cdk.Stack {
/**
* The Lambda function for image processing
*/
public readonly lambdaFunction: lambda.Function;
/**
* The S3 bucket for image uploads
*/
public readonly uploadsBucket: s3.Bucket;
/**
* Dead letter queue for failed Lambda invocations
*/
public readonly deadLetterQueue: sqs.Queue;
constructor(
scope: Construct,
id: string,
props: ImageProcessorLambdaStackProps
) {
super(scope, id, props);
const { environment, databaseUrl, frontendUrl, vpc, lambdaSecurityGroup } = props;
// Dead Letter Queue for failed Lambda invocations
this.deadLetterQueue = new sqs.Queue(this, "ImageProcessorDLQ", {
queueName: `image-processor-dlq-${environment}`,
retentionPeriod: cdk.Duration.days(14),
});
// S3 bucket for uploads
this.uploadsBucket = new s3.Bucket(this, "UploadsBucket", {
bucketName: `village-share-${environment}`,
versioned: true,
encryption: s3.BucketEncryption.S3_MANAGED,
blockPublicAccess: new s3.BlockPublicAccess({
blockPublicAcls: true,
blockPublicPolicy: false, // Allow bucket policy for public reads
ignorePublicAcls: true,
restrictPublicBuckets: false,
}),
cors: [
{
allowedMethods: [
s3.HttpMethods.GET,
s3.HttpMethods.PUT,
s3.HttpMethods.POST,
],
allowedOrigins: [frontendUrl, "http://localhost:3000"],
allowedHeaders: ["*"],
exposedHeaders: ["ETag"],
maxAge: 3600,
},
],
lifecycleRules: [
{
// Clean up incomplete multipart uploads
abortIncompleteMultipartUploadAfter: cdk.Duration.days(1),
},
{
// Delete staging files that weren't processed after 7 days
prefix: "staging/",
expiration: cdk.Duration.days(7),
},
],
});
// Bucket policy: allow public read for non-staging files
this.uploadsBucket.addToResourcePolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
principals: [new iam.AnyPrincipal()],
actions: ["s3:GetObject"],
resources: [
`${this.uploadsBucket.bucketArn}/profiles/*`,
`${this.uploadsBucket.bucketArn}/items/*`,
`${this.uploadsBucket.bucketArn}/forum/*`,
],
})
);
// Lambda execution role
const lambdaRole = new iam.Role(this, "ImageProcessorLambdaRole", {
roleName: `image-processor-lambda-role-${environment}`,
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
description: "Execution role for Image Processor Lambda",
});
// CloudWatch Logs permissions - scoped to this Lambda's log group
lambdaRole.addToPolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
],
resources: [
`arn:aws:logs:${this.region}:${this.account}:log-group:/aws/lambda/image-processor-${environment}`,
`arn:aws:logs:${this.region}:${this.account}:log-group:/aws/lambda/image-processor-${environment}:*`,
],
})
);
// S3 permissions
lambdaRole.addToPolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:HeadObject",
],
resources: [`${this.uploadsBucket.bucketArn}/*`],
})
);
// VPC permissions - use AWS managed policy for Lambda VPC access
lambdaRole.addManagedPolicy(
iam.ManagedPolicy.fromAwsManagedPolicyName(
"service-role/AWSLambdaVPCAccessExecutionRole"
)
);
// Lambda function
this.lambdaFunction = new lambda.Function(this, "ImageProcessorLambda", {
functionName: `image-processor-${environment}`,
runtime: lambda.Runtime.NODEJS_20_X,
handler: "index.handler",
code: lambda.Code.fromAsset(
path.join(__dirname, "../../../lambdas/imageProcessor"),
{
bundling: {
image: lambda.Runtime.NODEJS_20_X.bundlingImage,
command: [
"bash",
"-c",
[
"cp -r /asset-input/* /asset-output/",
"cd /asset-output",
"npm install --omit=dev",
// Copy shared modules
"mkdir -p shared",
"cp -r /asset-input/../shared/* shared/",
"cd shared && npm install --omit=dev",
].join(" && "),
],
},
}
),
role: lambdaRole,
timeout: cdk.Duration.seconds(60),
memorySize: 1024, // Higher memory for image processing
environment: {
NODE_ENV: environment,
DATABASE_URL: databaseUrl,
S3_BUCKET: this.uploadsBucket.bucketName,
AWS_REGION: this.region,
LOG_LEVEL: environment === "prod" ? "info" : "debug",
},
deadLetterQueue: this.deadLetterQueue,
retryAttempts: 2,
description:
"Processes uploaded images: extracts metadata and strips EXIF",
// VPC configuration for network isolation
vpc,
vpcSubnets: {
subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
},
securityGroups: [lambdaSecurityGroup],
});
// S3 event notification for staging uploads
this.uploadsBucket.addEventNotification(
s3.EventType.OBJECT_CREATED,
new s3n.LambdaDestination(this.lambdaFunction),
{
prefix: "staging/",
}
);
// Outputs
new cdk.CfnOutput(this, "LambdaFunctionArn", {
value: this.lambdaFunction.functionArn,
description: "ARN of the Image Processor Lambda",
exportName: `ImageProcessorLambdaArn-${environment}`,
});
new cdk.CfnOutput(this, "UploadsBucketName", {
value: this.uploadsBucket.bucketName,
description: "Name of the uploads S3 bucket",
exportName: `UploadsBucketName-${environment}`,
});
new cdk.CfnOutput(this, "UploadsBucketArn", {
value: this.uploadsBucket.bucketArn,
description: "ARN of the uploads S3 bucket",
exportName: `UploadsBucketArn-${environment}`,
});
new cdk.CfnOutput(this, "DLQUrl", {
value: this.deadLetterQueue.queueUrl,
description: "URL of the Dead Letter Queue",
exportName: `ImageProcessorDLQUrl-${environment}`,
});
}
}

View File

@@ -1,176 +0,0 @@
import * as cdk from "aws-cdk-lib";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import { Construct } from "constructs";
interface VpcStackProps extends cdk.StackProps {
/**
* Environment name (staging, prod)
*/
environment: string;
/**
* Maximum number of AZs to use (default: 2)
*/
maxAzs?: number;
/**
* Number of NAT Gateways (default: 1 for cost optimization)
* Use 2 for high availability in production
*/
natGateways?: number;
}
export class VpcStack extends cdk.Stack {
/**
* The VPC created by this stack
*/
public readonly vpc: ec2.Vpc;
/**
* Security group for Lambda functions
*/
public readonly lambdaSecurityGroup: ec2.SecurityGroup;
/**
* S3 Gateway endpoint (free)
*/
public readonly s3Endpoint: ec2.GatewayVpcEndpoint;
constructor(scope: Construct, id: string, props: VpcStackProps) {
super(scope, id, props);
const { environment, maxAzs = 2, natGateways = 1 } = props;
// Create VPC with public and private subnets
this.vpc = new ec2.Vpc(this, "VillageShareVpc", {
vpcName: `village-share-vpc-${environment}`,
ipAddresses: ec2.IpAddresses.cidr("10.0.0.0/16"),
maxAzs,
natGateways,
subnetConfiguration: [
{
name: "Public",
subnetType: ec2.SubnetType.PUBLIC,
cidrMask: 24,
mapPublicIpOnLaunch: false,
},
{
name: "Private",
subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
cidrMask: 24,
},
{
name: "Isolated",
subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
cidrMask: 24,
},
],
// Enable DNS support for VPC endpoints
enableDnsHostnames: true,
enableDnsSupport: true,
});
// Security group for Lambda functions
this.lambdaSecurityGroup = new ec2.SecurityGroup(
this,
"LambdaSecurityGroup",
{
vpc: this.vpc,
securityGroupName: `lambda-sg-${environment}`,
description: "Security group for Lambda functions in VPC",
allowAllOutbound: true, // Lambda needs outbound for AWS services
}
);
// Security group for VPC endpoints
const vpcEndpointSecurityGroup = new ec2.SecurityGroup(
this,
"VpcEndpointSecurityGroup",
{
vpc: this.vpc,
securityGroupName: `vpc-endpoint-sg-${environment}`,
description: "Security group for VPC Interface Endpoints",
allowAllOutbound: false,
}
);
// Allow HTTPS traffic from Lambda security group to VPC endpoints
vpcEndpointSecurityGroup.addIngressRule(
this.lambdaSecurityGroup,
ec2.Port.tcp(443),
"Allow HTTPS from Lambda functions"
);
// S3 Gateway Endpoint (FREE - no NAT charges for S3 traffic)
this.s3Endpoint = this.vpc.addGatewayEndpoint("S3Endpoint", {
service: ec2.GatewayVpcEndpointAwsService.S3,
subnets: [{ subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }],
});
// SES Interface Endpoint (for sending emails without NAT)
this.vpc.addInterfaceEndpoint("SesEndpoint", {
service: ec2.InterfaceVpcEndpointAwsService.SES,
subnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
securityGroups: [vpcEndpointSecurityGroup],
privateDnsEnabled: true,
});
// SQS Interface Endpoint (for DLQ access)
this.vpc.addInterfaceEndpoint("SqsEndpoint", {
service: ec2.InterfaceVpcEndpointAwsService.SQS,
subnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
securityGroups: [vpcEndpointSecurityGroup],
privateDnsEnabled: true,
});
// CloudWatch Logs Interface Endpoint
this.vpc.addInterfaceEndpoint("CloudWatchLogsEndpoint", {
service: ec2.InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS,
subnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
securityGroups: [vpcEndpointSecurityGroup],
privateDnsEnabled: true,
});
// Scheduler Interface Endpoint (for EventBridge Scheduler)
// Note: EventBridge Scheduler uses the scheduler.{region}.amazonaws.com endpoint
this.vpc.addInterfaceEndpoint("SchedulerEndpoint", {
service: new ec2.InterfaceVpcEndpointService(
`com.amazonaws.${cdk.Stack.of(this).region}.scheduler`
),
subnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
securityGroups: [vpcEndpointSecurityGroup],
privateDnsEnabled: true,
});
// Add tags to subnets for easy identification
cdk.Tags.of(this.vpc).add("Environment", environment);
cdk.Tags.of(this.vpc).add("Project", "village-share");
// Outputs
new cdk.CfnOutput(this, "VpcId", {
value: this.vpc.vpcId,
description: "VPC ID",
exportName: `VpcId-${environment}`,
});
new cdk.CfnOutput(this, "VpcCidr", {
value: this.vpc.vpcCidrBlock,
description: "VPC CIDR block",
exportName: `VpcCidr-${environment}`,
});
new cdk.CfnOutput(this, "PrivateSubnetIds", {
value: this.vpc
.selectSubnets({ subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS })
.subnetIds.join(","),
description: "Private subnet IDs",
exportName: `PrivateSubnetIds-${environment}`,
});
new cdk.CfnOutput(this, "LambdaSecurityGroupId", {
value: this.lambdaSecurityGroup.securityGroupId,
description: "Security group ID for Lambda functions",
exportName: `LambdaSecurityGroupId-${environment}`,
});
}
}

View File

@@ -1,487 +0,0 @@
{
"name": "rentall-infrastructure",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rentall-infrastructure",
"version": "1.0.0",
"dependencies": {
"aws-cdk-lib": "^2.170.0",
"constructs": "^10.4.2"
},
"devDependencies": {
"@types/node": "^22.0.0",
"aws-cdk": "^2.170.0",
"typescript": "^5.7.0"
}
},
"node_modules/@aws-cdk/asset-awscli-v1": {
"version": "2.2.258",
"resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.258.tgz",
"integrity": "sha512-TL3I9cIue0bAsuwrmjgjAQaEH6JL09y49FVQMDhrz4jJ2iPKuHtdrYd7ydm02t1YZdPZE2M0VNj6VD4fGIFpvw==",
"license": "Apache-2.0"
},
"node_modules/@aws-cdk/asset-node-proxy-agent-v6": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.1.0.tgz",
"integrity": "sha512-7bY3J8GCVxLupn/kNmpPc5VJz8grx+4RKfnnJiO1LG+uxkZfANZG3RMHhE+qQxxwkyQ9/MfPtTpf748UhR425A==",
"license": "Apache-2.0"
},
"node_modules/@aws-cdk/cloud-assembly-schema": {
"version": "48.20.0",
"resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-48.20.0.tgz",
"integrity": "sha512-+eeiav9LY4wbF/EFuCt/vfvi/Zoxo8bf94PW5clbMraChEliq83w4TbRVy0jB9jE0v1ooFTtIjSQkowSPkfISg==",
"bundleDependencies": [
"jsonschema",
"semver"
],
"license": "Apache-2.0",
"dependencies": {
"jsonschema": "~1.4.1",
"semver": "^7.7.2"
},
"engines": {
"node": ">= 18.0.0"
}
},
"node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": {
"version": "1.4.1",
"inBundle": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": {
"version": "7.7.2",
"inBundle": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@types/node": {
"version": "22.19.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.6.tgz",
"integrity": "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/aws-cdk": {
"version": "2.1100.3",
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1100.3.tgz",
"integrity": "sha512-jeSamF+IwPJKhqMir7Cw+2IoeHsmNFc/SoDAlOS9BYM8Wrd0Q1jJd3GcJOFzsMcWv9mcBAP5o23amyKHu03dXA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"cdk": "bin/cdk"
},
"engines": {
"node": ">= 18.0.0"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/aws-cdk-lib": {
"version": "2.234.1",
"resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.234.1.tgz",
"integrity": "sha512-2oNqAA1qjF9xHCom6yHuY8KE6UltK7pTg3egf/t1+C6/OFEaw9+jyhCWmTasGmvjyQSkbvKiCPZco0l+XVyxiQ==",
"bundleDependencies": [
"@balena/dockerignore",
"case",
"fs-extra",
"ignore",
"jsonschema",
"minimatch",
"punycode",
"semver",
"table",
"yaml",
"mime-types"
],
"license": "Apache-2.0",
"dependencies": {
"@aws-cdk/asset-awscli-v1": "2.2.258",
"@aws-cdk/asset-node-proxy-agent-v6": "^2.1.0",
"@aws-cdk/cloud-assembly-schema": "^48.20.0",
"@balena/dockerignore": "^1.0.2",
"case": "1.6.3",
"fs-extra": "^11.3.3",
"ignore": "^5.3.2",
"jsonschema": "^1.5.0",
"mime-types": "^2.1.35",
"minimatch": "^3.1.2",
"punycode": "^2.3.1",
"semver": "^7.7.3",
"table": "^6.9.0",
"yaml": "1.10.2"
},
"engines": {
"node": ">= 18.0.0"
},
"peerDependencies": {
"constructs": "^10.0.0"
}
},
"node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": {
"version": "1.0.2",
"inBundle": true,
"license": "Apache-2.0"
},
"node_modules/aws-cdk-lib/node_modules/ajv": {
"version": "8.17.1",
"inBundle": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/aws-cdk-lib/node_modules/ansi-regex": {
"version": "5.0.1",
"inBundle": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/aws-cdk-lib/node_modules/ansi-styles": {
"version": "4.3.0",
"inBundle": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/aws-cdk-lib/node_modules/astral-regex": {
"version": "2.0.0",
"inBundle": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/aws-cdk-lib/node_modules/balanced-match": {
"version": "1.0.2",
"inBundle": true,
"license": "MIT"
},
"node_modules/aws-cdk-lib/node_modules/brace-expansion": {
"version": "1.1.12",
"inBundle": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/aws-cdk-lib/node_modules/case": {
"version": "1.6.3",
"inBundle": true,
"license": "(MIT OR GPL-3.0-or-later)",
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/aws-cdk-lib/node_modules/color-convert": {
"version": "2.0.1",
"inBundle": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/aws-cdk-lib/node_modules/color-name": {
"version": "1.1.4",
"inBundle": true,
"license": "MIT"
},
"node_modules/aws-cdk-lib/node_modules/concat-map": {
"version": "0.0.1",
"inBundle": true,
"license": "MIT"
},
"node_modules/aws-cdk-lib/node_modules/emoji-regex": {
"version": "8.0.0",
"inBundle": true,
"license": "MIT"
},
"node_modules/aws-cdk-lib/node_modules/fast-deep-equal": {
"version": "3.1.3",
"inBundle": true,
"license": "MIT"
},
"node_modules/aws-cdk-lib/node_modules/fast-uri": {
"version": "3.1.0",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"inBundle": true,
"license": "BSD-3-Clause"
},
"node_modules/aws-cdk-lib/node_modules/fs-extra": {
"version": "11.3.3",
"inBundle": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/aws-cdk-lib/node_modules/graceful-fs": {
"version": "4.2.11",
"inBundle": true,
"license": "ISC"
},
"node_modules/aws-cdk-lib/node_modules/ignore": {
"version": "5.3.2",
"inBundle": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/aws-cdk-lib/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"inBundle": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/aws-cdk-lib/node_modules/json-schema-traverse": {
"version": "1.0.0",
"inBundle": true,
"license": "MIT"
},
"node_modules/aws-cdk-lib/node_modules/jsonfile": {
"version": "6.2.0",
"inBundle": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/aws-cdk-lib/node_modules/jsonschema": {
"version": "1.5.0",
"inBundle": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/aws-cdk-lib/node_modules/lodash.truncate": {
"version": "4.4.2",
"inBundle": true,
"license": "MIT"
},
"node_modules/aws-cdk-lib/node_modules/mime-db": {
"version": "1.52.0",
"inBundle": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/aws-cdk-lib/node_modules/mime-types": {
"version": "2.1.35",
"inBundle": true,
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/aws-cdk-lib/node_modules/minimatch": {
"version": "3.1.2",
"inBundle": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/aws-cdk-lib/node_modules/punycode": {
"version": "2.3.1",
"inBundle": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/aws-cdk-lib/node_modules/require-from-string": {
"version": "2.0.2",
"inBundle": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/aws-cdk-lib/node_modules/semver": {
"version": "7.7.3",
"inBundle": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/aws-cdk-lib/node_modules/slice-ansi": {
"version": "4.0.0",
"inBundle": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"astral-regex": "^2.0.0",
"is-fullwidth-code-point": "^3.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
}
},
"node_modules/aws-cdk-lib/node_modules/string-width": {
"version": "4.2.3",
"inBundle": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/aws-cdk-lib/node_modules/strip-ansi": {
"version": "6.0.1",
"inBundle": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/aws-cdk-lib/node_modules/table": {
"version": "6.9.0",
"inBundle": true,
"license": "BSD-3-Clause",
"dependencies": {
"ajv": "^8.0.1",
"lodash.truncate": "^4.4.2",
"slice-ansi": "^4.0.0",
"string-width": "^4.2.3",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/aws-cdk-lib/node_modules/universalify": {
"version": "2.0.1",
"inBundle": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/aws-cdk-lib/node_modules/yaml": {
"version": "1.10.2",
"inBundle": true,
"license": "ISC",
"engines": {
"node": ">= 6"
}
},
"node_modules/constructs": {
"version": "10.4.4",
"resolved": "https://registry.npmjs.org/constructs/-/constructs-10.4.4.tgz",
"integrity": "sha512-lP0qC1oViYf1cutHo9/KQ8QL637f/W29tDmv/6sy35F5zs+MD9f66nbAAIjicwc7fwyuF3rkg6PhZh4sfvWIpA==",
"license": "Apache-2.0"
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

View File

@@ -1,25 +0,0 @@
{
"name": "rentall-infrastructure",
"version": "1.0.0",
"description": "AWS CDK infrastructure for Rentall Lambda functions",
"scripts": {
"build": "tsc",
"watch": "tsc -w",
"cdk": "cdk",
"synth": "cdk synth",
"deploy": "cdk deploy --all",
"deploy:staging": "cdk deploy --all --context env=staging",
"deploy:prod": "cdk deploy --all --context env=prod",
"diff": "cdk diff",
"destroy": "cdk destroy --all"
},
"dependencies": {
"aws-cdk-lib": "^2.170.0",
"constructs": "^10.4.2"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"aws-cdk": "^2.170.0"
}
}

View File

@@ -1,26 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"declaration": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": false,
"inlineSourceMap": true,
"inlineSources": true,
"experimentalDecorators": true,
"strictPropertyInitialization": false,
"outDir": "./dist",
"rootDir": ".",
"typeRoots": ["./node_modules/@types"]
},
"include": ["bin/**/*", "lib/**/*"],
"exclude": ["node_modules", "cdk.out"]
}

View File

@@ -5,7 +5,7 @@ Sends email reminders to owners and renters to complete condition checks at key
## Check Types ## Check Types
| Check Type | Recipient | Timing | | Check Type | Recipient | Timing |
|------------|-----------|--------| | --------------------- | --------- | ---------------------------- |
| `pre_rental_owner` | Owner | 24 hours before rental start | | `pre_rental_owner` | Owner | 24 hours before rental start |
| `rental_start_renter` | Renter | At rental start | | `rental_start_renter` | Renter | At rental start |
| `rental_end_renter` | Renter | At rental end | | `rental_end_renter` | Renter | At rental end |
@@ -22,11 +22,6 @@ cd ../conditionCheckReminder && npm install
### Set Up Environment ### Set Up Environment
```bash
cp .env.example .env.dev
# Edit .env.dev with your DATABASE_URL
```
### Run Locally ### Run Locally
```bash ```bash
@@ -36,18 +31,3 @@ npm run local
# Specify rental ID and check type # Specify rental ID and check type
node -r dotenv/config test-local.js dotenv_config_path=.env.dev 123 rental_start_renter node -r dotenv/config test-local.js dotenv_config_path=.env.dev 123 rental_start_renter
``` ```
## Environment Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `DATABASE_URL` | PostgreSQL connection string | `postgresql://user:pass@localhost:5432/rentall` |
| `FRONTEND_URL` | Frontend URL for email links | `http://localhost:3000` |
| `SES_FROM_EMAIL` | Sender email address | `noreply@villageshare.app` |
| `EMAIL_ENABLED` | Enable/disable email sending | `false` |
| `SCHEDULE_GROUP_NAME` | EventBridge schedule group | `condition-check-reminders-dev` |
| `AWS_REGION` | AWS region | `us-east-1` |
## Deployment
See [infrastructure/cdk/README.md](../../infrastructure/cdk/README.md) for deployment instructions.

View File

@@ -14,7 +14,7 @@ let schedulerClient = null;
function getSchedulerClient() { function getSchedulerClient() {
if (!schedulerClient) { if (!schedulerClient) {
schedulerClient = new SchedulerClient({ schedulerClient = new SchedulerClient({
region: process.env.AWS_REGION || "us-east-1", region: process.env.AWS_REGION,
}); });
} }
return schedulerClient; return schedulerClient;
@@ -34,7 +34,7 @@ async function deleteSchedule(scheduleName) {
new DeleteScheduleCommand({ new DeleteScheduleCommand({
Name: scheduleName, Name: scheduleName,
GroupName: groupName, GroupName: groupName,
}) }),
); );
logger.info("Deleted schedule after execution", { logger.info("Deleted schedule after execution", {
@@ -74,7 +74,9 @@ function getEmailContent(checkType, rental) {
title: "Rental Start Condition Check", title: "Rental Start Condition Check",
message: `Please take photos when you receive "${itemName}" to document its condition. This protects you in case of any disputes.`, message: `Please take photos when you receive "${itemName}" to document its condition. This protects you in case of any disputes.`,
deadline: email.formatEmailDate( deadline: email.formatEmailDate(
new Date(new Date(rental.startDateTime).getTime() + 24 * 60 * 60 * 1000) new Date(
new Date(rental.startDateTime).getTime() + 24 * 60 * 60 * 1000,
),
), ),
}, },
rental_end_renter: { rental_end_renter: {
@@ -90,7 +92,7 @@ function getEmailContent(checkType, rental) {
title: "Post-Rental Condition Check", title: "Post-Rental Condition Check",
message: `Please take photos and mark the return status for "${itemName}". This completes the rental process.`, message: `Please take photos and mark the return status for "${itemName}". This completes the rental process.`,
deadline: email.formatEmailDate( deadline: email.formatEmailDate(
new Date(new Date(rental.endDateTime).getTime() + 48 * 60 * 60 * 1000) new Date(new Date(rental.endDateTime).getTime() + 48 * 60 * 60 * 1000),
), ),
}, },
}; };
@@ -162,7 +164,7 @@ async function processReminder(rentalId, checkType, scheduleName) {
const templatePath = path.join( const templatePath = path.join(
__dirname, __dirname,
"templates", "templates",
"conditionCheckReminderToUser.html" "conditionCheckReminderToUser.html",
); );
const template = await email.loadTemplate(templatePath); const template = await email.loadTemplate(templatePath);
@@ -178,7 +180,7 @@ async function processReminder(rentalId, checkType, scheduleName) {
const result = await email.sendEmail( const result = await email.sendEmail(
emailContent.recipient.email, emailContent.recipient.email,
emailContent.subject, emailContent.subject,
htmlBody htmlBody,
); );
if (!result.success) { if (!result.success) {

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
"main": "index.js", "main": "index.js",
"dependencies": { "dependencies": {
"@aws-sdk/client-scheduler": "^3.896.0", "@aws-sdk/client-scheduler": "^3.896.0",
"@rentall/lambda-shared": "file:../shared" "@village-share/lambda-shared": "file:../shared"
}, },
"devDependencies": { "devDependencies": {
"dotenv": "^17.2.3", "dotenv": "^17.2.3",

View File

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

View File

@@ -1,41 +1,29 @@
/** /**
* Local test script for the condition check reminder lambda * Local test script for the condition check reminder lambda
*
* Usage:
* 1. Set environment variables (or create a .env file)
* 2. Run: node test-local.js
*
* Required environment variables:
* - DATABASE_URL: PostgreSQL connection string
* - FRONTEND_URL: Frontend URL for email links
* - SES_FROM_EMAIL: Email sender address
* - EMAIL_ENABLED: Set to 'false' to skip actual email sending
* - SCHEDULE_GROUP_NAME: EventBridge schedule group name
* - AWS_REGION: AWS region
*/ */
const { handler } = require('./index'); const { handler } = require("./index");
// Test event - modify these values as needed // Test event - modify these values as needed
const testEvent = { const testEvent = {
rentalId: parseInt(process.argv[2]) || 1, // Pass rental ID as CLI arg or default to 1 rentalId: parseInt(process.argv[2]) || 1, // Pass rental ID as CLI arg or default to 1
checkType: process.argv[3] || 'pre_rental_owner' // Options: pre_rental_owner, rental_start_renter, rental_end_renter, post_rental_owner checkType: process.argv[3] || "pre_rental_owner", // Options: pre_rental_owner, rental_start_renter, rental_end_renter, post_rental_owner
}; };
console.log('Running condition check reminder lambda locally...'); console.log("Running condition check reminder lambda locally...");
console.log('Event:', JSON.stringify(testEvent, null, 2)); console.log("Event:", JSON.stringify(testEvent, null, 2));
console.log('---'); console.log("---");
handler(testEvent) handler(testEvent)
.then(result => { .then((result) => {
console.log('---'); console.log("---");
console.log('Success!'); console.log("Success!");
console.log('Result:', JSON.stringify(result, null, 2)); console.log("Result:", JSON.stringify(result, null, 2));
process.exit(0); process.exit(0);
}) })
.catch(err => { .catch((err) => {
console.error('---'); console.error("---");
console.error('Error:', err.message); console.error("Error:", err.message);
console.error(err.stack); console.error(err.stack);
process.exit(1); process.exit(1);
}); });

View File

@@ -20,17 +20,6 @@ cd lambdas/shared && npm install
cd ../imageProcessor && npm install cd ../imageProcessor && npm install
``` ```
### Set Up Environment
## Environment Variables
| Variable | Description | Example |
| -------------- | ---------------------------- | ----------------------------------------------- |
| `DATABASE_URL` | PostgreSQL connection string | `postgresql://user:pass@localhost:5432/db-name` |
| `S3_BUCKET` | S3 bucket name | `bucket-name` |
| `AWS_REGION` | AWS region | `us-east-1` |
| `LOG_LEVEL` | Logging level | `debug`, `info`, `warn`, `error` |
### Run Locally ### Run Locally
```bash ```bash

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
"main": "index.js", "main": "index.js",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.400.0", "@aws-sdk/client-s3": "^3.400.0",
"@rentall/lambda-shared": "file:../shared", "@village-share/lambda-shared": "file:../shared",
"exif-reader": "^2.0.0", "exif-reader": "^2.0.0",
"sharp": "^0.33.0" "sharp": "^0.33.0"
}, },

View File

@@ -7,14 +7,15 @@
* Example: * Example:
* npm run local -- staging/items/test-image.jpg my-bucket * npm run local -- staging/items/test-image.jpg my-bucket
* *
* Note: Requires .env.dev file with DATABASE_URL and AWS credentials configured.
*/ */
const { handler } = require("./index"); const { handler } = require("./index");
async function main() { async function main() {
// Filter out dotenv config args from process.argv // Filter out dotenv config args from process.argv
const args = process.argv.slice(2).filter(arg => !arg.startsWith("dotenv_config_path")); const args = process.argv
.slice(2)
.filter((arg) => !arg.startsWith("dotenv_config_path"));
// Get staging key from command line args // Get staging key from command line args
const stagingKey = args[0] || "staging/items/test-image.jpg"; const stagingKey = args[0] || "staging/items/test-image.jpg";

View File

@@ -2,12 +2,6 @@
Retries failed Stripe payouts daily. Triggered by EventBridge Scheduler at 7 AM EST. Retries failed Stripe payouts daily. Triggered by EventBridge Scheduler at 7 AM EST.
## Prerequisites
- Node.js 20.x
- PostgreSQL database with rentals data
- Stripe account with test API key (`sk_test_...`)
## Setup ## Setup
1. Install shared dependencies: 1. Install shared dependencies:
@@ -23,17 +17,6 @@ Retries failed Stripe payouts daily. Triggered by EventBridge Scheduler at 7 AM
npm install npm install
``` ```
## Environment Variables
| Variable | Description |
| ------------------- | ---------------------------------------------- |
| `DATABASE_URL` | PostgreSQL connection string |
| `STRIPE_SECRET_KEY` | Stripe API key (use `sk_test_...` for testing) |
| `FRONTEND_URL` | For email template links |
| `SES_FROM_EMAIL` | Sender email address |
| `SES_FROM_NAME` | Sender display name |
| `EMAIL_ENABLED` | Set to `true` to send emails |
## Local Testing ## Local Testing
Run the Lambda locally using your dev environment: Run the Lambda locally using your dev environment:
@@ -94,7 +77,7 @@ EventBridge Scheduler (7 AM EST daily)
v v
Lambda Function Lambda Function
| |
+-- Query failed payouts from PostgreSQL +-- Query failed payouts from database
| |
+-- For each failed payout: +-- For each failed payout:
| +-- Reset status to "pending" | +-- Reset status to "pending"

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
"description": "Lambda function to retry failed payouts via Stripe Connect", "description": "Lambda function to retry failed payouts via Stripe Connect",
"main": "index.js", "main": "index.js",
"dependencies": { "dependencies": {
"@rentall/lambda-shared": "file:../shared" "@village-share/lambda-shared": "file:../shared"
}, },
"devDependencies": { "devDependencies": {
"dotenv": "^16.4.5" "dotenv": "^16.4.5"

View File

@@ -7,9 +7,6 @@ let pool = null;
* Uses connection pooling optimized for Lambda: * Uses connection pooling optimized for Lambda:
* - Reuses connections across invocations (when container is warm) * - Reuses connections across invocations (when container is warm)
* - Small pool size to avoid exhausting database connections * - Small pool size to avoid exhausting database connections
*
* Expects DATABASE_URL environment variable in format:
* postgresql://user:password@host:port/database
*/ */
function getPool() { function getPool() {
if (!pool) { if (!pool) {
@@ -51,22 +48,26 @@ async function query(text, params) {
const result = await pool.query(text, params); const result = await pool.query(text, params);
const duration = Date.now() - start; const duration = Date.now() - start;
console.log(JSON.stringify({ console.log(
JSON.stringify({
level: "debug", level: "debug",
message: "Executed query", message: "Executed query",
query: text.substring(0, 100), query: text.substring(0, 100),
duration, duration,
rows: result.rowCount, rows: result.rowCount,
})); }),
);
return result; return result;
} catch (error) { } catch (error) {
console.error(JSON.stringify({ console.error(
JSON.stringify({
level: "error", level: "error",
message: "Query failed", message: "Query failed",
query: text.substring(0, 100), query: text.substring(0, 100),
error: error.message, error: error.message,
})); }),
);
throw error; throw error;
} }
} }

View File

@@ -11,7 +11,7 @@ let sesClient = null;
function getSESClient() { function getSESClient() {
if (!sesClient) { if (!sesClient) {
sesClient = new SESClient({ sesClient = new SESClient({
region: process.env.AWS_REGION || "us-east-1", region: process.env.AWS_REGION,
}); });
} }
return sesClient; return sesClient;
@@ -69,12 +69,14 @@ async function loadTemplate(templatePath) {
try { try {
return await fs.readFile(templatePath, "utf-8"); return await fs.readFile(templatePath, "utf-8");
} catch (error) { } catch (error) {
console.error(JSON.stringify({ console.error(
JSON.stringify({
level: "error", level: "error",
message: "Failed to load email template", message: "Failed to load email template",
templatePath, templatePath,
error: error.message, error: error.message,
})); }),
);
throw error; throw error;
} }
} }
@@ -90,12 +92,14 @@ async function loadTemplate(templatePath) {
async function sendEmail(to, subject, htmlBody, textBody = null) { async function sendEmail(to, subject, htmlBody, textBody = null) {
// Check if email sending is enabled // Check if email sending is enabled
if (process.env.EMAIL_ENABLED !== "true") { if (process.env.EMAIL_ENABLED !== "true") {
console.log(JSON.stringify({ console.log(
JSON.stringify({
level: "info", level: "info",
message: "Email sending disabled, skipping", message: "Email sending disabled, skipping",
to, to,
subject, subject,
})); }),
);
return { success: true, messageId: "disabled" }; return { success: true, messageId: "disabled" };
} }
@@ -146,23 +150,27 @@ async function sendEmail(to, subject, htmlBody, textBody = null) {
const command = new SendEmailCommand(params); const command = new SendEmailCommand(params);
const result = await client.send(command); const result = await client.send(command);
console.log(JSON.stringify({ console.log(
JSON.stringify({
level: "info", level: "info",
message: "Email sent successfully", message: "Email sent successfully",
to, to,
subject, subject,
messageId: result.MessageId, messageId: result.MessageId,
})); }),
);
return { success: true, messageId: result.MessageId }; return { success: true, messageId: result.MessageId };
} catch (error) { } catch (error) {
console.error(JSON.stringify({ console.error(
JSON.stringify({
level: "error", level: "error",
message: "Failed to send email", message: "Failed to send email",
to, to,
subject, subject,
error: error.message, error: error.message,
})); }),
);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }

View File

@@ -1,5 +1,5 @@
/** /**
* Shared utilities for Rentall Lambda functions. * Shared utilities for Village Share Lambda functions.
*/ */
const db = require("./db/connection"); const db = require("./db/connection");

View File

@@ -1,11 +1,11 @@
{ {
"name": "@rentall/lambda-shared", "name": "@village-share/lambda-shared",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@rentall/lambda-shared", "name": "@village-share/lambda-shared",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@aws-sdk/client-scheduler": "^3.896.0", "@aws-sdk/client-scheduler": "^3.896.0",
@@ -1216,40 +1216,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@emnapi/core": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
"integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
"integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz",
"integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -1687,19 +1653,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
"integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@tybys/wasm-util": "^0.10.0"
}
},
"node_modules/@pkgjs/parseargs": { "node_modules/@pkgjs/parseargs": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -2345,17 +2298,6 @@
"node": ">=18.0.0" "node": ">=18.0.0"
} }
}, },
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2468,48 +2410,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/@unrs/resolver-binding-android-arm-eabi": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
"integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@unrs/resolver-binding-android-arm64": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz",
"integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@unrs/resolver-binding-darwin-arm64": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz",
"integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@unrs/resolver-binding-darwin-x64": { "node_modules/@unrs/resolver-binding-darwin-x64": {
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz",
@@ -2524,219 +2424,6 @@
"darwin" "darwin"
] ]
}, },
"node_modules/@unrs/resolver-binding-freebsd-x64": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz",
"integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz",
"integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-arm-musleabihf": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz",
"integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-arm64-gnu": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz",
"integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-arm64-musl": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz",
"integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-ppc64-gnu": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz",
"integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-riscv64-gnu": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz",
"integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-riscv64-musl": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz",
"integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-s390x-gnu": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz",
"integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-x64-gnu": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz",
"integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-x64-musl": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz",
"integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-wasm32-wasi": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz",
"integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^0.2.11"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz",
"integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz",
"integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@unrs/resolver-binding-win32-x64-msvc": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz",
"integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/ansi-escapes": { "node_modules/ansi-escapes": {
"version": "4.3.2", "version": "4.3.2",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",

View File

@@ -1,7 +1,7 @@
{ {
"name": "@rentall/lambda-shared", "name": "@village-share/lambda-shared",
"version": "1.0.0", "version": "1.0.0",
"description": "Shared utilities for Rentall Lambda functions", "description": "Shared utilities for Village Share Lambda functions",
"main": "index.js", "main": "index.js",
"dependencies": { "dependencies": {
"@aws-sdk/client-ses": "^3.896.0", "@aws-sdk/client-ses": "^3.896.0",