Compare commits
5 Commits
cae9e7e473
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d3c124d3e | ||
|
|
420e0efeb4 | ||
|
|
23ca97cea9 | ||
|
|
b5755109a7 | ||
|
|
0136b74ee0 |
113
README.md
113
README.md
@@ -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
|
||||
|
||||
127
backend/S3.md
127
backend/S3.md
@@ -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
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>© 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>© 2025 Village Share. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>© 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>© 2025 Village Share. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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,18 +524,18 @@ 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 can’t go back!**
|
||||
|
||||
If you aren’t 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 you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t 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/).
|
||||
@@ -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", "/");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" }}
|
||||
>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
487
infrastructure/cdk/package-lock.json
generated
487
infrastructure/cdk/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
6088
lambdas/conditionCheckReminder/package-lock.json
generated
6088
lambdas/conditionCheckReminder/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
6494
lambdas/imageProcessor/package-lock.json
generated
6494
lambdas/imageProcessor/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
|
||||
5009
lambdas/payoutRetryProcessor/package-lock.json
generated
5009
lambdas/payoutRetryProcessor/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Shared utilities for Rentall Lambda functions.
|
||||
* Shared utilities for Village Share Lambda functions.
|
||||
*/
|
||||
|
||||
const db = require("./db/connection");
|
||||
|
||||
317
lambdas/shared/package-lock.json
generated
317
lambdas/shared/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user