condition check lambda

This commit is contained in:
jackiettran
2026-01-13 17:14:19 -05:00
parent 2ee5571b5b
commit f5fdcbfb82
30 changed files with 14293 additions and 461 deletions

View File

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

View File

@@ -0,0 +1,222 @@
const path = require("path");
const {
SchedulerClient,
DeleteScheduleCommand,
} = require("@aws-sdk/client-scheduler");
const { queries, email, logger } = require("../shared");
const { conditionCheckExists } = require("./queries");
let schedulerClient = null;
/**
* Get or create an EventBridge Scheduler client.
*/
function getSchedulerClient() {
if (!schedulerClient) {
schedulerClient = new SchedulerClient({
region: process.env.AWS_REGION || "us-east-1",
});
}
return schedulerClient;
}
/**
* Delete a one-time EventBridge schedule after it has fired.
* @param {string} scheduleName - Name of the schedule to delete
*/
async function deleteSchedule(scheduleName) {
try {
const client = getSchedulerClient();
const groupName =
process.env.SCHEDULE_GROUP_NAME || "condition-check-reminders";
await client.send(
new DeleteScheduleCommand({
Name: scheduleName,
GroupName: groupName,
})
);
logger.info("Deleted schedule after execution", {
scheduleName,
groupName,
});
} catch (error) {
// Log but don't fail - schedule may have already been deleted
logger.warn("Failed to delete schedule", {
scheduleName,
error: error.message,
});
}
}
/**
* Get email content based on check type.
* @param {string} checkType - Type of condition check
* @param {Object} rental - Rental with item, owner, renter details
* @returns {Object} Email content (subject, title, message, recipient)
*/
function getEmailContent(checkType, rental) {
const itemName = rental.item.name;
const frontendUrl = process.env.FRONTEND_URL;
const content = {
pre_rental_owner: {
recipient: rental.owner,
subject: `Condition Check Reminder: ${itemName}`,
title: "Pre-Rental Condition Check",
message: `Please take photos of "${itemName}" before the rental begins tomorrow. This helps protect both you and the renter.`,
deadline: email.formatEmailDate(rental.startDateTime),
},
rental_start_renter: {
recipient: rental.renter,
subject: `Document Item Condition: ${itemName}`,
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)
),
},
rental_end_renter: {
recipient: rental.renter,
subject: `Return Condition Check: ${itemName}`,
title: "Rental End Condition Check",
message: `Please take photos when returning "${itemName}" to document its condition before handoff.`,
deadline: email.formatEmailDate(rental.endDateTime),
},
post_rental_owner: {
recipient: rental.owner,
subject: `Review Return: ${itemName}`,
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)
),
},
};
return content[checkType];
}
/**
* Process a condition check reminder.
* @param {string} rentalId - UUID of the rental
* @param {string} checkType - Type of condition check
* @param {string} scheduleName - Name of the EventBridge schedule (for cleanup)
* @returns {Promise<Object>} Result of the operation
*/
async function processReminder(rentalId, checkType, scheduleName) {
logger.info("Processing condition check reminder", {
rentalId,
checkType,
scheduleName,
});
try {
// 1. Check if condition check already exists (skip if yes)
const exists = await conditionCheckExists(rentalId, checkType);
if (exists) {
logger.info("Condition check already exists, skipping reminder", {
rentalId,
checkType,
});
// Still delete the schedule
if (scheduleName) {
await deleteSchedule(scheduleName);
}
return { success: true, skipped: true, reason: "condition_check_exists" };
}
// 2. Fetch rental details
const rental = await queries.getRentalWithDetails(rentalId);
if (!rental) {
logger.error("Rental not found", { rentalId });
return { success: false, error: "rental_not_found" };
}
// 3. Check rental status - only send for active rentals
const validStatuses = ["confirmed", "active", "completed"];
if (!validStatuses.includes(rental.status)) {
logger.info("Rental status not valid for reminder", {
rentalId,
status: rental.status,
});
if (scheduleName) {
await deleteSchedule(scheduleName);
}
return { success: true, skipped: true, reason: "invalid_rental_status" };
}
// 4. Get email content and send
const emailContent = getEmailContent(checkType, rental);
if (!emailContent) {
logger.error("Unknown check type", { checkType });
return { success: false, error: "unknown_check_type" };
}
// 5. Load and render the email template
const templatePath = path.join(
__dirname,
"templates",
"conditionCheckReminderToUser.html"
);
const template = await email.loadTemplate(templatePath);
const htmlBody = email.renderTemplate(template, {
title: emailContent.title,
message: emailContent.message,
itemName: rental.item.name,
deadline: emailContent.deadline,
recipientName: emailContent.recipient.firstName,
});
// 6. Send the email
const result = await email.sendEmail(
emailContent.recipient.email,
emailContent.subject,
htmlBody
);
if (!result.success) {
logger.error("Failed to send reminder email", {
rentalId,
checkType,
error: result.error,
});
return { success: false, error: result.error };
}
logger.info("Sent condition check reminder", {
rentalId,
checkType,
to: emailContent.recipient.email,
messageId: result.messageId,
});
// 7. Delete the one-time schedule
if (scheduleName) {
await deleteSchedule(scheduleName);
}
return { success: true, messageId: result.messageId };
} catch (error) {
logger.error("Error processing condition check reminder", {
rentalId,
checkType,
error: error.message,
stack: error.stack,
});
return { success: false, error: error.message };
}
}
module.exports = {
processReminder,
getEmailContent,
deleteSchedule,
};

View File

@@ -0,0 +1,68 @@
const { processReminder } = require("./handler");
const { logger } = require("../shared");
/**
* Lambda handler for condition check reminder emails.
*
* Invoked by EventBridge Scheduler with a payload containing:
* - rentalId: UUID of the rental
* - checkType: Type of check (pre_rental_owner, rental_start_renter, rental_end_renter, post_rental_owner)
* - scheduleName: Name of the schedule (for cleanup after execution)
*
* @param {Object} event - EventBridge Scheduler event
* @returns {Promise<Object>} Result of the reminder processing
*/
exports.handler = async (event) => {
logger.info("Lambda invoked", { event });
// Extract payload - EventBridge Scheduler sends it directly
const { rentalId, checkType, scheduleName } = event;
// Validate required fields
if (!rentalId) {
logger.error("Missing rentalId in event payload");
return {
statusCode: 400,
body: JSON.stringify({ error: "Missing rentalId" }),
};
}
if (!checkType) {
logger.error("Missing checkType in event payload");
return {
statusCode: 400,
body: JSON.stringify({ error: "Missing checkType" }),
};
}
// Validate checkType
const validCheckTypes = [
"pre_rental_owner",
"rental_start_renter",
"rental_end_renter",
"post_rental_owner",
];
if (!validCheckTypes.includes(checkType)) {
logger.error("Invalid checkType", { checkType, validCheckTypes });
return {
statusCode: 400,
body: JSON.stringify({ error: "Invalid checkType" }),
};
}
// Process the reminder
const result = await processReminder(rentalId, checkType, scheduleName);
if (result.success) {
return {
statusCode: 200,
body: JSON.stringify(result),
};
} else {
return {
statusCode: 500,
body: JSON.stringify(result),
};
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
{
"name": "condition-check-reminder-lambda",
"version": "1.0.0",
"description": "Lambda function for sending condition check reminder emails",
"main": "index.js",
"dependencies": {
"@aws-sdk/client-scheduler": "^3.896.0",
"@rentall/lambda-shared": "file:../shared"
},
"devDependencies": {
"dotenv": "^17.2.3",
"jest": "^30.1.3"
},
"scripts": {
"test": "jest",
"local": "node -r dotenv/config test-local.js dotenv_config_path=.env.dev"
}
}

View File

@@ -0,0 +1,22 @@
const { query } = require("../shared/db/connection");
/**
* Check if a condition check already exists for a rental and check type.
* @param {string} rentalId - UUID of the rental
* @param {string} checkType - Type of check (pre_rental_owner, rental_start_renter, etc.)
* @returns {Promise<boolean>} True if a condition check exists
*/
async function conditionCheckExists(rentalId, checkType) {
const result = await query(
`SELECT id FROM "ConditionChecks"
WHERE "rentalId" = $1 AND "checkType" = $2
LIMIT 1`,
[rentalId, checkType]
);
return result.rows.length > 0;
}
module.exports = {
conditionCheckExists,
};

View File

@@ -0,0 +1,266 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>{{title}}</title>
<style>
/* Reset styles */
body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
}
/* Base styles */
body {
margin: 0;
padding: 0;
width: 100% !important;
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
/* Container */
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Header */
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #e9ecef;
font-size: 14px;
margin-top: 8px;
}
/* Content */
.content {
padding: 40px 30px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content h2 {
font-size: 20px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff !important;
text-decoration: none;
padding: 16px 32px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
text-align: center;
transition: all 0.3s ease;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
/* Info box */
.info-box {
background-color: #e3f2fd;
border-left: 4px solid #2196f3;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0;
color: #1565c0;
}
.info-box .icon {
font-size: 24px;
margin-bottom: 10px;
}
/* Alert box */
.alert-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.alert-box p {
margin: 0;
color: #856404;
}
/* Footer */
.footer {
background-color: #f8f9fa;
padding: 30px;
text-align: center;
border-top: 1px solid #e9ecef;
}
.footer p {
margin: 0 0 10px 0;
font-size: 14px;
color: #6c757d;
}
.footer a {
color: #667eea;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
/* Responsive */
@media only screen and (max-width: 600px) {
.email-container {
margin: 0;
border-radius: 0;
}
.header,
.content,
.footer {
padding: 20px;
}
.logo {
font-size: 28px;
}
.content h1 {
font-size: 22px;
}
.button {
display: block;
width: 100%;
box-sizing: border-box;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Your trusted rental marketplace</div>
</div>
<div class="content">
<h1>{{title}}</h1>
<p>{{message}}</p>
<div class="info-box">
<p><strong>Rental Item:</strong> {{itemName}}</p>
<p><strong>Deadline:</strong> {{deadline}}</p>
</div>
<p>
Taking condition photos helps protect both renters and owners by
providing clear documentation of the item's state. This is an
important step in the rental process.
</p>
<div class="alert-box">
<p>
<strong>Important:</strong> Please complete this condition check as
soon as possible. Missing this deadline may affect dispute
resolution if issues arise.
</p>
</div>
<h2>What to photograph:</h2>
<ul>
<li>Overall view of the item</li>
<li>Any existing damage or wear</li>
<li>Serial numbers or identifying marks</li>
<li>Accessories or additional components</li>
</ul>
<p>
If you have any questions about the condition check process, please
don't hesitate to contact our support team.
</p>
</div>
<div class="footer">
<p>&copy; 2025 Village Share. All rights reserved.</p>
<p>
You received this email because you have an active rental on Village
Share.
</p>
<p>
If you have any questions, please
<a href="mailto:support@villageshare.app">contact our support team</a
>.
</p>
</div>
</div>
</body>
</html>

View File

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

View File

@@ -0,0 +1,89 @@
const { Pool } = require("pg");
let pool = null;
/**
* Get or create a PostgreSQL connection pool.
* Uses connection pooling optimized for Lambda:
* - Reuses connections across invocations (when container is warm)
* - Small pool size to avoid exhausting database connections
*
* Expects DATABASE_URL environment variable in format:
* postgresql://user:password@host:port/database
*/
function getPool() {
if (!pool) {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("DATABASE_URL environment variable is required");
}
pool = new Pool({
connectionString,
// Lambda-optimized settings
max: 1, // Single connection per Lambda instance
idleTimeoutMillis: 120000, // 2 minutes - keep connection warm
connectionTimeoutMillis: 5000, // 5 seconds to connect
});
// Handle pool errors
pool.on("error", (err) => {
console.error("Unexpected database pool error:", err);
pool = null; // Reset pool on error
});
}
return pool;
}
/**
* Execute a query with automatic connection management.
* @param {string} text - SQL query text
* @param {Array} params - Query parameters
* @returns {Promise<object>} Query result
*/
async function query(text, params) {
const pool = getPool();
const start = Date.now();
try {
const result = await pool.query(text, params);
const duration = Date.now() - start;
console.log(JSON.stringify({
level: "debug",
message: "Executed query",
query: text.substring(0, 100),
duration,
rows: result.rowCount,
}));
return result;
} catch (error) {
console.error(JSON.stringify({
level: "error",
message: "Query failed",
query: text.substring(0, 100),
error: error.message,
}));
throw error;
}
}
/**
* Close the connection pool (for cleanup).
* Call this at the end of Lambda execution if needed.
*/
async function closePool() {
if (pool) {
await pool.end();
pool = null;
}
}
module.exports = {
getPool,
query,
closePool,
};

View File

@@ -0,0 +1,109 @@
const { query } = require("./connection");
/**
* Get rental with all related details (owner, renter, item).
* @param {string} rentalId - UUID of the rental
* @returns {Promise<object|null>} Rental with relations or null if not found
*/
async function getRentalWithDetails(rentalId) {
const result = await query(
`SELECT
r.id,
r."itemId",
r."renterId",
r."ownerId",
r."startDateTime",
r."endDateTime",
r."totalAmount",
r.status,
r."createdAt",
r."updatedAt",
-- Owner fields
owner.id AS "owner_id",
owner.email AS "owner_email",
owner."firstName" AS "owner_firstName",
owner."lastName" AS "owner_lastName",
-- Renter fields
renter.id AS "renter_id",
renter.email AS "renter_email",
renter."firstName" AS "renter_firstName",
renter."lastName" AS "renter_lastName",
-- Item fields
item.id AS "item_id",
item.name AS "item_name",
item.description AS "item_description"
FROM "Rentals" r
INNER JOIN "Users" owner ON r."ownerId" = owner.id
INNER JOIN "Users" renter ON r."renterId" = renter.id
INNER JOIN "Items" item ON r."itemId" = item.id
WHERE r.id = $1`,
[rentalId]
);
if (result.rows.length === 0) {
return null;
}
const row = result.rows[0];
// Transform flat result into nested structure
return {
id: row.id,
itemId: row.itemId,
renterId: row.renterId,
ownerId: row.ownerId,
startDateTime: row.startDateTime,
endDateTime: row.endDateTime,
totalAmount: row.totalAmount,
status: row.status,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
owner: {
id: row.owner_id,
email: row.owner_email,
firstName: row.owner_firstName,
lastName: row.owner_lastName,
},
renter: {
id: row.renter_id,
email: row.renter_email,
firstName: row.renter_firstName,
lastName: row.renter_lastName,
},
item: {
id: row.item_id,
name: row.item_name,
description: row.item_description,
},
};
}
/**
* Get user by ID.
* @param {string} userId - UUID of the user
* @returns {Promise<object|null>} User or null if not found
*/
async function getUserById(userId) {
const result = await query(
`SELECT
id,
email,
"firstName",
"lastName",
phone
FROM "Users"
WHERE id = $1`,
[userId]
);
if (result.rows.length === 0) {
return null;
}
return result.rows[0];
}
module.exports = {
getRentalWithDetails,
getUserById,
};

View File

@@ -0,0 +1,196 @@
const { SESClient, SendEmailCommand } = require("@aws-sdk/client-ses");
const fs = require("fs").promises;
const path = require("path");
let sesClient = null;
/**
* Get or create an SES client.
* Reuses client across Lambda invocations for better performance.
*/
function getSESClient() {
if (!sesClient) {
sesClient = new SESClient({
region: process.env.AWS_REGION || "us-east-1",
});
}
return sesClient;
}
/**
* Convert HTML to plain text for email fallback.
* @param {string} html - HTML content to convert
* @returns {string} Plain text version
*/
function htmlToPlainText(html) {
return html
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n\n")
.replace(/<\/div>/gi, "\n")
.replace(/<\/li>/gi, "\n")
.replace(/<\/h[1-6]>/gi, "\n\n")
.replace(/<li>/gi, "- ")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\n\s*\n\s*\n/g, "\n\n")
.trim();
}
/**
* Render a template by replacing {{variable}} placeholders with values.
* @param {string} template - Template string with {{placeholders}}
* @param {Object} variables - Key-value pairs to substitute
* @returns {string} Rendered template
*/
function renderTemplate(template, variables = {}) {
let rendered = template;
Object.keys(variables).forEach((key) => {
const regex = new RegExp(`{{${key}}}`, "g");
rendered = rendered.replace(regex, variables[key] ?? "");
});
return rendered;
}
/**
* Load a template file from disk.
* @param {string} templatePath - Absolute path to the template file
* @returns {Promise<string>} Template content
*/
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,
}));
throw error;
}
}
/**
* Send an email using AWS SES.
* @param {string|string[]} to - Recipient email address(es)
* @param {string} subject - Email subject line
* @param {string} htmlBody - HTML content of the email
* @param {string|null} textBody - Plain text content (auto-generated if not provided)
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
*/
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,
}));
return { success: true, messageId: "disabled" };
}
const client = getSESClient();
// Auto-generate plain text from HTML if not provided
const plainText = textBody || htmlToPlainText(htmlBody);
// Build sender address with friendly name
const fromName = process.env.SES_FROM_NAME || "Village Share";
const fromEmail = process.env.SES_FROM_EMAIL;
if (!fromEmail) {
throw new Error("SES_FROM_EMAIL environment variable is required");
}
const source = `${fromName} <${fromEmail}>`;
const params = {
Source: source,
Destination: {
ToAddresses: Array.isArray(to) ? to : [to],
},
Message: {
Subject: {
Data: subject,
Charset: "UTF-8",
},
Body: {
Html: {
Data: htmlBody,
Charset: "UTF-8",
},
Text: {
Data: plainText,
Charset: "UTF-8",
},
},
},
};
// Add reply-to if configured
if (process.env.SES_REPLY_TO_EMAIL) {
params.ReplyToAddresses = [process.env.SES_REPLY_TO_EMAIL];
}
try {
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,
}));
return { success: true, messageId: result.MessageId };
} catch (error) {
console.error(JSON.stringify({
level: "error",
message: "Failed to send email",
to,
subject,
error: error.message,
}));
return { success: false, error: error.message };
}
}
/**
* Format a date for email display.
* @param {Date|string} date - Date to format
* @returns {string} Formatted date string
*/
function formatEmailDate(date) {
const dateObj = typeof date === "string" ? new Date(date) : date;
return dateObj.toLocaleString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
timeZone: "America/New_York",
});
}
module.exports = {
sendEmail,
loadTemplate,
renderTemplate,
htmlToPlainText,
formatEmailDate,
};

15
lambdas/shared/index.js Normal file
View File

@@ -0,0 +1,15 @@
/**
* Shared utilities for Rentall Lambda functions.
*/
const db = require("./db/connection");
const queries = require("./db/queries");
const email = require("./email/client");
const logger = require("./utils/logger");
module.exports = {
db,
queries,
email,
logger,
};

5891
lambdas/shared/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
{
"name": "@rentall/lambda-shared",
"version": "1.0.0",
"description": "Shared utilities for Rentall Lambda functions",
"main": "index.js",
"dependencies": {
"@aws-sdk/client-ses": "^3.896.0",
"@aws-sdk/client-scheduler": "^3.896.0",
"pg": "^8.16.3"
},
"devDependencies": {
"jest": "^30.1.3"
},
"scripts": {
"test": "jest"
}
}

View File

@@ -0,0 +1,61 @@
/**
* CloudWatch-compatible structured logger for Lambda functions.
* Outputs JSON logs that CloudWatch Logs Insights can parse and query.
*/
const LOG_LEVELS = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
// Default to 'info' in production, 'debug' in development
const currentLevel = LOG_LEVELS[process.env.LOG_LEVEL?.toLowerCase()] ?? LOG_LEVELS.info;
/**
* Create a log entry in CloudWatch-compatible JSON format.
* @param {string} level - Log level (debug, info, warn, error)
* @param {string} message - Log message
* @param {Object} meta - Additional metadata to include
*/
function log(level, message, meta = {}) {
if (LOG_LEVELS[level] < currentLevel) {
return;
}
const entry = {
timestamp: new Date().toISOString(),
level: level.toUpperCase(),
message,
...meta,
};
// Add Lambda context if available
if (process.env.AWS_LAMBDA_FUNCTION_NAME) {
entry.function = process.env.AWS_LAMBDA_FUNCTION_NAME;
}
if (process.env.AWS_LAMBDA_LOG_STREAM_NAME) {
entry.logStream = process.env.AWS_LAMBDA_LOG_STREAM_NAME;
}
const output = JSON.stringify(entry);
switch (level) {
case "error":
console.error(output);
break;
case "warn":
console.warn(output);
break;
default:
console.log(output);
}
}
module.exports = {
debug: (message, meta) => log("debug", message, meta),
info: (message, meta) => log("info", message, meta),
warn: (message, meta) => log("warn", message, meta),
error: (message, meta) => log("error", message, meta),
};