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);
});