diff --git a/README.md b/README.md index 6938c2c..7270b7a 100644 --- a/README.md +++ b/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. \ No newline at end of file +# Village Share diff --git a/backend/S3.md b/backend/S3.md deleted file mode 100644 index ffbd70e..0000000 --- a/backend/S3.md +++ /dev/null @@ -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 diff --git a/backend/config/aws.js b/backend/config/aws.js index 5570521..4f79626 100644 --- a/backend/config/aws.js +++ b/backend/config/aws.js @@ -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(); diff --git a/backend/config/database.js b/backend/config/database.js index ece1e33..28932fa 100644 --- a/backend/config/database.js +++ b/backend/config/database.js @@ -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) diff --git a/backend/routes/auth.js b/backend/routes/auth.js index af90434..b105587 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -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; diff --git a/backend/scripts/manageAlphaInvitations.js b/backend/scripts/manageAlphaInvitations.js index 0b877a3..a9d8219 100644 --- a/backend/scripts/manageAlphaInvitations.js +++ b/backend/scripts/manageAlphaInvitations.js @@ -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 [notes]\n" + "Usage: node scripts/manageAlphaInvitations.js add [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 \n" + "Usage: node scripts/manageAlphaInvitations.js revoke \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 \n" + "Usage: node scripts/manageAlphaInvitations.js resend \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 \n" + "Usage: node scripts/manageAlphaInvitations.js restore \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 \n" + "Usage: node scripts/manageAlphaInvitations.js bulk \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); } diff --git a/backend/server.js b/backend/server.js index 59c4233..e05b23c 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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", ); } } diff --git a/backend/services/TwoFactorService.js b/backend/services/TwoFactorService.js index 83c93b4..c19611a 100644 --- a/backend/services/TwoFactorService.js +++ b/backend/services/TwoFactorService.js @@ -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")); diff --git a/backend/services/email/domain/AlphaInvitationEmailService.js b/backend/services/email/domain/AlphaInvitationEmailService.js index ed18b62..816d36e 100644 --- a/backend/services/email/domain/AlphaInvitationEmailService.js +++ b/backend/services/email/domain/AlphaInvitationEmailService.js @@ -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 }); diff --git a/backend/services/email/domain/AuthEmailService.js b/backend/services/email/domain/AuthEmailService.js index 79635db..675152c 100644 --- a/backend/services/email/domain/AuthEmailService.js +++ b/backend/services/email/domain/AuthEmailService.js @@ -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, ); } } diff --git a/backend/services/email/domain/FeedbackEmailService.js b/backend/services/email/domain/FeedbackEmailService.js index 27ac17f..e0375e2 100644 --- a/backend/services/email/domain/FeedbackEmailService.js +++ b/backend/services/email/domain/FeedbackEmailService.js @@ -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, ); } } diff --git a/backend/services/email/domain/ForumEmailService.js b/backend/services/email/domain/ForumEmailService.js index cce1f8e..f7fd4de 100644 --- a/backend/services/email/domain/ForumEmailService.js +++ b/backend/services/email/domain/ForumEmailService.js @@ -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}`, ); } diff --git a/backend/services/email/domain/MessagingEmailService.js b/backend/services/email/domain/MessagingEmailService.js index b259719..39be93b 100644 --- a/backend/services/email/domain/MessagingEmailService.js +++ b/backend/services/email/domain/MessagingEmailService.js @@ -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}`, ); } diff --git a/backend/services/email/domain/PaymentEmailService.js b/backend/services/email/domain/PaymentEmailService.js index 4e5e474..960437c 100644 --- a/backend/services/email/domain/PaymentEmailService.js +++ b/backend/services/email/domain/PaymentEmailService.js @@ -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 }); diff --git a/backend/services/email/domain/RentalFlowEmailService.js b/backend/services/email/domain/RentalFlowEmailService.js index 8eb2735..cc14724 100644 --- a/backend/services/email/domain/RentalFlowEmailService.js +++ b/backend/services/email/domain/RentalFlowEmailService.js @@ -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 {

⚠️ Action Required: Set Up Your Earnings Account

To receive your payout of $${payoutAmount.toFixed( - 2 + 2, )} when this rental completes, you need to set up your earnings account.

Set Up Earnings to Get Paid

@@ -276,7 +278,7 @@ class RentalFlowEmailService {

✓ Earnings Account Active

Your earnings account is set up. You'll automatically receive $${payoutAmount.toFixed( - 2 + 2, )} when this rental completes.

View your earnings dashboard →

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

Full Refund Processed

You will receive a full refund of $${refundInfo.amount.toFixed( - 2 + 2, )}. The refund will appear in your account within 5-10 business days.

@@ -774,7 +784,7 @@ class RentalFlowEmailService {
$${refundInfo.amount.toFixed(2)}

Refund Amount: $${refundInfo.amount.toFixed( - 2 + 2, )} (${refundPercentage}% of total)

Reason: ${refundInfo.reason}

Processing Time: Refunds typically appear within 5-10 business days.

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

⚠️ Action Required: Set Up Your Earnings Account

To receive your payout of $${payoutAmount.toFixed( - 2 + 2, )}, you need to set up your earnings account.

Set Up Earnings to Get Paid

@@ -1061,7 +1075,7 @@ class RentalFlowEmailService {

✓ Payout Initiated

Your earnings of $${payoutAmount.toFixed( - 2 + 2, )} have been transferred to your Stripe account.

Funds typically reach your bank within 2-7 business days.

View your earnings dashboard →

@@ -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 }); diff --git a/backend/services/email/domain/UserEngagementEmailService.js b/backend/services/email/domain/UserEngagementEmailService.js index 1ce30f9..0e48a96 100644 --- a/backend/services/email/domain/UserEngagementEmailService.js +++ b/backend/services/email/domain/UserEngagementEmailService.js @@ -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) { diff --git a/backend/services/stripeService.js b/backend/services/stripeService.js index 182211f..4272fdd 100644 --- a/backend/services/stripeService.js +++ b/backend/services/stripeService.js @@ -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 diff --git a/backend/tests/unit/middleware/csrf.test.js b/backend/tests/unit/middleware/csrf.test.js index fed8985..f3ab4c9 100644 --- a/backend/tests/unit/middleware/csrf.test.js +++ b/backend/tests/unit/middleware/csrf.test.js @@ -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(); }); }); -}); \ No newline at end of file +}); diff --git a/backend/tests/unit/services/TwoFactorService.test.js b/backend/tests/unit/services/TwoFactorService.test.js index ccf5690..16b27a1 100644 --- a/backend/tests/unit/services/TwoFactorService.test.js +++ b/backend/tests/unit/services/TwoFactorService.test.js @@ -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", + ); }); }); }); diff --git a/backend/tests/unit/services/email/EmailClient.test.js b/backend/tests/unit/services/email/EmailClient.test.js index a78aa37..2550b1b 100644 --- a/backend/tests/unit/services/email/EmailClient.test.js +++ b/backend/tests/unit/services/email/EmailClient.test.js @@ -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', - '

Hello

' + "test@example.com", + "Test Subject", + "

Hello

", ); - 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', - '

Hello

' + "test@example.com", + "Test Subject", + "

Hello

", ); - 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', - '

Hello World

' + "test@example.com", + "Test Subject", + "

Hello World

", ); expect(SendEmailCommand).toHaveBeenCalledWith({ - Source: 'Village Share ', + Source: "Village Share ", 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: '

Hello World

', - Charset: 'UTF-8', + Data: "

Hello World

", + 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', - '

Hello

' + ["user1@example.com", "user2@example.com"], + "Test Subject", + "

Hello

", ); 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', - '

Hello

', - 'Custom plain text' + "test@example.com", + "Test Subject", + "

Hello

", + "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', - '

Hello

' + "test@example.com", + "Test Subject", + "

Hello

", ); expect(SendEmailCommand).toHaveBeenCalledWith( expect.objectContaining({ - ReplyToAddresses: ['support@villageshare.app'], - }) + ReplyToAddresses: ["support@villageshare.app"], + }), ); }); - 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', - '

Hello

' + "test@example.com", + "Test Subject", + "

Hello

", ); - 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', - '

Hello

' + "test@example.com", + "Test Subject", + "

Hello

", ); expect(client.initialized).toBe(true); diff --git a/backend/tests/unit/services/email/domain/FeedbackEmailService.test.js b/backend/tests/unit/services/email/domain/FeedbackEmailService.test.js index 6629674..08240e4 100644 --- a/backend/tests/unit/services/email/domain/FeedbackEmailService.test.js +++ b/backend/tests/unit/services/email/domain/FeedbackEmailService.test.js @@ -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('Test'), + renderTemplate: jest.fn().mockResolvedValue("Test"), })); }); -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", + }), ); }); }); diff --git a/backend/tests/unit/services/email/domain/PaymentEmailService.test.js b/backend/tests/unit/services/email/domain/PaymentEmailService.test.js index 323e789..19f4671 100644 --- a/backend/tests/unit/services/email/domain/PaymentEmailService.test.js +++ b/backend/tests/unit/services/email/domain/PaymentEmailService.test.js @@ -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('Test'), + renderTemplate: jest.fn().mockResolvedValue("Test"), })); }); -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"); }); }); }); diff --git a/backend/tests/unit/services/email/domain/UserEngagementEmailService.test.js b/backend/tests/unit/services/email/domain/UserEngagementEmailService.test.js index 4077258..d5dd5b7 100644 --- a/backend/tests/unit/services/email/domain/UserEngagementEmailService.test.js +++ b/backend/tests/unit/services/email/domain/UserEngagementEmailService.test.js @@ -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('Test'), + renderTemplate: jest.fn().mockResolvedValue("Test"), })); }); -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@villageshare.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@villageshare.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"); }); }); }); diff --git a/infrastructure/README.md b/infrastructure/README.md deleted file mode 100644 index da5b4b7..0000000 --- a/infrastructure/README.md +++ /dev/null @@ -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 diff --git a/infrastructure/cdk/bin/app.ts b/infrastructure/cdk/bin/app.ts deleted file mode 100644 index 41fb73a..0000000 --- a/infrastructure/cdk/bin/app.ts +++ /dev/null @@ -1,254 +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"; -import { CertificateStack } from "../lib/certificate-stack"; -import { SecretsStack } from "../lib/secrets-stack"; -import { EcrStack } from "../lib/ecr-stack"; -import { RdsStack } from "../lib/rds-stack"; -import { EcsServiceStack } from "../lib/ecs-service-stack"; - -const app = new cdk.App(); - -// Get environment from context or default to dev -const environment = app.node.tryGetContext("env") || "dev"; - -// Get context variables for deployment configuration -const allowedIp = app.node.tryGetContext("allowedIp"); // e.g., "1.2.3.4/32" -const domainName = app.node.tryGetContext("domainName") || "village-share.com"; - -// Environment-specific configurations -const envConfig: Record< - string, - { - databaseUrl: string; - frontendUrl: string; - sesFromEmail: string; - emailEnabled: boolean; - natGateways: number; - subdomain: string; - dbInstanceType: "micro" | "small" | "medium"; - multiAz: boolean; - } -> = { - dev: { - databaseUrl: - process.env.DATABASE_URL || - "postgresql://user:password@localhost:5432/rentall_dev", - frontendUrl: `https://dev.${domainName}`, - sesFromEmail: `noreply@${domainName}`, - emailEnabled: false, // Disable emails in dev - natGateways: 1, - subdomain: "dev", - dbInstanceType: "micro", - multiAz: false, - }, - staging: { - databaseUrl: - process.env.DATABASE_URL || - "postgresql://user:password@localhost:5432/rentall_staging", - frontendUrl: `https://staging.${domainName}`, - sesFromEmail: `noreply@${domainName}`, - emailEnabled: true, - natGateways: 1, - subdomain: "staging", - dbInstanceType: "micro", - multiAz: false, - }, - prod: { - databaseUrl: - process.env.DATABASE_URL || - "postgresql://user:password@localhost:5432/rentall_prod", - frontendUrl: `https://${domainName}`, - sesFromEmail: `noreply@${domainName}`, - emailEnabled: true, - natGateways: 2, // Multi-AZ NAT gateways for high availability - subdomain: "", // No subdomain for prod - dbInstanceType: "small", - multiAz: true, - }, -}; - -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", - }, -}; - -// Common tags for all stacks -const commonTags = { - Environment: environment, - Project: "village-share", - ManagedBy: "cdk", -}; - -// ============================================================================ -// Certificate Stack (Shared across environments) -// Deploy this once and validate DNS before deploying other stacks -// ============================================================================ -const certificateStack = new CertificateStack(app, "CertificateStack", { - domainName, - ...envProps, - description: `ACM wildcard certificate for ${domainName}`, - tags: { - ...commonTags, - Service: "certificate", - }, -}); - -// ============================================================================ -// VPC Stack -// ============================================================================ -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: { - ...commonTags, - Service: "networking", - }, -}); - -// ============================================================================ -// Secrets Stack -// ============================================================================ -const secretsStack = new SecretsStack(app, `SecretsStack-${environment}`, { - environment, - ...envProps, - description: `Secrets Manager secrets for database and application (${environment})`, - tags: { - ...commonTags, - Service: "secrets", - }, -}); - -// ============================================================================ -// ECR Stack -// ============================================================================ -const ecrStack = new EcrStack(app, `EcrStack-${environment}`, { - environment, - ...envProps, - description: `ECR repositories for Docker images (${environment})`, - tags: { - ...commonTags, - Service: "ecr", - }, -}); - -// ============================================================================ -// RDS Stack -// ============================================================================ -const rdsStack = new RdsStack(app, `RdsStack-${environment}`, { - environment, - vpc: vpcStack.vpc, - databaseSecret: secretsStack.databaseSecret, - databaseName: "rentall", - multiAz: config.multiAz, - ...envProps, - description: `RDS PostgreSQL database (${environment})`, - tags: { - ...commonTags, - Service: "database", - }, -}); - -// RDS depends on VPC and Secrets -rdsStack.addDependency(vpcStack); -rdsStack.addDependency(secretsStack); - -// ============================================================================ -// ECS Service Stack -// ============================================================================ -const fullDomainName = config.subdomain - ? `${config.subdomain}.${domainName}` - : domainName; - -const ecsServiceStack = new EcsServiceStack( - app, - `EcsServiceStack-${environment}`, - { - environment, - vpc: vpcStack.vpc, - certificate: certificateStack.certificate, - backendRepository: ecrStack.backendRepository, - frontendRepository: ecrStack.frontendRepository, - databaseSecret: secretsStack.databaseSecret, - appSecret: secretsStack.appSecret, - databaseSecurityGroup: rdsStack.databaseSecurityGroup, - dbEndpoint: rdsStack.dbEndpoint, - dbPort: rdsStack.dbPort, - dbName: "rentall", - domainName: fullDomainName, - allowedIp: environment === "dev" ? allowedIp : undefined, // Only restrict in dev - frontendUrl: config.frontendUrl, - ...envProps, - description: `ECS Fargate services with ALB (${environment})`, - tags: { - ...commonTags, - Service: "ecs", - }, - } -); - -// ECS depends on VPC, Certificate, ECR, Secrets, and RDS -ecsServiceStack.addDependency(vpcStack); -ecsServiceStack.addDependency(certificateStack); -ecsServiceStack.addDependency(ecrStack); -ecsServiceStack.addDependency(secretsStack); -ecsServiceStack.addDependency(rdsStack); - -// ============================================================================ -// Lambda Stacks (existing) -// ============================================================================ -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: { - ...commonTags, - Service: "condition-check-reminder", - }, - } -); - -conditionCheckStack.addDependency(vpcStack); - -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: { - ...commonTags, - Service: "image-processor", - }, - } -); - -imageProcessorStack.addDependency(vpcStack); diff --git a/infrastructure/cdk/cdk.json b/infrastructure/cdk/cdk.json deleted file mode 100644 index 80ebc18..0000000 --- a/infrastructure/cdk/cdk.json +++ /dev/null @@ -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"] - } -} diff --git a/infrastructure/cdk/lib/certificate-stack.ts b/infrastructure/cdk/lib/certificate-stack.ts deleted file mode 100644 index d9853e5..0000000 --- a/infrastructure/cdk/lib/certificate-stack.ts +++ /dev/null @@ -1,59 +0,0 @@ -import * as cdk from "aws-cdk-lib"; -import * as acm from "aws-cdk-lib/aws-certificatemanager"; -import { Construct } from "constructs"; - -interface CertificateStackProps extends cdk.StackProps { - /** - * The domain name for the certificate (e.g., village-share.com) - */ - domainName: string; -} - -export class CertificateStack extends cdk.Stack { - /** - * The ACM certificate for the domain - */ - public readonly certificate: acm.Certificate; - - /** - * The certificate ARN for cross-stack references - */ - public readonly certificateArn: string; - - constructor(scope: Construct, id: string, props: CertificateStackProps) { - super(scope, id, props); - - const { domainName } = props; - - // Create wildcard certificate for the domain - // This covers both the apex domain and all subdomains - this.certificate = new acm.Certificate(this, "WildcardCertificate", { - domainName: domainName, - subjectAlternativeNames: [`*.${domainName}`], - validation: acm.CertificateValidation.fromDns(), - certificateName: `${domainName}-wildcard`, - }); - - this.certificateArn = this.certificate.certificateArn; - - // Outputs - new cdk.CfnOutput(this, "CertificateArn", { - value: this.certificate.certificateArn, - description: "ACM Certificate ARN", - exportName: `CertificateArn-${domainName.replace(/\./g, "-")}`, - }); - - new cdk.CfnOutput(this, "DomainName", { - value: domainName, - description: "Domain name for the certificate", - }); - - // Important: After deployment, you need to add CNAME records to your DNS provider - // Run: aws acm describe-certificate --certificate-arn --query 'Certificate.DomainValidationOptions' - // to get the CNAME records needed for DNS validation - new cdk.CfnOutput(this, "ValidationInstructions", { - value: `Run 'aws acm describe-certificate --certificate-arn ${this.certificate.certificateArn} --query Certificate.DomainValidationOptions' to get DNS validation records`, - description: "Instructions for DNS validation", - }); - } -} diff --git a/infrastructure/cdk/lib/condition-check-lambda-stack.ts b/infrastructure/cdk/lib/condition-check-lambda-stack.ts deleted file mode 100644 index d0cc6c8..0000000 --- a/infrastructure/cdk/lib/condition-check-lambda-stack.ts +++ /dev/null @@ -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}`, - }); - } -} diff --git a/infrastructure/cdk/lib/ecr-stack.ts b/infrastructure/cdk/lib/ecr-stack.ts deleted file mode 100644 index a2180c1..0000000 --- a/infrastructure/cdk/lib/ecr-stack.ts +++ /dev/null @@ -1,90 +0,0 @@ -import * as cdk from "aws-cdk-lib"; -import * as ecr from "aws-cdk-lib/aws-ecr"; -import { Construct } from "constructs"; - -interface EcrStackProps extends cdk.StackProps { - /** - * Environment name (dev, staging, prod) - */ - environment: string; - - /** - * Number of images to retain (default: 10) - */ - maxImageCount?: number; -} - -export class EcrStack extends cdk.Stack { - /** - * Backend Docker image repository - */ - public readonly backendRepository: ecr.Repository; - - /** - * Frontend Docker image repository - */ - public readonly frontendRepository: ecr.Repository; - - constructor(scope: Construct, id: string, props: EcrStackProps) { - super(scope, id, props); - - const { environment, maxImageCount = 10 } = props; - - // Backend repository - this.backendRepository = new ecr.Repository(this, "BackendRepository", { - repositoryName: `rentall-backend-${environment}`, - removalPolicy: cdk.RemovalPolicy.RETAIN, - imageScanOnPush: true, - imageTagMutability: ecr.TagMutability.MUTABLE, - lifecycleRules: [ - { - rulePriority: 1, - description: `Keep only the last ${maxImageCount} images`, - maxImageCount: maxImageCount, - tagStatus: ecr.TagStatus.ANY, - }, - ], - }); - - // Frontend repository - this.frontendRepository = new ecr.Repository(this, "FrontendRepository", { - repositoryName: `rentall-frontend-${environment}`, - removalPolicy: cdk.RemovalPolicy.RETAIN, - imageScanOnPush: true, - imageTagMutability: ecr.TagMutability.MUTABLE, - lifecycleRules: [ - { - rulePriority: 1, - description: `Keep only the last ${maxImageCount} images`, - maxImageCount: maxImageCount, - tagStatus: ecr.TagStatus.ANY, - }, - ], - }); - - // Outputs - new cdk.CfnOutput(this, "BackendRepositoryUri", { - value: this.backendRepository.repositoryUri, - description: "Backend ECR repository URI", - exportName: `BackendRepositoryUri-${environment}`, - }); - - new cdk.CfnOutput(this, "BackendRepositoryName", { - value: this.backendRepository.repositoryName, - description: "Backend ECR repository name", - exportName: `BackendRepositoryName-${environment}`, - }); - - new cdk.CfnOutput(this, "FrontendRepositoryUri", { - value: this.frontendRepository.repositoryUri, - description: "Frontend ECR repository URI", - exportName: `FrontendRepositoryUri-${environment}`, - }); - - new cdk.CfnOutput(this, "FrontendRepositoryName", { - value: this.frontendRepository.repositoryName, - description: "Frontend ECR repository name", - exportName: `FrontendRepositoryName-${environment}`, - }); - } -} diff --git a/infrastructure/cdk/lib/ecs-service-stack.ts b/infrastructure/cdk/lib/ecs-service-stack.ts deleted file mode 100644 index b0d0c27..0000000 --- a/infrastructure/cdk/lib/ecs-service-stack.ts +++ /dev/null @@ -1,483 +0,0 @@ -import * as cdk from "aws-cdk-lib"; -import * as ec2 from "aws-cdk-lib/aws-ec2"; -import * as ecs from "aws-cdk-lib/aws-ecs"; -import * as ecr from "aws-cdk-lib/aws-ecr"; -import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2"; -import * as logs from "aws-cdk-lib/aws-logs"; -import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager"; -import * as acm from "aws-cdk-lib/aws-certificatemanager"; -import * as iam from "aws-cdk-lib/aws-iam"; -import { Construct } from "constructs"; - -interface EcsServiceStackProps extends cdk.StackProps { - /** - * Environment name (dev, staging, prod) - */ - environment: string; - - /** - * VPC to deploy services in - */ - vpc: ec2.IVpc; - - /** - * ACM certificate for HTTPS - */ - certificate: acm.ICertificate; - - /** - * Backend ECR repository - */ - backendRepository: ecr.IRepository; - - /** - * Frontend ECR repository - */ - frontendRepository: ecr.IRepository; - - /** - * Database credentials secret - */ - databaseSecret: secretsmanager.ISecret; - - /** - * Application secrets (JWT, etc.) - */ - appSecret: secretsmanager.ISecret; - - /** - * Database security group (to allow ECS -> RDS access) - */ - databaseSecurityGroup: ec2.ISecurityGroup; - - /** - * Database endpoint - */ - dbEndpoint: string; - - /** - * Database port - */ - dbPort: number; - - /** - * Database name - */ - dbName: string; - - /** - * Domain name for the environment (e.g., dev.village-share.com) - */ - domainName: string; - - /** - * IP address to restrict ALB access to (CIDR format, e.g., "1.2.3.4/32") - * If not provided, ALB is open to the internet - */ - allowedIp?: string; - - /** - * Frontend URL for CORS configuration - */ - frontendUrl: string; -} - -export class EcsServiceStack extends cdk.Stack { - /** - * The ECS cluster - */ - public readonly cluster: ecs.Cluster; - - /** - * The Application Load Balancer - */ - public readonly alb: elbv2.ApplicationLoadBalancer; - - /** - * Backend ECS service - */ - public readonly backendService: ecs.FargateService; - - /** - * Frontend ECS service - */ - public readonly frontendService: ecs.FargateService; - - constructor(scope: Construct, id: string, props: EcsServiceStackProps) { - super(scope, id, props); - - const { - environment, - vpc, - certificate, - backendRepository, - frontendRepository, - databaseSecret, - appSecret, - databaseSecurityGroup, - dbEndpoint, - dbPort, - dbName, - domainName, - allowedIp, - frontendUrl, - } = props; - - // ECS Cluster with Container Insights - this.cluster = new ecs.Cluster(this, "Cluster", { - clusterName: `rentall-cluster-${environment}`, - vpc, - containerInsights: true, - }); - - // ALB Security Group - const albSecurityGroup = new ec2.SecurityGroup(this, "AlbSecurityGroup", { - vpc, - securityGroupName: `rentall-alb-sg-${environment}`, - description: `ALB security group for rentall ${environment}`, - allowAllOutbound: true, - }); - - // Configure ALB access based on allowedIp - if (allowedIp) { - // Restrict to specific IP (dev environment) - albSecurityGroup.addIngressRule( - ec2.Peer.ipv4(allowedIp), - ec2.Port.tcp(443), - `Allow HTTPS from ${allowedIp}` - ); - albSecurityGroup.addIngressRule( - ec2.Peer.ipv4(allowedIp), - ec2.Port.tcp(80), - `Allow HTTP from ${allowedIp} (for redirect)` - ); - } else { - // Open to the internet (staging/prod) - albSecurityGroup.addIngressRule( - ec2.Peer.anyIpv4(), - ec2.Port.tcp(443), - "Allow HTTPS from anywhere" - ); - albSecurityGroup.addIngressRule( - ec2.Peer.anyIpv4(), - ec2.Port.tcp(80), - "Allow HTTP from anywhere (for redirect)" - ); - } - - // Application Load Balancer - this.alb = new elbv2.ApplicationLoadBalancer(this, "Alb", { - loadBalancerName: `rentall-alb-${environment}`, - vpc, - internetFacing: true, - securityGroup: albSecurityGroup, - vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC }, - }); - - // HTTPS Listener (port 443) - const httpsListener = this.alb.addListener("HttpsListener", { - port: 443, - protocol: elbv2.ApplicationProtocol.HTTPS, - certificates: [certificate], - sslPolicy: elbv2.SslPolicy.TLS12, - }); - - // HTTP Listener (port 80) - Redirect to HTTPS - this.alb.addListener("HttpListener", { - port: 80, - protocol: elbv2.ApplicationProtocol.HTTP, - defaultAction: elbv2.ListenerAction.redirect({ - protocol: "HTTPS", - port: "443", - permanent: true, - }), - }); - - // Backend Security Group - const backendSecurityGroup = new ec2.SecurityGroup( - this, - "BackendSecurityGroup", - { - vpc, - securityGroupName: `rentall-backend-sg-${environment}`, - description: `Backend service security group (${environment})`, - allowAllOutbound: true, - } - ); - - // Allow ALB to reach backend - backendSecurityGroup.addIngressRule( - albSecurityGroup, - ec2.Port.tcp(5000), - "Allow traffic from ALB" - ); - - // Allow backend to reach database - databaseSecurityGroup.addIngressRule( - backendSecurityGroup, - ec2.Port.tcp(dbPort), - "Allow traffic from backend ECS" - ); - - // Frontend Security Group - const frontendSecurityGroup = new ec2.SecurityGroup( - this, - "FrontendSecurityGroup", - { - vpc, - securityGroupName: `rentall-frontend-sg-${environment}`, - description: `Frontend service security group (${environment})`, - allowAllOutbound: true, - } - ); - - // Allow ALB to reach frontend - frontendSecurityGroup.addIngressRule( - albSecurityGroup, - ec2.Port.tcp(80), - "Allow traffic from ALB" - ); - - // CloudWatch Log Groups - const backendLogGroup = new logs.LogGroup(this, "BackendLogGroup", { - logGroupName: `/ecs/rentall-backend-${environment}`, - retention: logs.RetentionDays.ONE_MONTH, - removalPolicy: cdk.RemovalPolicy.DESTROY, - }); - - const frontendLogGroup = new logs.LogGroup(this, "FrontendLogGroup", { - logGroupName: `/ecs/rentall-frontend-${environment}`, - retention: logs.RetentionDays.ONE_MONTH, - removalPolicy: cdk.RemovalPolicy.DESTROY, - }); - - // Backend Task Definition - const backendTaskDef = new ecs.FargateTaskDefinition( - this, - "BackendTaskDef", - { - family: `rentall-backend-${environment}`, - cpu: 512, // 0.5 vCPU - memoryLimitMiB: 1024, // 1 GB - } - ); - - // Grant secrets access to backend task - databaseSecret.grantRead(backendTaskDef.taskRole); - appSecret.grantRead(backendTaskDef.taskRole); - - // Backend container - const backendContainer = backendTaskDef.addContainer("backend", { - containerName: "backend", - image: ecs.ContainerImage.fromEcrRepository(backendRepository, "latest"), - logging: ecs.LogDriver.awsLogs({ - logGroup: backendLogGroup, - streamPrefix: "backend", - }), - environment: { - NODE_ENV: environment === "prod" ? "production" : "development", - PORT: "5000", - DB_HOST: dbEndpoint, - DB_PORT: dbPort.toString(), - DB_NAME: dbName, - FRONTEND_URL: frontendUrl, - CORS_ORIGIN: frontendUrl, - }, - secrets: { - DB_USER: ecs.Secret.fromSecretsManager(databaseSecret, "username"), - DB_PASSWORD: ecs.Secret.fromSecretsManager(databaseSecret, "password"), - JWT_SECRET: ecs.Secret.fromSecretsManager(appSecret, "jwtSecret"), - }, - portMappings: [ - { - containerPort: 5000, - protocol: ecs.Protocol.TCP, - }, - ], - healthCheck: { - command: [ - "CMD-SHELL", - "curl -f http://localhost:5000/api/health || exit 1", - ], - interval: cdk.Duration.seconds(30), - timeout: cdk.Duration.seconds(5), - retries: 3, - startPeriod: cdk.Duration.seconds(60), - }, - }); - - // Backend Service - this.backendService = new ecs.FargateService(this, "BackendService", { - serviceName: `backend-${environment}`, - cluster: this.cluster, - taskDefinition: backendTaskDef, - desiredCount: 1, - securityGroups: [backendSecurityGroup], - vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, - enableExecuteCommand: true, // Enable ECS Exec for debugging/migrations - circuitBreaker: { rollback: true }, - minHealthyPercent: 100, - maxHealthyPercent: 200, - }); - - // Frontend Task Definition (Fargate Spot for cost savings) - const frontendTaskDef = new ecs.FargateTaskDefinition( - this, - "FrontendTaskDef", - { - family: `rentall-frontend-${environment}`, - cpu: 256, // 0.25 vCPU - memoryLimitMiB: 512, // 512 MB - } - ); - - // Frontend container - const frontendContainer = frontendTaskDef.addContainer("frontend", { - containerName: "frontend", - image: ecs.ContainerImage.fromEcrRepository(frontendRepository, "latest"), - logging: ecs.LogDriver.awsLogs({ - logGroup: frontendLogGroup, - streamPrefix: "frontend", - }), - portMappings: [ - { - containerPort: 80, - protocol: ecs.Protocol.TCP, - }, - ], - healthCheck: { - command: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"], - interval: cdk.Duration.seconds(30), - timeout: cdk.Duration.seconds(5), - retries: 3, - startPeriod: cdk.Duration.seconds(30), - }, - }); - - // Frontend Service (using Fargate Spot for 70% cost savings) - this.frontendService = new ecs.FargateService(this, "FrontendService", { - serviceName: `frontend-${environment}`, - cluster: this.cluster, - taskDefinition: frontendTaskDef, - desiredCount: 1, - securityGroups: [frontendSecurityGroup], - vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, - capacityProviderStrategies: [ - { - capacityProvider: "FARGATE_SPOT", - weight: 1, - }, - ], - circuitBreaker: { rollback: true }, - minHealthyPercent: 100, - maxHealthyPercent: 200, - }); - - // Backend Target Group - const backendTargetGroup = new elbv2.ApplicationTargetGroup( - this, - "BackendTargetGroup", - { - targetGroupName: `backend-tg-${environment}`, - vpc, - port: 5000, - protocol: elbv2.ApplicationProtocol.HTTP, - targetType: elbv2.TargetType.IP, - healthCheck: { - path: "/api/health", - healthyHttpCodes: "200", - interval: cdk.Duration.seconds(30), - timeout: cdk.Duration.seconds(5), - healthyThresholdCount: 2, - unhealthyThresholdCount: 3, - }, - deregistrationDelay: cdk.Duration.seconds(30), - } - ); - - // Register backend service with target group - this.backendService.attachToApplicationTargetGroup(backendTargetGroup); - - // Frontend Target Group - const frontendTargetGroup = new elbv2.ApplicationTargetGroup( - this, - "FrontendTargetGroup", - { - targetGroupName: `frontend-tg-${environment}`, - vpc, - port: 80, - protocol: elbv2.ApplicationProtocol.HTTP, - targetType: elbv2.TargetType.IP, - healthCheck: { - path: "/", - healthyHttpCodes: "200", - interval: cdk.Duration.seconds(30), - timeout: cdk.Duration.seconds(5), - healthyThresholdCount: 2, - unhealthyThresholdCount: 3, - }, - deregistrationDelay: cdk.Duration.seconds(30), - } - ); - - // Register frontend service with target group - this.frontendService.attachToApplicationTargetGroup(frontendTargetGroup); - - // Configure listener rules for path-based routing - // /api/* -> backend - httpsListener.addTargetGroups("BackendRule", { - targetGroups: [backendTargetGroup], - priority: 10, - conditions: [elbv2.ListenerCondition.pathPatterns(["/api/*"])], - }); - - // /* -> frontend (default) - httpsListener.addTargetGroups("FrontendRule", { - targetGroups: [frontendTargetGroup], - priority: 20, - conditions: [elbv2.ListenerCondition.pathPatterns(["/*"])], - }); - - // Outputs - new cdk.CfnOutput(this, "ClusterName", { - value: this.cluster.clusterName, - description: "ECS Cluster name", - exportName: `ClusterName-${environment}`, - }); - - new cdk.CfnOutput(this, "AlbDnsName", { - value: this.alb.loadBalancerDnsName, - description: "ALB DNS name - add CNAME record pointing to this", - exportName: `AlbDnsName-${environment}`, - }); - - new cdk.CfnOutput(this, "AlbArn", { - value: this.alb.loadBalancerArn, - description: "ALB ARN", - exportName: `AlbArn-${environment}`, - }); - - new cdk.CfnOutput(this, "ServiceUrl", { - value: `https://${domainName}`, - description: "Service URL", - }); - - new cdk.CfnOutput(this, "BackendServiceName", { - value: this.backendService.serviceName, - description: "Backend service name", - exportName: `BackendServiceName-${environment}`, - }); - - new cdk.CfnOutput(this, "FrontendServiceName", { - value: this.frontendService.serviceName, - description: "Frontend service name", - exportName: `FrontendServiceName-${environment}`, - }); - - // Instructions for accessing the service - new cdk.CfnOutput(this, "DnsInstructions", { - value: `Add CNAME record: ${domainName} -> ${this.alb.loadBalancerDnsName}`, - description: "DNS configuration instructions", - }); - } -} diff --git a/infrastructure/cdk/lib/image-processor-lambda-stack.ts b/infrastructure/cdk/lib/image-processor-lambda-stack.ts deleted file mode 100644 index 7f3bb8f..0000000 --- a/infrastructure/cdk/lib/image-processor-lambda-stack.ts +++ /dev/null @@ -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}`, - }); - } -} diff --git a/infrastructure/cdk/lib/rds-stack.ts b/infrastructure/cdk/lib/rds-stack.ts deleted file mode 100644 index c748ce1..0000000 --- a/infrastructure/cdk/lib/rds-stack.ts +++ /dev/null @@ -1,174 +0,0 @@ -import * as cdk from "aws-cdk-lib"; -import * as ec2 from "aws-cdk-lib/aws-ec2"; -import * as rds from "aws-cdk-lib/aws-rds"; -import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager"; -import { Construct } from "constructs"; - -interface RdsStackProps extends cdk.StackProps { - /** - * Environment name (dev, staging, prod) - */ - environment: string; - - /** - * VPC to deploy the database in - */ - vpc: ec2.IVpc; - - /** - * Database credentials secret from SecretsStack - */ - databaseSecret: secretsmanager.ISecret; - - /** - * Database name (default: rentall) - */ - databaseName?: string; - - /** - * Instance type (default: t3.micro for Free Tier) - */ - instanceType?: ec2.InstanceType; - - /** - * Allocated storage in GB (default: 20) - */ - allocatedStorage?: number; - - /** - * Enable Multi-AZ deployment (default: false for dev/staging) - */ - multiAz?: boolean; - - /** - * Backup retention days (default: 7) - */ - backupRetentionDays?: number; -} - -export class RdsStack extends cdk.Stack { - /** - * The RDS database instance - */ - public readonly database: rds.DatabaseInstance; - - /** - * Security group for the database - */ - public readonly databaseSecurityGroup: ec2.SecurityGroup; - - /** - * Database endpoint address - */ - public readonly dbEndpoint: string; - - /** - * Database port - */ - public readonly dbPort: number; - - constructor(scope: Construct, id: string, props: RdsStackProps) { - super(scope, id, props); - - const { - environment, - vpc, - databaseSecret, - databaseName = "rentall", - instanceType = ec2.InstanceType.of( - ec2.InstanceClass.T3, - ec2.InstanceSize.MICRO - ), - allocatedStorage = 20, - multiAz = false, - backupRetentionDays = 7, - } = props; - - // Security group for the database - this.databaseSecurityGroup = new ec2.SecurityGroup( - this, - "DatabaseSecurityGroup", - { - vpc, - securityGroupName: `rentall-db-sg-${environment}`, - description: `Security group for RDS database (${environment})`, - allowAllOutbound: false, - } - ); - - // Create the RDS instance - this.database = new rds.DatabaseInstance(this, "Database", { - instanceIdentifier: `rentall-db-${environment}`, - engine: rds.DatabaseInstanceEngine.postgres({ - version: rds.PostgresEngineVersion.VER_15, - }), - instanceType, - vpc, - vpcSubnets: { - subnetType: ec2.SubnetType.PRIVATE_ISOLATED, - }, - securityGroups: [this.databaseSecurityGroup], - credentials: rds.Credentials.fromSecret(databaseSecret), - databaseName, - allocatedStorage, - maxAllocatedStorage: allocatedStorage * 2, // Allow storage autoscaling up to 2x - storageType: rds.StorageType.GP2, - multiAz, - autoMinorVersionUpgrade: true, - deletionProtection: environment === "prod", - removalPolicy: - environment === "prod" - ? cdk.RemovalPolicy.RETAIN - : cdk.RemovalPolicy.DESTROY, - backupRetention: cdk.Duration.days(backupRetentionDays), - preferredBackupWindow: "03:00-04:00", // UTC - preferredMaintenanceWindow: "Sun:04:00-Sun:05:00", // UTC - storageEncrypted: true, - monitoringInterval: cdk.Duration.seconds(60), - enablePerformanceInsights: true, - performanceInsightRetention: rds.PerformanceInsightRetention.DEFAULT, // 7 days (free) - parameterGroup: new rds.ParameterGroup(this, "ParameterGroup", { - engine: rds.DatabaseInstanceEngine.postgres({ - version: rds.PostgresEngineVersion.VER_15, - }), - parameters: { - // Enforce SSL connections - "rds.force_ssl": "1", - // Log slow queries (> 1 second) - log_min_duration_statement: "1000", - }, - }), - publiclyAccessible: false, - }); - - this.dbEndpoint = this.database.dbInstanceEndpointAddress; - this.dbPort = this.database.dbInstanceEndpointPort - ? parseInt(this.database.dbInstanceEndpointPort) - : 5432; - - // Outputs - new cdk.CfnOutput(this, "DatabaseEndpoint", { - value: this.database.dbInstanceEndpointAddress, - description: "Database endpoint address", - exportName: `DatabaseEndpoint-${environment}`, - }); - - new cdk.CfnOutput(this, "DatabasePort", { - value: this.database.dbInstanceEndpointPort, - description: "Database port", - exportName: `DatabasePort-${environment}`, - }); - - new cdk.CfnOutput(this, "DatabaseSecurityGroupId", { - value: this.databaseSecurityGroup.securityGroupId, - description: "Database security group ID", - exportName: `DatabaseSecurityGroupId-${environment}`, - }); - - new cdk.CfnOutput(this, "DatabaseInstanceIdentifier", { - value: this.database.instanceIdentifier, - description: "Database instance identifier", - exportName: `DatabaseInstanceIdentifier-${environment}`, - }); - } -} diff --git a/infrastructure/cdk/lib/secrets-stack.ts b/infrastructure/cdk/lib/secrets-stack.ts deleted file mode 100644 index 315c363..0000000 --- a/infrastructure/cdk/lib/secrets-stack.ts +++ /dev/null @@ -1,87 +0,0 @@ -import * as cdk from "aws-cdk-lib"; -import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager"; -import { Construct } from "constructs"; - -interface SecretsStackProps extends cdk.StackProps { - /** - * Environment name (dev, staging, prod) - */ - environment: string; - - /** - * Database username (default: rentall_admin) - */ - dbUsername?: string; -} - -export class SecretsStack extends cdk.Stack { - /** - * Database credentials secret - */ - public readonly databaseSecret: secretsmanager.Secret; - - /** - * Application secrets (JWT, etc.) - */ - public readonly appSecret: secretsmanager.Secret; - - constructor(scope: Construct, id: string, props: SecretsStackProps) { - super(scope, id, props); - - const { environment, dbUsername = "rentall_admin" } = props; - - // Database credentials secret with auto-generated password - this.databaseSecret = new secretsmanager.Secret(this, "DatabaseSecret", { - secretName: `rentall/${environment}/database`, - description: `Database credentials for rentall ${environment} environment`, - generateSecretString: { - secretStringTemplate: JSON.stringify({ - username: dbUsername, - }), - generateStringKey: "password", - excludePunctuation: true, - excludeCharacters: '/@"\'\\', - passwordLength: 32, - }, - }); - - // Application secrets (JWT secret, etc.) - this.appSecret = new secretsmanager.Secret(this, "AppSecret", { - secretName: `rentall/${environment}/app`, - description: `Application secrets for rentall ${environment} environment`, - generateSecretString: { - secretStringTemplate: JSON.stringify({ - // Add any additional app secrets here - }), - generateStringKey: "jwtSecret", - excludePunctuation: false, - passwordLength: 64, - }, - }); - - // Outputs - new cdk.CfnOutput(this, "DatabaseSecretArn", { - value: this.databaseSecret.secretArn, - description: "Database credentials secret ARN", - exportName: `DatabaseSecretArn-${environment}`, - }); - - new cdk.CfnOutput(this, "DatabaseSecretName", { - value: this.databaseSecret.secretName, - description: "Database credentials secret name", - exportName: `DatabaseSecretName-${environment}`, - }); - - new cdk.CfnOutput(this, "AppSecretArn", { - value: this.appSecret.secretArn, - description: "Application secrets ARN", - exportName: `AppSecretArn-${environment}`, - }); - - new cdk.CfnOutput(this, "AppSecretName", { - value: this.appSecret.secretName, - description: "Application secrets name", - exportName: `AppSecretName-${environment}`, - }); - } -} diff --git a/infrastructure/cdk/lib/vpc-stack.ts b/infrastructure/cdk/lib/vpc-stack.ts deleted file mode 100644 index b6cc03d..0000000 --- a/infrastructure/cdk/lib/vpc-stack.ts +++ /dev/null @@ -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}`, - }); - } -} diff --git a/infrastructure/cdk/package-lock.json b/infrastructure/cdk/package-lock.json deleted file mode 100644 index 2f062d8..0000000 --- a/infrastructure/cdk/package-lock.json +++ /dev/null @@ -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" - } - } -} diff --git a/infrastructure/cdk/package.json b/infrastructure/cdk/package.json deleted file mode 100644 index 1be94b4..0000000 --- a/infrastructure/cdk/package.json +++ /dev/null @@ -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" - } -} diff --git a/infrastructure/cdk/tsconfig.json b/infrastructure/cdk/tsconfig.json deleted file mode 100644 index e5be163..0000000 --- a/infrastructure/cdk/tsconfig.json +++ /dev/null @@ -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"] -} diff --git a/lambdas/conditionCheckReminder/handler.js b/lambdas/conditionCheckReminder/handler.js index 7a4714a..bbb354c 100644 --- a/lambdas/conditionCheckReminder/handler.js +++ b/lambdas/conditionCheckReminder/handler.js @@ -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) { diff --git a/lambdas/shared/email/client.js b/lambdas/shared/email/client.js index 7d9c07b..0f4b323 100644 --- a/lambdas/shared/email/client.js +++ b/lambdas/shared/email/client.js @@ -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 }; }