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

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() {
const config = {
region: process.env.AWS_REGION || "us-east-1",
region: process.env.AWS_REGION,
};
const credentials = getAWSCredentials();

View File

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

View File

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

View File

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

View File

@@ -10,13 +10,9 @@ module.exports = {
},
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(
"Note: PostgreSQL does not support removing ENUM values. " +
"'requires_action' will remain in the enum but will not be used."
"PostgreSQL does not support removing ENUM values. " +
"'requires_action' will remain in the enum but will not be used.",
);
},
};

View File

@@ -9,10 +9,8 @@ module.exports = {
},
down: async (queryInterface, Sequelize) => {
// Note: PostgreSQL doesn't support removing enum values directly
// This would require recreating the enum type
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) {
@@ -457,7 +457,7 @@ User.prototype.unbanUser = async function () {
bannedAt: null,
bannedBy: 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
User.prototype.storePendingTotpSecret = async function (
encryptedSecret,
encryptedSecretIv
encryptedSecretIv,
) {
return this.update({
twoFactorSetupPendingSecret: encryptedSecret,
@@ -478,7 +478,7 @@ User.prototype.storePendingTotpSecret = async function (
// Enable TOTP 2FA after verification
User.prototype.enableTotp = async function (recoveryCodes) {
const hashedCodes = await Promise.all(
recoveryCodes.map((code) => bcrypt.hash(code, 12))
recoveryCodes.map((code) => bcrypt.hash(code, 12)),
);
// Store in structured format
@@ -506,7 +506,7 @@ User.prototype.enableTotp = async function (recoveryCodes) {
// Enable Email 2FA
User.prototype.enableEmailTwoFactor = async function (recoveryCodes) {
const hashedCodes = await Promise.all(
recoveryCodes.map((code) => bcrypt.hash(code, 12))
recoveryCodes.map((code) => bcrypt.hash(code, 12)),
);
// Store in structured format
@@ -563,7 +563,7 @@ User.prototype.verifyEmailOtp = function (inputCode) {
return TwoFactorService.verifyEmailOtp(
inputCode,
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");
recentCodes.unshift(codeHash);
// Keep only last 5 codes (covers about 2.5 minutes of 30-second windows)
await this.update({ recentTotpCodes: JSON.stringify(recentCodes.slice(0, 5)) });
await this.update({
recentTotpCodes: JSON.stringify(recentCodes.slice(0, 5)),
});
};
// Verify TOTP code with replay protection
@@ -615,18 +617,25 @@ User.prototype.verifyTotpCode = function (code) {
if (this.hasUsedTotpCode(code)) {
return false;
}
return TwoFactorService.verifyTotpCode(this.totpSecret, this.totpSecretIv, code);
return TwoFactorService.verifyTotpCode(
this.totpSecret,
this.totpSecretIv,
code,
);
};
// Verify pending TOTP code (during setup)
User.prototype.verifyPendingTotpCode = function (code) {
if (!this.twoFactorSetupPendingSecret || !this.twoFactorSetupPendingSecretIv) {
if (
!this.twoFactorSetupPendingSecret ||
!this.twoFactorSetupPendingSecretIv
) {
return false;
}
return TwoFactorService.verifyTotpCode(
this.twoFactorSetupPendingSecret,
this.twoFactorSetupPendingSecretIv,
code
code,
);
};
@@ -639,7 +648,7 @@ User.prototype.useRecoveryCode = async function (inputCode) {
const recoveryData = JSON.parse(this.recoveryCodesHash);
const { valid, index } = await TwoFactorService.verifyRecoveryCode(
inputCode,
recoveryData
recoveryData,
);
if (valid) {
@@ -661,7 +670,8 @@ User.prototype.useRecoveryCode = async function (inputCode) {
return {
valid,
remainingCodes: TwoFactorService.getRemainingRecoveryCodesCount(recoveryData),
remainingCodes:
TwoFactorService.getRemainingRecoveryCodesCount(recoveryData),
};
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -57,7 +57,7 @@ class ForumEmailService {
}
try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
const timestamp = new Date(comment.createdAt).toLocaleString("en-US", {
@@ -77,7 +77,7 @@ class ForumEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"forumCommentToPostAuthor",
variables
variables,
);
const subject = `${commenter.firstName} ${commenter.lastName} commented on your post`;
@@ -85,12 +85,12 @@ class ForumEmailService {
const result = await this.emailClient.sendEmail(
postAuthor.email,
subject,
htmlContent
htmlContent,
);
if (result.success) {
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,
post,
reply,
parentComment
parentComment,
) {
if (!this.initialized) {
await this.initialize();
}
try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
const timestamp = new Date(reply.createdAt).toLocaleString("en-US", {
@@ -152,7 +152,7 @@ class ForumEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"forumReplyToCommentAuthor",
variables
variables,
);
const subject = `${replier.firstName} ${replier.lastName} replied to your comment`;
@@ -160,12 +160,12 @@ class ForumEmailService {
const result = await this.emailClient.sendEmail(
commentAuthor.email,
subject,
htmlContent
htmlContent,
);
if (result.success) {
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,
postAuthor,
post,
comment
comment,
) {
if (!this.initialized) {
await this.initialize();
}
try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
const variables = {
@@ -216,7 +216,7 @@ class ForumEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"forumAnswerAcceptedToCommentAuthor",
variables
variables,
);
const subject = `Your comment was marked as the accepted answer!`;
@@ -224,12 +224,12 @@ class ForumEmailService {
const result = await this.emailClient.sendEmail(
commentAuthor.email,
subject,
htmlContent
htmlContent,
);
if (result.success) {
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) {
logger.error(
"Failed to send forum answer accepted notification email:",
error
error,
);
return { success: false, error: error.message };
}
@@ -263,14 +263,14 @@ class ForumEmailService {
participant,
commenter,
post,
comment
comment,
) {
if (!this.initialized) {
await this.initialize();
}
try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
const timestamp = new Date(comment.createdAt).toLocaleString("en-US", {
@@ -290,7 +290,7 @@ class ForumEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"forumThreadActivityToParticipant",
variables
variables,
);
const subject = `New activity on a post you're following`;
@@ -298,12 +298,12 @@ class ForumEmailService {
const result = await this.emailClient.sendEmail(
participant.email,
subject,
htmlContent
htmlContent,
);
if (result.success) {
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) {
logger.error(
"Failed to send forum thread activity notification email:",
error
error,
);
return { success: false, error: error.message };
}
@@ -331,18 +331,13 @@ class ForumEmailService {
* @param {Date} closedAt - Timestamp when discussion was closed
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
*/
async sendForumPostClosedNotification(
recipient,
closer,
post,
closedAt
) {
async sendForumPostClosedNotification(recipient, closer, post, closedAt) {
if (!this.initialized) {
await this.initialize();
}
try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
const timestamp = new Date(closedAt).toLocaleString("en-US", {
@@ -352,8 +347,7 @@ class ForumEmailService {
const variables = {
recipientName: recipient.firstName || "there",
adminName:
`${closer.firstName} ${closer.lastName}`.trim() || "A user",
adminName: `${closer.firstName} ${closer.lastName}`.trim() || "A user",
postTitle: post.title,
postUrl: postUrl,
timestamp: timestamp,
@@ -361,7 +355,7 @@ class ForumEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"forumPostClosed",
variables
variables,
);
const subject = `Discussion closed: ${post.title}`;
@@ -369,12 +363,12 @@ class ForumEmailService {
const result = await this.emailClient.sendEmail(
recipient.email,
subject,
htmlContent
htmlContent,
);
if (result.success) {
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) {
logger.error(
"Failed to send forum post closed notification email:",
error
error,
);
return { success: false, error: error.message };
}
@@ -401,18 +395,24 @@ class ForumEmailService {
* @param {string} deletionReason - Reason for deletion
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
*/
async sendForumPostDeletionNotification(postAuthor, admin, post, deletionReason) {
async sendForumPostDeletionNotification(
postAuthor,
admin,
post,
deletionReason,
) {
if (!this.initialized) {
await this.initialize();
}
try {
const supportEmail = process.env.SUPPORT_EMAIL;
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const supportEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
const frontendUrl = process.env.FRONTEND_URL;
const variables = {
postAuthorName: postAuthor.firstName || "there",
adminName: `${admin.firstName} ${admin.lastName}`.trim() || "An administrator",
adminName:
`${admin.firstName} ${admin.lastName}`.trim() || "An administrator",
postTitle: post.title,
deletionReason,
supportEmail,
@@ -421,7 +421,7 @@ class ForumEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"forumPostDeletionToAuthor",
variables
variables,
);
const subject = `Important: Your forum post "${post.title}" has been removed`;
@@ -429,12 +429,12 @@ class ForumEmailService {
const result = await this.emailClient.sendEmail(
postAuthor.email,
subject,
htmlContent
htmlContent,
);
if (result.success) {
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) {
logger.error(
"Failed to send forum post deletion notification email:",
error
error,
);
return { success: false, error: error.message };
}
@@ -462,19 +462,25 @@ class ForumEmailService {
* @param {string} deletionReason - Reason for deletion
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
*/
async sendForumCommentDeletionNotification(commentAuthor, admin, post, deletionReason) {
async sendForumCommentDeletionNotification(
commentAuthor,
admin,
post,
deletionReason,
) {
if (!this.initialized) {
await this.initialize();
}
try {
const supportEmail = process.env.SUPPORT_EMAIL;
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const supportEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
const frontendUrl = process.env.FRONTEND_URL;
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
const variables = {
commentAuthorName: commentAuthor.firstName || "there",
adminName: `${admin.firstName} ${admin.lastName}`.trim() || "An administrator",
adminName:
`${admin.firstName} ${admin.lastName}`.trim() || "An administrator",
postTitle: post.title,
postUrl,
deletionReason,
@@ -483,7 +489,7 @@ class ForumEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"forumCommentDeletionToAuthor",
variables
variables,
);
const subject = `Your comment on "${post.title}" has been removed`;
@@ -491,12 +497,12 @@ class ForumEmailService {
const result = await this.emailClient.sendEmail(
commentAuthor.email,
subject,
htmlContent
htmlContent,
);
if (result.success) {
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) {
logger.error(
"Failed to send forum comment deletion notification email:",
error
error,
);
return { success: false, error: error.message };
}
@@ -531,7 +537,7 @@ class ForumEmailService {
}
try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
const variables = {
@@ -546,7 +552,7 @@ class ForumEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"forumItemRequestNotification",
variables
variables,
);
const subject = `Someone nearby is looking for: ${post.title}`;
@@ -554,12 +560,12 @@ class ForumEmailService {
const result = await this.emailClient.sendEmail(
recipient.email,
subject,
htmlContent
htmlContent,
);
if (result.success) {
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 {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const frontendUrl = process.env.FRONTEND_URL;
const conversationUrl = `${frontendUrl}/messages/conversations/${sender.id}`;
const timestamp = new Date(message.createdAt).toLocaleString("en-US", {
@@ -68,7 +68,7 @@ class MessagingEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"newMessageToUser",
variables
variables,
);
const subject = `New message from ${sender.firstName} ${sender.lastName}`;
@@ -76,12 +76,12 @@ class MessagingEmailService {
const result = await this.emailClient.sendEmail(
receiver.email,
subject,
htmlContent
htmlContent,
);
if (result.success) {
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 {
const {
renterFirstName,
itemName,
declineReason,
updatePaymentUrl,
} = params;
const { renterFirstName, itemName, declineReason, updatePaymentUrl } =
params;
const variables = {
renterFirstName: renterFirstName || "there",
@@ -65,13 +61,13 @@ class PaymentEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"paymentDeclinedToRenter",
variables
variables,
);
return await this.emailClient.sendEmail(
renterEmail,
`Action Required: Payment Issue - ${itemName || "Your Rental"}`,
htmlContent
htmlContent,
);
} catch (error) {
logger.error("Failed to send payment declined notification", { error });
@@ -105,16 +101,18 @@ class PaymentEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"paymentMethodUpdatedToOwner",
variables
variables,
);
return await this.emailClient.sendEmail(
ownerEmail,
`Payment Method Updated - ${itemName || "Your Item"}`,
htmlContent
htmlContent,
);
} 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 };
}
}
@@ -151,22 +149,25 @@ class PaymentEmailService {
const variables = {
ownerName: ownerName || "there",
payoutAmount: payoutAmount?.toFixed(2) || "0.00",
failureMessage: failureMessage || "There was an issue with your payout.",
actionRequired: actionRequired || "Please check your bank account details.",
failureMessage:
failureMessage || "There was an issue with your payout.",
actionRequired:
actionRequired || "Please check your bank account details.",
failureCode: failureCode || "unknown",
requiresBankUpdate: requiresBankUpdate || false,
payoutSettingsUrl: payoutSettingsUrl || process.env.FRONTEND_URL + "/settings/payouts",
payoutSettingsUrl:
payoutSettingsUrl || process.env.FRONTEND_URL + "/settings/payouts",
};
const htmlContent = await this.templateManager.renderTemplate(
"payoutFailedToOwner",
variables
variables,
);
return await this.emailClient.sendEmail(
ownerEmail,
"Action Required: Payout Issue - Village Share",
htmlContent
htmlContent,
);
} catch (error) {
logger.error("Failed to send payout failed notification", { error });
@@ -200,13 +201,13 @@ class PaymentEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"accountDisconnectedToOwner",
variables
variables,
);
return await this.emailClient.sendEmail(
ownerEmail,
"Your payout account has been disconnected - Village Share",
htmlContent
htmlContent,
);
} catch (error) {
logger.error("Failed to send account disconnected email", { error });
@@ -240,13 +241,13 @@ class PaymentEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"payoutsDisabledToOwner",
variables
variables,
);
return await this.emailClient.sendEmail(
ownerEmail,
"Action Required: Your payouts have been paused - Village Share",
htmlContent
htmlContent,
);
} catch (error) {
logger.error("Failed to send payouts disabled email", { error });
@@ -289,16 +290,16 @@ class PaymentEmailService {
const htmlContent = await this.templateManager.renderTemplate(
"disputeAlertToAdmin",
variables
variables,
);
// 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(
adminEmail,
`URGENT: Payment Dispute - Rental #${disputeData.rentalId}`,
htmlContent
htmlContent,
);
} catch (error) {
logger.error("Failed to send dispute alert email", { error });
@@ -326,22 +327,24 @@ class PaymentEmailService {
const variables = {
rentalId: disputeData.rentalId,
amount: disputeData.amount.toFixed(2),
ownerPayoutAmount: parseFloat(disputeData.ownerPayoutAmount || 0).toFixed(2),
ownerPayoutAmount: parseFloat(
disputeData.ownerPayoutAmount || 0,
).toFixed(2),
ownerName: disputeData.ownerName || "Unknown",
ownerEmail: disputeData.ownerEmail || "Unknown",
};
const htmlContent = await this.templateManager.renderTemplate(
"disputeLostAlertToAdmin",
variables
variables,
);
const adminEmail = process.env.ADMIN_EMAIL || process.env.SES_FROM_EMAIL;
const adminEmail = process.env.CUSTOMER_SUPPORT_EMAIL;
return await this.emailClient.sendEmail(
adminEmail,
`ACTION REQUIRED: Dispute Lost - Owner Already Paid - Rental #${disputeData.rentalId}`,
htmlContent
htmlContent,
);
} catch (error) {
logger.error("Failed to send dispute lost alert email", { error });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,246 +1,279 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Personal Information Updated - Village Share</title>
<style>
/* Reset styles */
body, table, td, p, a, li, blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
}
/* Reset styles */
body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
}
/* Base styles */
body {
margin: 0;
padding: 0;
width: 100% !important;
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
/* Base styles */
body {
margin: 0;
padding: 0;
width: 100% !important;
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
/* Container */
/* Container */
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Header */
.header {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #d4edda;
font-size: 14px;
margin-top: 8px;
}
/* Content */
.content {
padding: 40px 30px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Success box */
.success-box {
background-color: #d4edda;
border-left: 4px solid #28a745;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.success-box p {
margin: 0;
color: #155724;
font-size: 14px;
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0;
color: #004085;
font-size: 14px;
}
/* Security box */
.security-box {
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 15px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.security-box p {
margin: 0;
color: #721c24;
font-size: 14px;
}
/* Details table */
.details-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
.details-table td {
padding: 12px;
border-bottom: 1px solid #e9ecef;
}
.details-table td:first-child {
font-weight: 600;
color: #495057;
width: 40%;
}
.details-table td:last-child {
color: #6c757d;
}
/* Footer */
.footer {
background-color: #f8f9fa;
padding: 30px;
text-align: center;
border-top: 1px solid #e9ecef;
}
.footer p {
margin: 0 0 10px 0;
font-size: 14px;
color: #6c757d;
}
.footer a {
color: #667eea;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
/* Responsive */
@media only screen and (max-width: 600px) {
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
margin: 0;
border-radius: 0;
}
/* Header */
.header {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
padding: 40px 30px;
text-align: center;
.header,
.content,
.footer {
padding: 20px;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #d4edda;
font-size: 14px;
margin-top: 8px;
}
/* Content */
.content {
padding: 40px 30px;
font-size: 28px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Success box */
.success-box {
background-color: #d4edda;
border-left: 4px solid #28a745;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.success-box p {
margin: 0;
color: #155724;
font-size: 14px;
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0;
color: #004085;
font-size: 14px;
}
/* Security box */
.security-box {
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 15px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.security-box p {
margin: 0;
color: #721c24;
font-size: 14px;
}
/* Details table */
.details-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
.details-table td {
padding: 12px;
border-bottom: 1px solid #e9ecef;
}
.details-table td:first-child {
font-weight: 600;
color: #495057;
width: 40%;
}
.details-table td:last-child {
color: #6c757d;
}
/* Footer */
.footer {
background-color: #f8f9fa;
padding: 30px;
text-align: center;
border-top: 1px solid #e9ecef;
}
.footer p {
margin: 0 0 10px 0;
font-size: 14px;
color: #6c757d;
}
.footer a {
color: #667eea;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
/* Responsive */
@media only screen and (max-width: 600px) {
.email-container {
margin: 0;
border-radius: 0;
}
.header, .content, .footer {
padding: 20px;
}
.logo {
font-size: 28px;
}
.content h1 {
font-size: 22px;
}
font-size: 22px;
}
}
</style>
</head>
<body>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Personal Information Updated</div>
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Personal Information Updated</div>
</div>
<div class="content">
<p>Hi {{recipientName}},</p>
<h1>Your Personal Information Has Been Updated</h1>
<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>
</div>
<div class="content">
<p>Hi {{recipientName}},</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>
<h1>Your Personal Information Has Been Updated</h1>
<table class="details-table">
<tr>
<td>Date & Time:</td>
<td>{{timestamp}}</td>
</tr>
<tr>
<td>Account Email:</td>
<td>{{email}}</td>
</tr>
</table>
<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>
</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>
<table class="details-table">
<tr>
<td>Date & Time:</td>
<td>{{timestamp}}</td>
</tr>
<tr>
<td>Account Email:</td>
<td>{{email}}</td>
</tr>
</table>
<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>
</div>
<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>
</div>
<p>Thanks for using Village Share!</p>
<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
community-support@village-share.com and consider changing your
password.
</p>
</div>
<div class="footer">
<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>&copy; 2025 Village Share. All rights reserved.</p>
<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>
</div>
<p>Thanks for using Village Share!</p>
</div>
<div class="footer">
<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>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</body>
</html>

View File

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

View File

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

View File

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

View File

@@ -98,7 +98,7 @@ describe("Stripe Routes", () => {
StripeService.getCheckoutSession.mockResolvedValue(mockSession);
const response = await request(app).get(
"/stripe/checkout-session/cs_123456789"
"/stripe/checkout-session/cs_123456789",
);
expect(response.status).toBe(200);
@@ -116,7 +116,7 @@ describe("Stripe Routes", () => {
});
expect(StripeService.getCheckoutSession).toHaveBeenCalledWith(
"cs_123456789"
"cs_123456789",
);
});
@@ -132,7 +132,7 @@ describe("Stripe Routes", () => {
StripeService.getCheckoutSession.mockResolvedValue(mockSession);
const response = await request(app).get(
"/stripe/checkout-session/cs_123456789"
"/stripe/checkout-session/cs_123456789",
);
expect(response.status).toBe(200);
@@ -150,7 +150,7 @@ describe("Stripe Routes", () => {
StripeService.getCheckoutSession.mockRejectedValue(error);
const response = await request(app).get(
"/stripe/checkout-session/invalid_session"
"/stripe/checkout-session/invalid_session",
);
expect(response.status).toBe(500);
@@ -261,7 +261,6 @@ describe("Stripe Routes", () => {
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: "Invalid email address" });
// Note: route uses logger instead of console.error
});
it("should handle database update errors", async () => {
@@ -313,7 +312,7 @@ describe("Stripe Routes", () => {
expect(StripeService.createAccountLink).toHaveBeenCalledWith(
"acct_123456789",
"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.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(
"acct_123456789"
"acct_123456789",
);
});
@@ -516,7 +514,6 @@ describe("Stripe Routes", () => {
expect(response.status).toBe(500);
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.body).toEqual({ error: "Invalid email address" });
// Note: route uses logger.withRequestId().error() instead of console.error
});
it("should handle database update errors", async () => {
@@ -785,7 +781,7 @@ describe("Stripe Routes", () => {
StripeService.getCheckoutSession.mockRejectedValue(error);
const response = await request(app).get(
`/stripe/checkout-session/${longSessionId}`
`/stripe/checkout-session/${longSessionId}`,
);
expect(response.status).toBe(500);

View File

@@ -1,10 +1,10 @@
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const { authenticator } = require('otplib');
const QRCode = require('qrcode');
const crypto = require("crypto");
const bcrypt = require("bcryptjs");
const { authenticator } = require("otplib");
const QRCode = require("qrcode");
// Mock dependencies
jest.mock('otplib', () => ({
jest.mock("otplib", () => ({
authenticator: {
generateSecret: jest.fn(),
keyuri: jest.fn(),
@@ -12,34 +12,34 @@ jest.mock('otplib', () => ({
},
}));
jest.mock('qrcode', () => ({
jest.mock("qrcode", () => ({
toDataURL: jest.fn(),
}));
jest.mock('bcryptjs', () => ({
jest.mock("bcryptjs", () => ({
hash: jest.fn(),
compare: jest.fn(),
}));
jest.mock('../../../utils/logger', () => ({
jest.mock("../../../utils/logger", () => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
}));
const TwoFactorService = require('../../../services/TwoFactorService');
const TwoFactorService = require("../../../services/TwoFactorService");
describe('TwoFactorService', () => {
describe("TwoFactorService", () => {
const originalEnv = process.env;
beforeEach(() => {
jest.clearAllMocks();
process.env = {
...originalEnv,
TOTP_ENCRYPTION_KEY: 'a'.repeat(64), // 64 hex chars = 32 bytes
TOTP_ISSUER: 'TestApp',
TWO_FACTOR_EMAIL_OTP_EXPIRY_MINUTES: '10',
TWO_FACTOR_STEP_UP_VALIDITY_MINUTES: '5',
TOTP_ENCRYPTION_KEY: "a".repeat(64),
TOTP_ISSUER: "TestApp",
TWO_FACTOR_EMAIL_OTP_EXPIRY_MINUTES: "10",
TWO_FACTOR_STEP_UP_VALIDITY_MINUTES: "5",
};
});
@@ -47,91 +47,117 @@ describe('TwoFactorService', () => {
process.env = originalEnv;
});
describe('generateTotpSecret', () => {
it('should generate TOTP secret with QR code', async () => {
authenticator.generateSecret.mockReturnValue('test-secret');
authenticator.keyuri.mockReturnValue('otpauth://totp/VillageShare:test@example.com?secret=test-secret');
QRCode.toDataURL.mockResolvedValue('data:image/png;base64,qrcode');
describe("generateTotpSecret", () => {
it("should generate TOTP secret with QR code", async () => {
authenticator.generateSecret.mockReturnValue("test-secret");
authenticator.keyuri.mockReturnValue(
"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.encryptedSecretIv).toBeDefined();
// 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 () => {
authenticator.generateSecret.mockReturnValue('test-secret');
authenticator.keyuri.mockReturnValue('otpauth://totp/VillageShare:test@example.com');
QRCode.toDataURL.mockResolvedValue('data:image/png;base64,qrcode');
it("should use issuer from environment", async () => {
authenticator.generateSecret.mockReturnValue("test-secret");
authenticator.keyuri.mockReturnValue(
"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(authenticator.keyuri).toHaveBeenCalled();
});
});
describe('verifyTotpCode', () => {
it('should return true for valid code', () => {
describe("verifyTotpCode", () => {
it("should return true for valid code", () => {
authenticator.verify.mockReturnValue(true);
// Use actual encryption
const { encrypted, iv } = TwoFactorService._encryptSecret('test-secret');
const result = TwoFactorService.verifyTotpCode(encrypted, iv, '123456');
const { encrypted, iv } = TwoFactorService._encryptSecret("test-secret");
const result = TwoFactorService.verifyTotpCode(encrypted, iv, "123456");
expect(result).toBe(true);
});
it('should return false for invalid code', () => {
it("should return false for invalid code", () => {
authenticator.verify.mockReturnValue(false);
const { encrypted, iv } = TwoFactorService._encryptSecret('test-secret');
const result = TwoFactorService.verifyTotpCode(encrypted, iv, '654321');
const { encrypted, iv } = TwoFactorService._encryptSecret("test-secret");
const result = TwoFactorService.verifyTotpCode(encrypted, iv, "654321");
expect(result).toBe(false);
});
it('should return false for non-6-digit code', () => {
const result = TwoFactorService.verifyTotpCode('encrypted', 'iv', '12345');
it("should return false for non-6-digit code", () => {
const result = TwoFactorService.verifyTotpCode(
"encrypted",
"iv",
"12345",
);
expect(result).toBe(false);
const result2 = TwoFactorService.verifyTotpCode('encrypted', 'iv', '1234567');
const result2 = TwoFactorService.verifyTotpCode(
"encrypted",
"iv",
"1234567",
);
expect(result2).toBe(false);
const result3 = TwoFactorService.verifyTotpCode('encrypted', 'iv', 'abcdef');
const result3 = TwoFactorService.verifyTotpCode(
"encrypted",
"iv",
"abcdef",
);
expect(result3).toBe(false);
});
it('should return false when decryption fails', () => {
const result = TwoFactorService.verifyTotpCode('invalid-encrypted', 'invalid-iv', '123456');
it("should return false when decryption fails", () => {
const result = TwoFactorService.verifyTotpCode(
"invalid-encrypted",
"invalid-iv",
"123456",
);
expect(result).toBe(false);
});
});
describe('generateEmailOtp', () => {
it('should generate 6-digit code', () => {
describe("generateEmailOtp", () => {
it("should generate 6-digit code", () => {
const result = TwoFactorService.generateEmailOtp();
expect(result.code).toMatch(/^\d{6}$/);
});
it('should return hashed code', () => {
it("should return hashed code", () => {
const result = TwoFactorService.generateEmailOtp();
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 now = new Date();
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 result2 = TwoFactorService.generateEmailOtp();
@@ -140,10 +166,10 @@ describe('TwoFactorService', () => {
});
});
describe('verifyEmailOtp', () => {
it('should return true for valid code', () => {
const code = '123456';
const hashedCode = crypto.createHash('sha256').update(code).digest('hex');
describe("verifyEmailOtp", () => {
it("should return true for valid code", () => {
const code = "123456";
const hashedCode = crypto.createHash("sha256").update(code).digest("hex");
const expiry = new Date(Date.now() + 600000); // 10 minutes from now
const result = TwoFactorService.verifyEmailOtp(code, hashedCode, expiry);
@@ -151,18 +177,25 @@ describe('TwoFactorService', () => {
expect(result).toBe(true);
});
it('should return false for invalid code', () => {
const correctHash = crypto.createHash('sha256').update('123456').digest('hex');
it("should return false for invalid code", () => {
const correctHash = crypto
.createHash("sha256")
.update("123456")
.digest("hex");
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);
});
it('should return false for expired code', () => {
const code = '123456';
const hashedCode = crypto.createHash('sha256').update(code).digest('hex');
it("should return false for expired code", () => {
const code = "123456";
const hashedCode = crypto.createHash("sha256").update(code).digest("hex");
const expiry = new Date(Date.now() - 60000); // 1 minute ago
const result = TwoFactorService.verifyEmailOtp(code, hashedCode, expiry);
@@ -170,18 +203,27 @@ describe('TwoFactorService', () => {
expect(result).toBe(false);
});
it('should return false for non-6-digit code', () => {
const hashedCode = crypto.createHash('sha256').update('123456').digest('hex');
it("should return false for non-6-digit code", () => {
const hashedCode = crypto
.createHash("sha256")
.update("123456")
.digest("hex");
const expiry = new Date(Date.now() + 600000);
expect(TwoFactorService.verifyEmailOtp('12345', hashedCode, expiry)).toBe(false);
expect(TwoFactorService.verifyEmailOtp('1234567', hashedCode, expiry)).toBe(false);
expect(TwoFactorService.verifyEmailOtp('abcdef', hashedCode, expiry)).toBe(false);
expect(TwoFactorService.verifyEmailOtp("12345", 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', () => {
const code = '123456';
const hashedCode = crypto.createHash('sha256').update(code).digest('hex');
it("should return false when no expiry provided", () => {
const code = "123456";
const hashedCode = crypto.createHash("sha256").update(code).digest("hex");
const result = TwoFactorService.verifyEmailOtp(code, hashedCode, null);
@@ -189,9 +231,9 @@ describe('TwoFactorService', () => {
});
});
describe('generateRecoveryCodes', () => {
it('should generate 10 recovery codes', async () => {
bcrypt.hash.mockResolvedValue('hashed-code');
describe("generateRecoveryCodes", () => {
it("should generate 10 recovery codes", async () => {
bcrypt.hash.mockResolvedValue("hashed-code");
const result = await TwoFactorService.generateRecoveryCodes();
@@ -199,31 +241,31 @@ describe('TwoFactorService', () => {
expect(result.hashedCodes).toHaveLength(10);
});
it('should generate codes in XXXX-XXXX format', async () => {
bcrypt.hash.mockResolvedValue('hashed-code');
it("should generate codes in XXXX-XXXX format", async () => {
bcrypt.hash.mockResolvedValue("hashed-code");
const result = await TwoFactorService.generateRecoveryCodes();
result.codes.forEach(code => {
result.codes.forEach((code) => {
expect(code).toMatch(/^[A-Z0-9]{4}-[A-Z0-9]{4}$/);
});
});
it('should exclude confusing characters', async () => {
bcrypt.hash.mockResolvedValue('hashed-code');
it("should exclude confusing characters", async () => {
bcrypt.hash.mockResolvedValue("hashed-code");
const result = await TwoFactorService.generateRecoveryCodes();
const confusingChars = ['0', 'O', '1', 'I', 'L'];
result.codes.forEach(code => {
confusingChars.forEach(char => {
const confusingChars = ["0", "O", "1", "I", "L"];
result.codes.forEach((code) => {
confusingChars.forEach((char) => {
expect(code).not.toContain(char);
});
});
});
it('should hash each code with bcrypt', async () => {
bcrypt.hash.mockResolvedValue('hashed-code');
it("should hash each code with bcrypt", async () => {
bcrypt.hash.mockResolvedValue("hashed-code");
await TwoFactorService.generateRecoveryCodes();
@@ -231,104 +273,114 @@ describe('TwoFactorService', () => {
});
});
describe('verifyRecoveryCode', () => {
it('should return valid for correct code (new format)', async () => {
describe("verifyRecoveryCode", () => {
it("should return valid for correct code (new format)", async () => {
bcrypt.compare.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
const recoveryData = {
version: 1,
codes: [
{ hash: 'hash1', used: false, index: 0 },
{ hash: 'hash2', used: false, index: 1 },
{ hash: "hash1", used: false, index: 0 },
{ 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.index).toBe(1);
});
it('should return invalid for incorrect code', async () => {
it("should return invalid for incorrect code", async () => {
bcrypt.compare.mockResolvedValue(false);
const recoveryData = {
version: 1,
codes: [
{ hash: 'hash1', used: false, index: 0 },
],
codes: [{ 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.index).toBe(-1);
});
it('should skip used codes', async () => {
it("should skip used codes", async () => {
bcrypt.compare.mockResolvedValue(true);
const recoveryData = {
version: 1,
codes: [
{ hash: 'hash1', used: true, index: 0 },
{ hash: 'hash2', used: false, index: 1 },
{ hash: "hash1", used: true, index: 0 },
{ hash: "hash2", used: false, index: 1 },
],
};
await TwoFactorService.verifyRecoveryCode('XXXX-YYYY', recoveryData);
await TwoFactorService.verifyRecoveryCode("XXXX-YYYY", recoveryData);
// Should only check the unused code
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);
const recoveryData = {
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 = {
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);
});
it('should handle legacy array format', async () => {
it("should handle legacy array format", async () => {
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);
});
it('should skip null entries in legacy format', async () => {
it("should skip null entries in legacy format", async () => {
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);
});
});
describe('validateStepUpSession', () => {
it('should return true for valid session', () => {
describe("validateStepUpSession", () => {
it("should return true for valid session", () => {
const user = {
twoFactorVerifiedAt: new Date(Date.now() - 60000), // 1 minute ago
};
@@ -338,7 +390,7 @@ describe('TwoFactorService', () => {
expect(result).toBe(true);
});
it('should return false for expired session', () => {
it("should return false for expired session", () => {
const user = {
twoFactorVerifiedAt: new Date(Date.now() - 600000), // 10 minutes ago
};
@@ -348,7 +400,7 @@ describe('TwoFactorService', () => {
expect(result).toBe(false);
});
it('should return false when no verification timestamp', () => {
it("should return false when no verification timestamp", () => {
const user = {
twoFactorVerifiedAt: null,
};
@@ -358,7 +410,7 @@ describe('TwoFactorService', () => {
expect(result).toBe(false);
});
it('should use custom max age when provided', () => {
it("should use custom max age when provided", () => {
const user = {
twoFactorVerifiedAt: new Date(Date.now() - 1200000), // 20 minutes ago
};
@@ -369,85 +421,88 @@ describe('TwoFactorService', () => {
});
});
describe('getRemainingRecoveryCodesCount', () => {
it('should return count for new format', () => {
describe("getRemainingRecoveryCodesCount", () => {
it("should return count for new format", () => {
const recoveryData = {
version: 1,
codes: [
{ hash: 'hash1', used: false },
{ hash: 'hash2', used: true },
{ hash: 'hash3', used: false },
{ hash: "hash1", used: false },
{ hash: "hash2", used: true },
{ hash: "hash3", used: false },
],
};
const result = TwoFactorService.getRemainingRecoveryCodesCount(recoveryData);
const result =
TwoFactorService.getRemainingRecoveryCodesCount(recoveryData);
expect(result).toBe(2);
});
it('should return count for legacy array format', () => {
const recoveryData = ['hash1', null, 'hash3', 'hash4', null];
it("should return count for legacy array format", () => {
const recoveryData = ["hash1", null, "hash3", "hash4", null];
const result = TwoFactorService.getRemainingRecoveryCodesCount(recoveryData);
const result =
TwoFactorService.getRemainingRecoveryCodesCount(recoveryData);
expect(result).toBe(3);
});
it('should return 0 for null data', () => {
it("should return 0 for null data", () => {
const result = TwoFactorService.getRemainingRecoveryCodesCount(null);
expect(result).toBe(0);
});
it('should return 0 for undefined data', () => {
it("should return 0 for undefined data", () => {
const result = TwoFactorService.getRemainingRecoveryCodesCount(undefined);
expect(result).toBe(0);
});
it('should handle empty array', () => {
it("should handle empty array", () => {
const result = TwoFactorService.getRemainingRecoveryCodesCount([]);
expect(result).toBe(0);
});
it('should handle all used codes', () => {
it("should handle all used codes", () => {
const recoveryData = {
version: 1,
codes: [
{ hash: 'hash1', used: true },
{ hash: 'hash2', used: true },
{ hash: "hash1", used: true },
{ hash: "hash2", used: true },
],
};
const result = TwoFactorService.getRemainingRecoveryCodesCount(recoveryData);
const result =
TwoFactorService.getRemainingRecoveryCodesCount(recoveryData);
expect(result).toBe(0);
});
});
describe('isEmailOtpLocked', () => {
it('should return true when max attempts reached', () => {
describe("isEmailOtpLocked", () => {
it("should return true when max attempts reached", () => {
const result = TwoFactorService.isEmailOtpLocked(3);
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);
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);
expect(result).toBe(false);
});
it('should return false for zero attempts', () => {
it("should return false for zero attempts", () => {
const result = TwoFactorService.isEmailOtpLocked(0);
expect(result).toBe(false);
});
});
describe('_encryptSecret / _decryptSecret', () => {
it('should encrypt and decrypt correctly', () => {
const secret = 'my-test-secret';
describe("_encryptSecret / _decryptSecret", () => {
it("should encrypt and decrypt correctly", () => {
const secret = "my-test-secret";
const { encrypted, iv } = TwoFactorService._encryptSecret(secret);
const decrypted = TwoFactorService._decryptSecret(encrypted, iv);
@@ -455,16 +510,20 @@ describe('TwoFactorService', () => {
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;
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', () => {
process.env.TOTP_ENCRYPTION_KEY = 'short';
it("should throw error when encryption key is wrong length", () => {
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
jest.mock('@aws-sdk/client-ses', () => ({
jest.mock("@aws-sdk/client-ses", () => ({
SESClient: jest.fn().mockImplementation(() => ({
send: jest.fn(),
})),
SendEmailCommand: jest.fn(),
}));
jest.mock('../../../../config/aws', () => ({
getAWSConfig: jest.fn(() => ({ region: 'us-east-1' })),
jest.mock("../../../../config/aws", () => ({
getAWSConfig: jest.fn(() => ({ region: "us-east-1" })),
}));
jest.mock('../../../../services/email/core/emailUtils', () => ({
htmlToPlainText: jest.fn((html) => html.replace(/<[^>]*>/g, '')),
jest.mock("../../../../services/email/core/emailUtils", () => ({
htmlToPlainText: jest.fn((html) => html.replace(/<[^>]*>/g, "")),
}));
// Clear singleton between tests
beforeEach(() => {
jest.clearAllMocks();
// Reset the singleton instance
const EmailClient = require('../../../../services/email/core/EmailClient');
const EmailClient = require("../../../../services/email/core/EmailClient");
EmailClient.instance = null;
});
describe('EmailClient', () => {
const { SESClient, SendEmailCommand } = require('@aws-sdk/client-ses');
const { getAWSConfig } = require('../../../../config/aws');
describe("EmailClient", () => {
const { SESClient, SendEmailCommand } = require("@aws-sdk/client-ses");
const { getAWSConfig } = require("../../../../config/aws");
describe('constructor', () => {
it('should create a new instance', () => {
const EmailClient = require('../../../../services/email/core/EmailClient');
describe("constructor", () => {
it("should create a new instance", () => {
const EmailClient = require("../../../../services/email/core/EmailClient");
EmailClient.instance = null;
const client = new EmailClient();
expect(client).toBeDefined();
@@ -36,8 +36,8 @@ describe('EmailClient', () => {
expect(client.initialized).toBe(false);
});
it('should return existing instance (singleton pattern)', () => {
const EmailClient = require('../../../../services/email/core/EmailClient');
it("should return existing instance (singleton pattern)", () => {
const EmailClient = require("../../../../services/email/core/EmailClient");
EmailClient.instance = null;
const client1 = new EmailClient();
const client2 = new EmailClient();
@@ -45,21 +45,21 @@ describe('EmailClient', () => {
});
});
describe('initialize', () => {
it('should initialize SES client with AWS config', async () => {
const EmailClient = require('../../../../services/email/core/EmailClient');
describe("initialize", () => {
it("should initialize SES client with AWS config", async () => {
const EmailClient = require("../../../../services/email/core/EmailClient");
EmailClient.instance = null;
const client = new EmailClient();
await client.initialize();
expect(getAWSConfig).toHaveBeenCalled();
expect(SESClient).toHaveBeenCalledWith({ region: 'us-east-1' });
expect(SESClient).toHaveBeenCalledWith({ region: "us-east-1" });
expect(client.initialized).toBe(true);
});
it('should not re-initialize if already initialized', async () => {
const EmailClient = require('../../../../services/email/core/EmailClient');
it("should not re-initialize if already initialized", async () => {
const EmailClient = require("../../../../services/email/core/EmailClient");
EmailClient.instance = null;
const client = new EmailClient();
@@ -69,8 +69,8 @@ describe('EmailClient', () => {
expect(SESClient).toHaveBeenCalledTimes(1);
});
it('should wait for existing initialization if in progress', async () => {
const EmailClient = require('../../../../services/email/core/EmailClient');
it("should wait for existing initialization if in progress", async () => {
const EmailClient = require("../../../../services/email/core/EmailClient");
EmailClient.instance = null;
const client = new EmailClient();
@@ -83,28 +83,28 @@ describe('EmailClient', () => {
expect(SESClient).toHaveBeenCalledTimes(1);
});
it('should throw error if AWS config fails', async () => {
it("should throw error if AWS config fails", async () => {
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;
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;
beforeEach(() => {
process.env = {
...originalEnv,
EMAIL_ENABLED: 'true',
SES_FROM_EMAIL: 'noreply@villageshare.app',
SES_FROM_NAME: 'Village Share',
EMAIL_ENABLED: "true",
SES_FROM_EMAIL: "noreply@email.com",
SES_FROM_NAME: "Village Share",
};
});
@@ -112,114 +112,114 @@ describe('EmailClient', () => {
process.env = originalEnv;
});
it('should return early if EMAIL_ENABLED is not true', async () => {
process.env.EMAIL_ENABLED = 'false';
it("should return early if EMAIL_ENABLED is not true", async () => {
process.env.EMAIL_ENABLED = "false";
const EmailClient = require('../../../../services/email/core/EmailClient');
const EmailClient = require("../../../../services/email/core/EmailClient");
EmailClient.instance = null;
const client = new EmailClient();
const result = await client.sendEmail(
'test@example.com',
'Test Subject',
'<p>Hello</p>'
"test@example.com",
"Test Subject",
"<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;
const EmailClient = require('../../../../services/email/core/EmailClient');
const EmailClient = require("../../../../services/email/core/EmailClient");
EmailClient.instance = null;
const client = new EmailClient();
const result = await client.sendEmail(
'test@example.com',
'Test Subject',
'<p>Hello</p>'
"test@example.com",
"Test Subject",
"<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 () => {
const mockSend = jest.fn().mockResolvedValue({ MessageId: 'msg-123' });
it("should send email with correct parameters", async () => {
const mockSend = jest.fn().mockResolvedValue({ MessageId: "msg-123" });
SESClient.mockImplementation(() => ({ send: mockSend }));
const EmailClient = require('../../../../services/email/core/EmailClient');
const EmailClient = require("../../../../services/email/core/EmailClient");
EmailClient.instance = null;
const client = new EmailClient();
const result = await client.sendEmail(
'test@example.com',
'Test Subject',
'<p>Hello World</p>'
"test@example.com",
"Test Subject",
"<p>Hello World</p>",
);
expect(SendEmailCommand).toHaveBeenCalledWith({
Source: 'Village Share <noreply@villageshare.app>',
Source: "Village Share <noreply@email.com>",
Destination: {
ToAddresses: ['test@example.com'],
ToAddresses: ["test@example.com"],
},
Message: {
Subject: {
Data: 'Test Subject',
Charset: 'UTF-8',
Data: "Test Subject",
Charset: "UTF-8",
},
Body: {
Html: {
Data: '<p>Hello World</p>',
Charset: 'UTF-8',
Data: "<p>Hello World</p>",
Charset: "UTF-8",
},
Text: {
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 () => {
const mockSend = jest.fn().mockResolvedValue({ MessageId: 'msg-456' });
it("should send to multiple recipients", async () => {
const mockSend = jest.fn().mockResolvedValue({ MessageId: "msg-456" });
SESClient.mockImplementation(() => ({ send: mockSend }));
const EmailClient = require('../../../../services/email/core/EmailClient');
const EmailClient = require("../../../../services/email/core/EmailClient");
EmailClient.instance = null;
const client = new EmailClient();
await client.sendEmail(
['user1@example.com', 'user2@example.com'],
'Test Subject',
'<p>Hello</p>'
["user1@example.com", "user2@example.com"],
"Test Subject",
"<p>Hello</p>",
);
expect(SendEmailCommand).toHaveBeenCalledWith(
expect.objectContaining({
Destination: {
ToAddresses: ['user1@example.com', 'user2@example.com'],
ToAddresses: ["user1@example.com", "user2@example.com"],
},
})
}),
);
});
it('should use provided text content', async () => {
const mockSend = jest.fn().mockResolvedValue({ MessageId: 'msg-789' });
it("should use provided text content", async () => {
const mockSend = jest.fn().mockResolvedValue({ MessageId: "msg-789" });
SESClient.mockImplementation(() => ({ send: mockSend }));
const EmailClient = require('../../../../services/email/core/EmailClient');
const EmailClient = require("../../../../services/email/core/EmailClient");
EmailClient.instance = null;
const client = new EmailClient();
await client.sendEmail(
'test@example.com',
'Test Subject',
'<p>Hello</p>',
'Custom plain text'
"test@example.com",
"Test Subject",
"<p>Hello</p>",
"Custom plain text",
);
expect(SendEmailCommand).toHaveBeenCalledWith(
@@ -227,68 +227,70 @@ describe('EmailClient', () => {
Message: expect.objectContaining({
Body: expect.objectContaining({
Text: {
Data: 'Custom plain text',
Charset: 'UTF-8',
Data: "Custom plain text",
Charset: "UTF-8",
},
}),
}),
})
}),
);
});
it('should add reply-to address if configured', async () => {
process.env.SES_REPLY_TO_EMAIL = 'support@villageshare.app';
const mockSend = jest.fn().mockResolvedValue({ MessageId: 'msg-000' });
it("should add reply-to address if configured", async () => {
process.env.SES_REPLY_TO_EMAIL = "support@email.com";
const mockSend = jest.fn().mockResolvedValue({ MessageId: "msg-000" });
SESClient.mockImplementation(() => ({ send: mockSend }));
const EmailClient = require('../../../../services/email/core/EmailClient');
const EmailClient = require("../../../../services/email/core/EmailClient");
EmailClient.instance = null;
const client = new EmailClient();
await client.sendEmail(
'test@example.com',
'Test Subject',
'<p>Hello</p>'
"test@example.com",
"Test Subject",
"<p>Hello</p>",
);
expect(SendEmailCommand).toHaveBeenCalledWith(
expect.objectContaining({
ReplyToAddresses: ['support@villageshare.app'],
})
ReplyToAddresses: ["support@email.com"],
}),
);
});
it('should return error if send fails', async () => {
const mockSend = jest.fn().mockRejectedValue(new Error('SES send failed'));
it("should return error if send fails", async () => {
const mockSend = jest
.fn()
.mockRejectedValue(new Error("SES send failed"));
SESClient.mockImplementation(() => ({ send: mockSend }));
const EmailClient = require('../../../../services/email/core/EmailClient');
const EmailClient = require("../../../../services/email/core/EmailClient");
EmailClient.instance = null;
const client = new EmailClient();
const result = await client.sendEmail(
'test@example.com',
'Test Subject',
'<p>Hello</p>'
"test@example.com",
"Test Subject",
"<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 () => {
const mockSend = jest.fn().mockResolvedValue({ MessageId: 'msg-auto' });
it("should auto-initialize if not initialized", async () => {
const mockSend = jest.fn().mockResolvedValue({ MessageId: "msg-auto" });
SESClient.mockImplementation(() => ({ send: mockSend }));
const EmailClient = require('../../../../services/email/core/EmailClient');
const EmailClient = require("../../../../services/email/core/EmailClient");
EmailClient.instance = null;
const client = new EmailClient();
expect(client.initialized).toBe(false);
await client.sendEmail(
'test@example.com',
'Test Subject',
'<p>Hello</p>'
"test@example.com",
"Test Subject",
"<p>Hello</p>",
);
expect(client.initialized).toBe(true);

View File

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

View File

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

View File

@@ -1,27 +1,29 @@
// Mock dependencies
jest.mock('../../../../../services/email/core/EmailClient', () => {
jest.mock("../../../../../services/email/core/EmailClient", () => {
return jest.fn().mockImplementation(() => ({
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(() => ({
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(),
error: 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;
const originalEnv = process.env;
@@ -29,8 +31,8 @@ describe('UserEngagementEmailService', () => {
jest.clearAllMocks();
process.env = {
...originalEnv,
FRONTEND_URL: 'http://localhost:3000',
SUPPORT_EMAIL: 'support@villageshare.com',
FRONTEND_URL: "http://localhost:3000",
CUSTOMER_SUPPORT_EMAIL: "support@email.com",
};
service = new UserEngagementEmailService();
});
@@ -39,8 +41,8 @@ describe('UserEngagementEmailService', () => {
process.env = originalEnv;
});
describe('initialize', () => {
it('should initialize only once', async () => {
describe("initialize", () => {
it("should initialize only once", async () => {
await service.initialize();
await service.initialize();
@@ -49,148 +51,176 @@ describe('UserEngagementEmailService', () => {
});
});
describe('sendFirstListingCelebrationEmail', () => {
const owner = { firstName: 'John', email: 'john@example.com' };
const item = { id: 123, name: 'Power Drill' };
describe("sendFirstListingCelebrationEmail", () => {
const owner = { firstName: "John", email: "john@example.com" };
const item = { id: 123, name: "Power Drill" };
it('should send first listing celebration email with correct variables', async () => {
const result = await service.sendFirstListingCelebrationEmail(owner, item);
it("should send first listing celebration email with correct variables", async () => {
const result = await service.sendFirstListingCelebrationEmail(
owner,
item,
);
expect(result.success).toBe(true);
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
'firstListingCelebrationToOwner',
"firstListingCelebrationToOwner",
expect.objectContaining({
ownerName: 'John',
itemName: 'Power Drill',
ownerName: "John",
itemName: "Power Drill",
itemId: 123,
viewItemUrl: 'http://localhost:3000/items/123',
})
viewItemUrl: "http://localhost:3000/items/123",
}),
);
expect(service.emailClient.sendEmail).toHaveBeenCalledWith(
'john@example.com',
'Congratulations! Your first item is live on Village Share',
expect.any(String)
"john@example.com",
"Congratulations! Your first item is live on Village Share",
expect.any(String),
);
});
it('should use default name when firstName is missing', async () => {
const ownerNoName = { email: 'john@example.com' };
it("should use default name when firstName is missing", async () => {
const ownerNoName = { email: "john@example.com" };
await service.sendFirstListingCelebrationEmail(ownerNoName, item);
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
'firstListingCelebrationToOwner',
expect.objectContaining({ ownerName: 'there' })
"firstListingCelebrationToOwner",
expect.objectContaining({ ownerName: "there" }),
);
});
it('should handle errors gracefully', async () => {
service.templateManager.renderTemplate.mockRejectedValueOnce(new Error('Template error'));
it("should handle errors gracefully", async () => {
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.error).toBe('Template error');
expect(result.error).toBe("Template error");
});
});
describe('sendItemDeletionNotificationToOwner', () => {
const owner = { firstName: 'John', email: 'john@example.com' };
const item = { id: 123, name: 'Power Drill' };
const deletionReason = 'Violated community guidelines';
describe("sendItemDeletionNotificationToOwner", () => {
const owner = { firstName: "John", email: "john@example.com" };
const item = { id: 123, name: "Power Drill" };
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(
owner,
item,
deletionReason
deletionReason,
);
expect(result.success).toBe(true);
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
'itemDeletionToOwner',
"itemDeletionToOwner",
expect.objectContaining({
ownerName: 'John',
itemName: 'Power Drill',
deletionReason: 'Violated community guidelines',
supportEmail: 'support@villageshare.com',
dashboardUrl: 'http://localhost:3000/owning',
})
ownerName: "John",
itemName: "Power Drill",
deletionReason: "Violated community guidelines",
supportEmail: "support@email.com",
dashboardUrl: "http://localhost:3000/owning",
}),
);
expect(service.emailClient.sendEmail).toHaveBeenCalledWith(
'john@example.com',
"john@example.com",
'Important: Your listing "Power Drill" has been removed',
expect.any(String)
expect.any(String),
);
});
it('should use default name when firstName is missing', async () => {
const ownerNoName = { email: 'john@example.com' };
it("should use default name when firstName is missing", async () => {
const ownerNoName = { email: "john@example.com" };
await service.sendItemDeletionNotificationToOwner(ownerNoName, item, deletionReason);
await service.sendItemDeletionNotificationToOwner(
ownerNoName,
item,
deletionReason,
);
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
'itemDeletionToOwner',
expect.objectContaining({ ownerName: 'there' })
"itemDeletionToOwner",
expect.objectContaining({ ownerName: "there" }),
);
});
it('should handle errors gracefully', async () => {
service.emailClient.sendEmail.mockRejectedValueOnce(new Error('Send error'));
it("should handle errors gracefully", async () => {
service.emailClient.sendEmail.mockRejectedValueOnce(
new Error("Send error"),
);
const result = await service.sendItemDeletionNotificationToOwner(
owner,
item,
deletionReason
deletionReason,
);
expect(result.success).toBe(false);
expect(result.error).toBe('Send error');
expect(result.error).toBe("Send error");
});
});
describe('sendUserBannedNotification', () => {
const bannedUser = { firstName: 'John', email: 'john@example.com' };
const admin = { firstName: 'Admin', lastName: 'User' };
const banReason = 'Multiple policy violations';
describe("sendUserBannedNotification", () => {
const bannedUser = { firstName: "John", email: "john@example.com" };
const admin = { firstName: "Admin", lastName: "User" };
const banReason = "Multiple policy violations";
it('should send user banned notification with correct variables', async () => {
const result = await service.sendUserBannedNotification(bannedUser, admin, banReason);
it("should send user banned notification with correct variables", async () => {
const result = await service.sendUserBannedNotification(
bannedUser,
admin,
banReason,
);
expect(result.success).toBe(true);
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
'userBannedNotification',
"userBannedNotification",
expect.objectContaining({
userName: 'John',
banReason: 'Multiple policy violations',
supportEmail: 'support@villageshare.com',
})
userName: "John",
banReason: "Multiple policy violations",
supportEmail: "support@email.com",
}),
);
expect(service.emailClient.sendEmail).toHaveBeenCalledWith(
'john@example.com',
'Important: Your Village Share Account Has Been Suspended',
expect.any(String)
"john@example.com",
"Important: Your Village Share Account Has Been Suspended",
expect.any(String),
);
});
it('should use default name when firstName is missing', async () => {
const bannedUserNoName = { email: 'john@example.com' };
it("should use default name when firstName is missing", async () => {
const bannedUserNoName = { email: "john@example.com" };
await service.sendUserBannedNotification(bannedUserNoName, admin, banReason);
await service.sendUserBannedNotification(
bannedUserNoName,
admin,
banReason,
);
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
'userBannedNotification',
expect.objectContaining({ userName: 'there' })
"userBannedNotification",
expect.objectContaining({ userName: "there" }),
);
});
it('should handle errors gracefully', async () => {
service.templateManager.renderTemplate.mockRejectedValueOnce(new Error('Template error'));
it("should handle errors gracefully", async () => {
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.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.
*/
import React from 'react';
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
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';
import React from "react";
import {
render,
screen,
waitFor,
fireEvent,
act,
} from "@testing-library/react";
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
vi.mock('../../contexts/AuthContext');
vi.mock('../../services/api', () => ({
vi.mock("../../contexts/AuthContext");
vi.mock("../../services/api", () => ({
authAPI: {
verifyEmail: vi.fn(),
resendVerification: vi.fn(),
@@ -25,8 +31,8 @@ vi.mock('../../services/api', () => ({
}));
const mockNavigate = vi.fn();
vi.mock('react-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-router')>();
vi.mock("react-router", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-router")>();
return {
...actual,
useNavigate: () => mockNavigate,
@@ -34,16 +40,23 @@ vi.mock('react-router', async (importOriginal) => {
});
const mockedUseAuth = useAuth as MockedFunction<typeof useAuth>;
const mockedVerifyEmail = authAPI.verifyEmail as MockedFunction<typeof authAPI.verifyEmail>;
const mockedResendVerification = authAPI.resendVerification as MockedFunction<typeof authAPI.resendVerification>;
const mockedVerifyEmail = authAPI.verifyEmail as MockedFunction<
typeof authAPI.verifyEmail
>;
const mockedResendVerification = authAPI.resendVerification as MockedFunction<
typeof authAPI.resendVerification
>;
// 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({
user: mockUnverifiedUser,
loading: false,
showAuthModal: false,
authModalMode: 'login',
authModalMode: "login",
login: vi.fn(),
register: vi.fn(),
googleLogin: vi.fn(),
@@ -60,11 +73,11 @@ const renderVerifyEmail = (searchParams: string = '', authOverrides: Partial<Ret
<Routes>
<Route path="/verify-email" element={<VerifyEmail />} />
</Routes>
</MemoryRouter>
</MemoryRouter>,
);
};
describe('VerifyEmail', () => {
describe("VerifyEmail", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers({ shouldAdvanceTime: true });
@@ -76,77 +89,85 @@ describe('VerifyEmail', () => {
vi.useRealTimers();
});
describe('Initial Loading State', () => {
it('shows loading state while auth is initializing', async () => {
renderVerifyEmail('', { loading: true });
describe("Initial Loading State", () => {
it("shows loading state while auth is initializing", async () => {
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
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 });
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 }
expect.stringContaining("/?login=true&redirect="),
{ replace: true },
);
});
});
it('redirects unauthenticated users with token to login with return URL', async () => {
renderVerifyEmail('?token=test-token-123', { user: null });
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.stringContaining("login=true"),
{ replace: true },
);
expect(mockNavigate).toHaveBeenCalledWith(
expect.stringContaining('verify-email'),
{ replace: true }
expect.stringContaining("verify-email"),
{ replace: true },
);
});
});
});
describe('Auto-Verification with Token', () => {
it('auto-verifies when token present in URL', async () => {
renderVerifyEmail('?token=valid-token-123');
describe("Auto-Verification with Token", () => {
it("auto-verifies when token present in URL", async () => {
renderVerifyEmail("?token=valid-token-123");
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();
renderVerifyEmail('?token=valid-token', { checkAuth: mockCheckAuth });
renderVerifyEmail("?token=valid-token", { checkAuth: mockCheckAuth });
await waitFor(() => {
expect(screen.getByText('Email Verified Successfully!')).toBeInTheDocument();
expect(
screen.getByText("Email Verified Successfully!"),
).toBeInTheDocument();
});
expect(mockCheckAuth).toHaveBeenCalled();
});
it('shows success state immediately for already verified user', async () => {
renderVerifyEmail('', { user: mockUser });
it("shows success state immediately for already verified user", async () => {
renderVerifyEmail("", { user: mockUser });
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 () => {
renderVerifyEmail('?token=valid-token');
it("auto-redirects to home after successful verification", async () => {
renderVerifyEmail("?token=valid-token");
await waitFor(() => {
expect(screen.getByText('Email Verified Successfully!')).toBeInTheDocument();
expect(
screen.getByText("Email Verified Successfully!"),
).toBeInTheDocument();
});
// Advance timers to trigger auto-redirect (3 seconds)
@@ -155,215 +176,228 @@ describe('VerifyEmail', () => {
});
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true });
expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true });
});
});
});
describe('Manual Code Entry', () => {
it('shows manual code entry form when no token in URL', async () => {
describe("Manual Code Entry", () => {
it("shows manual code entry form when no token in URL", async () => {
renderVerifyEmail();
await waitFor(() => {
expect(screen.getByText('Enter Verification Code')).toBeInTheDocument();
expect(screen.getByText('Enter the 6-digit code sent to your email')).toBeInTheDocument();
expect(screen.getByText("Enter Verification Code")).toBeInTheDocument();
expect(
screen.getByText("Enter the 6-digit code sent to your email"),
).toBeInTheDocument();
});
// Check for 6 input fields
const inputs = screen.getAllByRole('textbox');
const inputs = screen.getAllByRole("textbox");
expect(inputs).toHaveLength(6);
});
it('handles 6-digit input with auto-focus', async () => {
it("handles 6-digit input with auto-focus", async () => {
renderVerifyEmail();
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
fireEvent.change(inputs[0], { target: { value: '1' } });
expect(inputs[0]).toHaveValue('1');
fireEvent.change(inputs[0], { target: { value: "1" } });
expect(inputs[0]).toHaveValue("1");
// 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();
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
fireEvent.change(inputs[0], { target: { value: 'a' } });
expect(inputs[0]).toHaveValue('');
fireEvent.change(inputs[0], { target: { value: "a" } });
expect(inputs[0]).toHaveValue("");
// Try typing numbers
fireEvent.change(inputs[0], { target: { value: '5' } });
expect(inputs[0]).toHaveValue('5');
fireEvent.change(inputs[0], { target: { value: "5" } });
expect(inputs[0]).toHaveValue("5");
});
it('handles paste of 6-digit code', async () => {
it("handles paste of 6-digit code", async () => {
renderVerifyEmail();
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
const pasteEvent = new Event('paste', { bubbles: true, cancelable: true }) as any;
const pasteEvent = new Event("paste", {
bubbles: true,
cancelable: true,
}) as any;
pasteEvent.clipboardData = {
getData: () => '123456',
getData: () => "123456",
};
fireEvent(container!, pasteEvent);
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
mockedVerifyEmail.mockImplementation(() => new Promise(() => {}));
renderVerifyEmail();
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)
fireEvent.change(inputs[0], { target: { value: '1' } });
fireEvent.change(inputs[1], { target: { value: '2' } });
fireEvent.change(inputs[2], { target: { value: '3' } });
fireEvent.change(inputs[3], { target: { value: '4' } });
fireEvent.change(inputs[4], { target: { value: '5' } });
fireEvent.change(inputs[0], { target: { value: "1" } });
fireEvent.change(inputs[1], { target: { value: "2" } });
fireEvent.change(inputs[2], { target: { value: "3" } });
fireEvent.change(inputs[3], { target: { value: "4" } });
fireEvent.change(inputs[4], { target: { value: "5" } });
// 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();
// 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
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();
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();
});
it('backspace moves focus to previous input', async () => {
it("backspace moves focus to previous input", async () => {
renderVerifyEmail();
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
fireEvent.change(inputs[0], { target: { value: '1' } });
fireEvent.change(inputs[1], { target: { value: '2' } });
fireEvent.change(inputs[0], { target: { value: "1" } });
fireEvent.change(inputs[1], { target: { value: "2" } });
// Clear second input and press backspace
fireEvent.change(inputs[1], { target: { value: '' } });
fireEvent.keyDown(inputs[1], { key: 'Backspace' });
fireEvent.change(inputs[1], { target: { value: "" } });
fireEvent.keyDown(inputs[1], { key: "Backspace" });
// The component handles this by focusing previous input
});
});
describe('Error Handling', () => {
it('displays EXPIRED error message', async () => {
describe("Error Handling", () => {
it("displays EXPIRED error message", async () => {
mockedVerifyEmail.mockRejectedValue({
response: { data: { code: 'VERIFICATION_EXPIRED' } },
response: { data: { code: "VERIFICATION_EXPIRED" } },
});
renderVerifyEmail('?token=expired-token');
renderVerifyEmail("?token=expired-token");
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({
response: { data: { code: 'VERIFICATION_INVALID' } },
response: { data: { code: "VERIFICATION_INVALID" } },
});
renderVerifyEmail('?token=invalid-token');
renderVerifyEmail("?token=invalid-token");
await waitFor(() => {
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({
response: { data: { code: 'TOO_MANY_ATTEMPTS' } },
response: { data: { code: "TOO_MANY_ATTEMPTS" } },
});
renderVerifyEmail('?token=blocked-token');
renderVerifyEmail("?token=blocked-token");
await waitFor(() => {
expect(screen.getByText(/too many attempts/i)).toBeInTheDocument();
});
});
it('displays ALREADY_VERIFIED error message', async () => {
it("displays ALREADY_VERIFIED error message", async () => {
mockedVerifyEmail.mockRejectedValue({
response: { data: { code: 'ALREADY_VERIFIED' } },
response: { data: { code: "ALREADY_VERIFIED" } },
});
renderVerifyEmail('?token=already-verified-token');
renderVerifyEmail("?token=already-verified-token");
await waitFor(() => {
expect(screen.getByText(/already verified/i)).toBeInTheDocument();
});
});
it('clears input on error', async () => {
it("clears input on error", async () => {
mockedVerifyEmail.mockRejectedValue({
response: { data: { code: 'VERIFICATION_INVALID' } },
response: { data: { code: "VERIFICATION_INVALID" } },
});
renderVerifyEmail();
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
fireEvent.change(inputs[0], { target: { value: '1' } });
fireEvent.change(inputs[1], { target: { value: '2' } });
fireEvent.change(inputs[2], { target: { value: '3' } });
fireEvent.change(inputs[3], { target: { value: '4' } });
fireEvent.change(inputs[4], { target: { value: '5' } });
fireEvent.change(inputs[5], { target: { value: '6' } });
fireEvent.change(inputs[0], { target: { value: "1" } });
fireEvent.change(inputs[1], { target: { value: "2" } });
fireEvent.change(inputs[2], { target: { value: "3" } });
fireEvent.change(inputs[3], { target: { value: "4" } });
fireEvent.change(inputs[4], { target: { value: "5" } });
fireEvent.change(inputs[5], { target: { value: "6" } });
// Wait for error message to appear
await waitFor(() => {
@@ -372,33 +406,35 @@ describe('VerifyEmail', () => {
// Inputs should be cleared after error
await waitFor(() => {
const updatedInputs = screen.getAllByRole('textbox');
const updatedInputs = screen.getAllByRole("textbox");
updatedInputs.forEach((input) => {
expect(input).toHaveValue('');
expect(input).toHaveValue("");
});
});
});
});
describe('Resend Verification', () => {
it('shows resend button', async () => {
describe("Resend Verification", () => {
it("shows resend button", async () => {
renderVerifyEmail();
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();
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);
await waitFor(() => {
@@ -408,32 +444,32 @@ describe('VerifyEmail', () => {
expect(mockedResendVerification).toHaveBeenCalled();
});
it('disables resend during cooldown', async () => {
it("disables resend during cooldown", async () => {
renderVerifyEmail();
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);
await waitFor(() => {
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();
});
it('shows success message after resend', async () => {
it("shows success message after resend", async () => {
renderVerifyEmail();
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);
await waitFor(() => {
@@ -441,14 +477,14 @@ describe('VerifyEmail', () => {
});
});
it('counts down timer', async () => {
it("counts down timer", async () => {
renderVerifyEmail();
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);
await waitFor(() => {
@@ -457,25 +493,30 @@ describe('VerifyEmail', () => {
// With shouldAdvanceTime: true, the timer will automatically count down
// Wait for the countdown to show a lower value
await waitFor(() => {
// Timer should have counted down from 60s to something less
const resendText = screen.getByRole('button', { name: /resend in \d+s/i }).textContent;
expect(resendText).toMatch(/Resend in [0-5][0-9]s/);
}, { timeout: 3000 });
await waitFor(
() => {
// Timer should have counted down from 60s to something less
const resendText = screen.getByRole("button", {
name: /resend in \d+s/i,
}).textContent;
expect(resendText).toMatch(/Resend in [0-5][0-9]s/);
},
{ timeout: 3000 },
);
});
it('handles resend error for already verified', async () => {
it("handles resend error for already verified", async () => {
mockedResendVerification.mockRejectedValue({
response: { data: { code: 'ALREADY_VERIFIED' } },
response: { data: { code: "ALREADY_VERIFIED" } },
});
renderVerifyEmail();
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);
await waitFor(() => {
@@ -483,7 +524,7 @@ describe('VerifyEmail', () => {
});
});
it('handles rate limit error (429)', async () => {
it("handles rate limit error (429)", async () => {
mockedResendVerification.mockRejectedValue({
response: { status: 429 },
});
@@ -491,39 +532,43 @@ describe('VerifyEmail', () => {
renderVerifyEmail();
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);
await waitFor(() => {
expect(screen.getByText(/please wait before requesting/i)).toBeInTheDocument();
expect(
screen.getByText(/please wait before requesting/i),
).toBeInTheDocument();
});
});
});
describe('Navigation', () => {
it('has return to home link', async () => {
describe("Navigation", () => {
it("has return to home link", async () => {
renderVerifyEmail();
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 });
expect(homeLink).toHaveAttribute('href', '/');
const homeLink = screen.getByRole("link", { name: /return to home/i });
expect(homeLink).toHaveAttribute("href", "/");
});
it('has go to home link on success', async () => {
renderVerifyEmail('?token=valid-token');
it("has go to home link on success", async () => {
renderVerifyEmail("?token=valid-token");
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 });
expect(homeLink).toHaveAttribute('href', '/');
const homeLink = screen.getByRole("link", { name: /go to home page/i });
expect(homeLink).toHaveAttribute("href", "/");
});
});
});

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ let failedQueue: Array<{
const processQueue = (
error: AxiosError | null,
token: string | null = null
token: string | null = null,
) => {
failedQueue.forEach((prom) => {
if (error) {
@@ -95,7 +95,7 @@ api.interceptors.response.use(
methods: errorData.methods,
originalRequest,
},
})
}),
);
return Promise.reject(error);
}
@@ -128,7 +128,6 @@ api.interceptors.response.use(
const errorData = error.response?.data as any;
// 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
if (
(errorData?.code === "TOKEN_EXPIRED" ||
@@ -167,7 +166,7 @@ api.interceptors.response.use(
}
return Promise.reject(error);
}
},
);
export const authAPI = {
@@ -279,7 +278,7 @@ export const rentalAPI = {
// Return status marking
markReturn: (
id: string,
data: { status: string; actualReturnDateTime?: string }
data: { status: string; actualReturnDateTime?: string },
) => api.post(`/rentals/${id}/mark-return`, data),
reportDamage: (id: string, data: any) =>
api.post(`/rentals/${id}/report-damage`, data),
@@ -338,7 +337,7 @@ export const forumAPI = {
content: string;
parentId?: string;
imageFilenames?: string[];
}
},
) => api.post(`/forum/posts/${postId}/comments`, data),
updateComment: (commentId: string, data: any) =>
api.put(`/forum/comments/${commentId}`, data),
@@ -388,7 +387,7 @@ export const mapsAPI = {
export const conditionCheckAPI = {
submitConditionCheck: (
rentalId: string,
data: { checkType: string; imageFilenames: string[]; notes?: string }
data: { checkType: string; imageFilenames: string[]; notes?: string },
) => api.post(`/condition-checks/${rentalId}`, data),
getBatchConditionChecks: (rentalIds: string[]) =>
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

@@ -4,12 +4,12 @@ Sends email reminders to owners and renters to complete condition checks at key
## Check Types
| Check Type | Recipient | Timing |
|------------|-----------|--------|
| `pre_rental_owner` | Owner | 24 hours before rental start |
| `rental_start_renter` | Renter | At rental start |
| `rental_end_renter` | Renter | At rental end |
| `post_rental_owner` | Owner | 24 hours after rental end |
| Check Type | Recipient | Timing |
| --------------------- | --------- | ---------------------------- |
| `pre_rental_owner` | Owner | 24 hours before rental start |
| `rental_start_renter` | Renter | At rental start |
| `rental_end_renter` | Renter | At rental end |
| `post_rental_owner` | Owner | 24 hours after rental end |
## Local Development
@@ -22,11 +22,6 @@ cd ../conditionCheckReminder && npm install
### Set Up Environment
```bash
cp .env.example .env.dev
# Edit .env.dev with your DATABASE_URL
```
### Run Locally
```bash
@@ -36,18 +31,3 @@ npm run local
# Specify rental ID and check type
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() {
if (!schedulerClient) {
schedulerClient = new SchedulerClient({
region: process.env.AWS_REGION || "us-east-1",
region: process.env.AWS_REGION,
});
}
return schedulerClient;
@@ -34,7 +34,7 @@ async function deleteSchedule(scheduleName) {
new DeleteScheduleCommand({
Name: scheduleName,
GroupName: groupName,
})
}),
);
logger.info("Deleted schedule after execution", {
@@ -74,7 +74,9 @@ function getEmailContent(checkType, rental) {
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.`,
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: {
@@ -90,7 +92,7 @@ function getEmailContent(checkType, rental) {
title: "Post-Rental Condition Check",
message: `Please take photos and mark the return status for "${itemName}". This completes the rental process.`,
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(
__dirname,
"templates",
"conditionCheckReminderToUser.html"
"conditionCheckReminderToUser.html",
);
const template = await email.loadTemplate(templatePath);
@@ -178,7 +180,7 @@ async function processReminder(rentalId, checkType, scheduleName) {
const result = await email.sendEmail(
emailContent.recipient.email,
emailContent.subject,
htmlBody
htmlBody,
);
if (!result.success) {

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,41 +1,29 @@
/**
* 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
const testEvent = {
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
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
};
console.log('Running condition check reminder lambda locally...');
console.log('Event:', JSON.stringify(testEvent, null, 2));
console.log('---');
console.log("Running condition check reminder lambda locally...");
console.log("Event:", JSON.stringify(testEvent, null, 2));
console.log("---");
handler(testEvent)
.then(result => {
console.log('---');
console.log('Success!');
console.log('Result:', JSON.stringify(result, null, 2));
.then((result) => {
console.log("---");
console.log("Success!");
console.log("Result:", JSON.stringify(result, null, 2));
process.exit(0);
})
.catch(err => {
console.error('---');
console.error('Error:', err.message);
.catch((err) => {
console.error("---");
console.error("Error:", err.message);
console.error(err.stack);
process.exit(1);
});

View File

@@ -20,17 +20,6 @@ cd lambdas/shared && 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
```bash

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -7,14 +7,15 @@
* Example:
* 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");
async function main() {
// 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
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.
## Prerequisites
- Node.js 20.x
- PostgreSQL database with rentals data
- Stripe account with test API key (`sk_test_...`)
## Setup
1. Install shared dependencies:
@@ -23,17 +17,6 @@ Retries failed Stripe payouts daily. Triggered by EventBridge Scheduler at 7 AM
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
Run the Lambda locally using your dev environment:
@@ -94,7 +77,7 @@ EventBridge Scheduler (7 AM EST daily)
v
Lambda Function
|
+-- Query failed payouts from PostgreSQL
+-- Query failed payouts from database
|
+-- For each failed payout:
| +-- 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",
"main": "index.js",
"dependencies": {
"@rentall/lambda-shared": "file:../shared"
"@village-share/lambda-shared": "file:../shared"
},
"devDependencies": {
"dotenv": "^16.4.5"

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
{
"name": "@rentall/lambda-shared",
"name": "@village-share/lambda-shared",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@rentall/lambda-shared",
"name": "@village-share/lambda-shared",
"version": "1.0.0",
"dependencies": {
"@aws-sdk/client-scheduler": "^3.896.0",
@@ -1216,40 +1216,6 @@
"dev": true,
"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": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -1687,19 +1653,6 @@
"@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": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -2345,17 +2298,6 @@
"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": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2468,48 +2410,6 @@
"dev": true,
"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": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz",
@@ -2524,219 +2424,6 @@
"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": {
"version": "4.3.2",
"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",
"description": "Shared utilities for Rentall Lambda functions",
"description": "Shared utilities for Village Share Lambda functions",
"main": "index.js",
"dependencies": {
"@aws-sdk/client-ses": "^3.896.0",