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

@@ -1,258 +0,0 @@
const cron = require("node-cron");
const {
Rental,
User,
Item,
ConditionCheck,
} = require("../models");
const { Op } = require("sequelize");
const emailServices = require("../services/email");
const logger = require("../utils/logger");
const reminderSchedule = "0 * * * *"; // Run every hour
class ConditionCheckReminderJob {
static startScheduledReminders() {
console.log("Starting automated condition check reminder job...");
const reminderJob = cron.schedule(
reminderSchedule,
async () => {
try {
await this.sendConditionCheckReminders();
} catch (error) {
logger.error("Error in scheduled condition check reminders", {
error: error.message,
stack: error.stack,
});
}
},
{
scheduled: false,
timezone: "America/New_York",
}
);
// Start the job
reminderJob.start();
console.log("Condition check reminder job scheduled:");
console.log("- Reminders every hour: " + reminderSchedule);
return {
reminderJob,
stop() {
reminderJob.stop();
console.log("Condition check reminder job stopped");
},
getStatus() {
return {
reminderJobRunning: reminderJob.getStatus() === "scheduled",
};
},
};
}
// Send reminders for upcoming condition check windows
static async sendConditionCheckReminders() {
try {
const now = new Date();
const reminderWindow = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 24 hours ahead
// Find rentals with upcoming condition check windows
const rentals = await Rental.findAll({
where: {
status: {
[Op.in]: ["confirmed", "active", "completed"],
},
},
include: [
{ model: User, as: "owner" },
{ model: User, as: "renter" },
{ model: Item, as: "item" },
],
});
for (const rental of rentals) {
await this.checkAndSendConditionReminders(rental, now, reminderWindow);
}
console.log(
`Processed ${rentals.length} rentals for condition check reminders`
);
} catch (error) {
console.error("Error sending condition check reminders:", error);
}
}
// Check specific rental for reminder needs
static async checkAndSendConditionReminders(rental, now, reminderWindow) {
const rentalStart = new Date(rental.startDateTime);
const rentalEnd = new Date(rental.endDateTime);
// Pre-rental owner check (24 hours before rental start)
const preRentalWindow = new Date(
rentalStart.getTime() - 24 * 60 * 60 * 1000
);
if (now <= preRentalWindow && preRentalWindow <= reminderWindow) {
const existingCheck = await ConditionCheck.findOne({
where: {
rentalId: rental.id,
checkType: "pre_rental_owner",
},
});
if (!existingCheck) {
await this.sendPreRentalOwnerReminder(rental);
}
}
// Rental start renter check (within 24 hours of rental start)
if (now <= rentalStart && rentalStart <= reminderWindow) {
const existingCheck = await ConditionCheck.findOne({
where: {
rentalId: rental.id,
checkType: "rental_start_renter",
},
});
if (!existingCheck) {
await this.sendRentalStartRenterReminder(rental);
}
}
// Rental end renter check (within 24 hours of rental end)
if (now <= rentalEnd && rentalEnd <= reminderWindow) {
const existingCheck = await ConditionCheck.findOne({
where: {
rentalId: rental.id,
checkType: "rental_end_renter",
},
});
if (!existingCheck) {
await this.sendRentalEndRenterReminder(rental);
}
}
// Post-rental owner check (24 hours after rental end)
const postRentalWindow = new Date(
rentalEnd.getTime() + 24 * 60 * 60 * 1000
);
if (now <= postRentalWindow && postRentalWindow <= reminderWindow) {
const existingCheck = await ConditionCheck.findOne({
where: {
rentalId: rental.id,
checkType: "post_rental_owner",
},
});
if (!existingCheck) {
await this.sendPostRentalOwnerReminder(rental);
}
}
}
// Individual email senders
static async sendPreRentalOwnerReminder(rental) {
const notificationData = {
type: "condition_check_reminder",
subtype: "pre_rental_owner",
title: "Condition Check Reminder",
message: `Please take photos of "${rental.item.name}" before the rental begins tomorrow.`,
rentalId: rental.id,
userId: rental.ownerId,
metadata: {
checkType: "pre_rental_owner",
deadline: new Date(rental.startDateTime).toISOString(),
},
};
await emailServices.rentalReminder.sendConditionCheckReminder(
rental.owner.email,
notificationData,
rental
);
console.log(`Pre-rental owner reminder sent for rental ${rental.id}`);
}
static async sendRentalStartRenterReminder(rental) {
const notificationData = {
type: "condition_check_reminder",
subtype: "rental_start_renter",
title: "Condition Check Reminder",
message: `Please take photos when you receive "${rental.item.name}" to document its condition.`,
rentalId: rental.id,
userId: rental.renterId,
metadata: {
checkType: "rental_start_renter",
deadline: new Date(
rental.startDateTime.getTime() + 24 * 60 * 60 * 1000
).toISOString(),
},
};
await emailServices.rentalReminder.sendConditionCheckReminder(
rental.renter.email,
notificationData,
rental
);
console.log(`Rental start renter reminder sent for rental ${rental.id}`);
}
static async sendRentalEndRenterReminder(rental) {
const notificationData = {
type: "condition_check_reminder",
subtype: "rental_end_renter",
title: "Condition Check Reminder",
message: `Please take photos when returning "${rental.item.name}" to document its condition.`,
rentalId: rental.id,
userId: rental.renterId,
metadata: {
checkType: "rental_end_renter",
deadline: new Date(
rental.endDateTime.getTime() + 24 * 60 * 60 * 1000
).toISOString(),
},
};
await emailServices.rentalReminder.sendConditionCheckReminder(
rental.renter.email,
notificationData,
rental
);
console.log(`Rental end renter reminder sent for rental ${rental.id}`);
}
static async sendPostRentalOwnerReminder(rental) {
const notificationData = {
type: "condition_check_reminder",
subtype: "post_rental_owner",
title: "Condition Check Reminder",
message: `Please take photos and mark the return status for "${rental.item.name}".`,
rentalId: rental.id,
userId: rental.ownerId,
metadata: {
checkType: "post_rental_owner",
deadline: new Date(
rental.endDateTime.getTime() + 48 * 60 * 60 * 1000
).toISOString(),
},
};
await emailServices.rentalReminder.sendConditionCheckReminder(
rental.owner.email,
notificationData,
rental
);
console.log(`Post-rental owner reminder sent for rental ${rental.id}`);
}
}
module.exports = ConditionCheckReminderJob;

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,7 @@
"license": "ISC",
"dependencies": {
"@aws-sdk/client-s3": "^3.940.0",
"@aws-sdk/client-scheduler": "^3.896.0",
"@aws-sdk/client-ses": "^3.896.0",
"@aws-sdk/credential-providers": "^3.901.0",
"@aws-sdk/s3-request-presigner": "^3.940.0",

View File

@@ -14,6 +14,7 @@ const DamageAssessmentService = require("../services/damageAssessmentService");
const StripeWebhookService = require("../services/stripeWebhookService");
const StripeService = require("../services/stripeService");
const emailServices = require("../services/email");
const EventBridgeSchedulerService = require("../services/eventBridgeSchedulerService");
const logger = require("../utils/logger");
const { PaymentError } = require("../utils/stripeErrors");
const { validateS3Keys } = require("../utils/s3KeyValidator");
@@ -553,6 +554,23 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
],
});
// Create condition check reminder schedules
try {
await EventBridgeSchedulerService.createConditionCheckSchedules(
updatedRental
);
} catch (schedulerError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error(
"Failed to create condition check schedules",
{
error: schedulerError.message,
rentalId: updatedRental.id,
}
);
// Don't fail the confirmation - schedules are non-critical
}
// Send confirmation emails
// Send approval confirmation to owner with Stripe reminder
try {
@@ -707,6 +725,23 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
],
});
// Create condition check reminder schedules
try {
await EventBridgeSchedulerService.createConditionCheckSchedules(
updatedRental
);
} catch (schedulerError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error(
"Failed to create condition check schedules",
{
error: schedulerError.message,
rentalId: updatedRental.id,
}
);
// Don't fail the confirmation - schedules are non-critical
}
// Send confirmation emails
// Send approval confirmation to owner (for free rentals, no Stripe reminder shown)
try {
@@ -1842,6 +1877,21 @@ router.post(
paymentMethodLast4,
});
// Create condition check reminder schedules
try {
await EventBridgeSchedulerService.createConditionCheckSchedules(rental);
} catch (schedulerError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error(
"Failed to create condition check schedules",
{
error: schedulerError.message,
rentalId: rental.id,
}
);
// Don't fail the confirmation - schedules are non-critical
}
// Send confirmation emails
try {
await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(

View File

@@ -33,7 +33,6 @@ const uploadRoutes = require("./routes/upload");
const healthRoutes = require("./routes/health");
const PayoutProcessor = require("./jobs/payoutProcessor");
const ConditionCheckReminderJob = require("./jobs/conditionCheckReminder");
const emailServices = require("./services/email");
const s3Service = require("./services/s3Service");
@@ -233,11 +232,6 @@ sequelize
const payoutJobs = PayoutProcessor.startScheduledPayouts();
logger.info("Payout processor started");
// Start the condition check reminder job
const conditionCheckJobs =
ConditionCheckReminderJob.startScheduledReminders();
logger.info("Condition check reminder job started");
server.listen(PORT, () => {
logger.info(`Server is running on port ${PORT}`, {
port: PORT,

View File

@@ -0,0 +1,292 @@
const {
SchedulerClient,
CreateScheduleCommand,
DeleteScheduleCommand,
} = require("@aws-sdk/client-scheduler");
const { getAWSConfig } = require("../config/aws");
const logger = require("../utils/logger");
/**
* Service for managing EventBridge Scheduler schedules for condition check reminders.
*
* Creates one-time schedules when a rental is confirmed:
* - pre_rental_owner: 24 hours before rental start
* - rental_start_renter: At rental start
* - rental_end_renter: At rental end
* - post_rental_owner: 24 hours after rental end
*/
class EventBridgeSchedulerService {
constructor() {
if (EventBridgeSchedulerService.instance) {
return EventBridgeSchedulerService.instance;
}
this.client = null;
this.initialized = false;
// Only enable when explicitly set - allows deploying code without activating the feature
this.conditionCheckRemindersEnabled =
process.env.CONDITION_CHECK_REMINDERS_ENABLED === "true";
EventBridgeSchedulerService.instance = this;
}
/**
* Initialize the EventBridge Scheduler client.
*/
async initialize() {
if (this.initialized) return;
try {
const awsConfig = getAWSConfig();
this.client = new SchedulerClient(awsConfig);
this.initialized = true;
logger.info("EventBridge Scheduler Service initialized");
} catch (error) {
logger.error("Failed to initialize EventBridge Scheduler Service", {
error,
});
throw error;
}
}
/**
* Get configuration from environment.
*/
getConfig() {
return {
groupName:
process.env.SCHEDULER_GROUP_NAME || "condition-check-reminders",
lambdaArn: process.env.LAMBDA_CONDITION_CHECK_ARN,
schedulerRoleArn: process.env.SCHEDULER_ROLE_ARN,
};
}
/**
* Create schedule name for a rental and check type.
*/
getScheduleName(rentalId, checkType) {
return `rental-${rentalId}-${checkType}`;
}
/**
* Calculate schedule times for all 4 check types.
*/
getScheduleTimes(rental) {
const startDate = new Date(rental.startDateTime);
const endDate = new Date(rental.endDateTime);
return {
pre_rental_owner: new Date(startDate.getTime() - 24 * 60 * 60 * 1000),
rental_start_renter: startDate,
rental_end_renter: endDate,
post_rental_owner: new Date(endDate.getTime() + 24 * 60 * 60 * 1000),
};
}
/**
* Create a single EventBridge schedule.
*/
async createSchedule(name, scheduleTime, payload) {
if (!this.initialized) {
await this.initialize();
}
const config = this.getConfig();
if (!config.lambdaArn) {
logger.warn("Lambda ARN not configured, skipping schedule creation", {
name,
});
return null;
}
// Don't create schedules for times in the past
if (scheduleTime <= new Date()) {
logger.info("Schedule time is in the past, skipping", {
name,
scheduleTime,
});
return null;
}
try {
const command = new CreateScheduleCommand({
Name: name,
GroupName: config.groupName,
ScheduleExpression: `at(${scheduleTime.toISOString().replace(".000Z", "")})`,
ScheduleExpressionTimezone: "UTC",
FlexibleTimeWindow: {
Mode: "OFF",
},
Target: {
Arn: config.lambdaArn,
RoleArn: config.schedulerRoleArn,
Input: JSON.stringify({
...payload,
scheduleName: name,
}),
},
ActionAfterCompletion: "DELETE", // Auto-delete after execution
});
const result = await this.client.send(command);
logger.info("Created EventBridge schedule", {
name,
scheduleTime,
scheduleArn: result.ScheduleArn,
});
return result.ScheduleArn;
} catch (error) {
// If schedule already exists, that's okay
if (error.name === "ConflictException") {
logger.info("Schedule already exists", { name });
return name;
}
logger.error("Failed to create EventBridge schedule", {
name,
error: error.message,
});
throw error;
}
}
/**
* Delete a single EventBridge schedule.
*/
async deleteSchedule(name) {
if (!this.initialized) {
await this.initialize();
}
const config = this.getConfig();
try {
const command = new DeleteScheduleCommand({
Name: name,
GroupName: config.groupName,
});
await this.client.send(command);
logger.info("Deleted EventBridge schedule", { name });
return true;
} catch (error) {
// If schedule doesn't exist, that's okay
if (error.name === "ResourceNotFoundException") {
logger.info("Schedule not found (already deleted)", { name });
return true;
}
logger.error("Failed to delete EventBridge schedule", {
name,
error: error.message,
});
throw error;
}
}
/**
* Create all 4 condition check schedules for a rental.
* Call this after a rental is confirmed.
*/
async createConditionCheckSchedules(rental) {
if (!this.conditionCheckRemindersEnabled) {
logger.debug(
"EventBridge Scheduler disabled, skipping schedule creation"
);
return null;
}
logger.info("Creating condition check schedules for rental", {
rentalId: rental.id,
});
const scheduleTimes = this.getScheduleTimes(rental);
let schedulesCreated = 0;
const checkTypes = [
"pre_rental_owner",
"rental_start_renter",
"rental_end_renter",
"post_rental_owner",
];
for (const checkType of checkTypes) {
const name = this.getScheduleName(rental.id, checkType);
const scheduleTime = scheduleTimes[checkType];
try {
const arn = await this.createSchedule(name, scheduleTime, {
rentalId: rental.id,
checkType,
scheduledFor: scheduleTime.toISOString(),
});
if (arn) {
schedulesCreated++;
}
} catch (error) {
logger.error("Failed to create schedule, continuing with others", {
rentalId: rental.id,
checkType,
error: error.message,
});
}
}
logger.info("Finished creating condition check schedules", {
rentalId: rental.id,
schedulesCreated,
});
return schedulesCreated;
}
/**
* Delete all condition check schedules for a rental.
* Call this when a rental is cancelled.
*/
async deleteConditionCheckSchedules(rental) {
if (!this.conditionCheckRemindersEnabled) {
logger.debug(
"EventBridge Scheduler disabled, skipping schedule deletion"
);
return;
}
logger.info("Deleting condition check schedules for rental", {
rentalId: rental.id,
});
const checkTypes = [
"pre_rental_owner",
"rental_start_renter",
"rental_end_renter",
"post_rental_owner",
];
for (const checkType of checkTypes) {
const name = this.getScheduleName(rental.id, checkType);
try {
await this.deleteSchedule(name);
} catch (error) {
logger.error("Failed to delete schedule, continuing with others", {
rentalId: rental.id,
checkType,
error: error.message,
});
}
}
logger.info("Finished deleting condition check schedules", {
rentalId: rental.id,
});
}
}
// Export singleton instance
module.exports = new EventBridgeSchedulerService();

View File

@@ -1,5 +1,6 @@
const { Rental } = require("../models");
const StripeService = require("./stripeService");
const EventBridgeSchedulerService = require("./eventBridgeSchedulerService");
const { isActive } = require("../utils/rentalStatus");
const logger = require("../utils/logger");
@@ -187,6 +188,17 @@ class RefundService {
payoutStatus: "pending",
});
// Delete condition check schedules since rental is cancelled
try {
await EventBridgeSchedulerService.deleteConditionCheckSchedules(updatedRental);
} catch (schedulerError) {
logger.error("Failed to delete condition check schedules", {
error: schedulerError.message,
rentalId: updatedRental.id,
});
// Don't fail the cancellation - schedule cleanup is non-critical
}
return {
rental: updatedRental,
refund: {

View File

@@ -67,8 +67,8 @@ const PricingForm: React.FC<PricingFormProps> = ({
"Set multiple pricing tiers for flexible rental rates."
) : (
<>
Community Rentals charges a 10% Community Upkeep Fee to help keep
us running.{" "}
Village Share charges a 10% Community Upkeep Fee to help keep us
running.{" "}
<Link to="/faq" target="_blank">
Calculate what you can earn here
</Link>

View File

@@ -44,7 +44,7 @@ const FAQ: React.FC = () => {
<div>
<p>
We charge a <strong>10% Community Upkeep Fee</strong> on
rental amounts to keep Community Rentals running!
rental amounts to keep Village Share running!
</p>
<p className="mb-2">This fee covers:</p>
<ul className="mb-3">
@@ -70,7 +70,10 @@ const FAQ: React.FC = () => {
</div>
</div>
<p className="small text-muted">
<strong>Payout Timeline:</strong> Earnings are transferred immediately when the rental is marked complete. Funds typically reach your bank within 2-7 business days. Make sure your Stripe account is set up to receive payouts.
<strong>Payout Timeline:</strong> Earnings are transferred
immediately when the rental is marked complete. Funds
typically reach your bank within 2-7 business days. Make sure
your Stripe account is set up to receive payouts.
</p>
</div>
),
@@ -87,7 +90,8 @@ const FAQ: React.FC = () => {
<strong>Transfer Initiated</strong>
<span className="text-success ms-2">(Immediate)</span>
<p className="mb-0 small text-muted">
When the rental is marked complete, we immediately transfer your earnings to your Stripe account.
When the rental is marked complete, we immediately
transfer your earnings to your Stripe account.
</p>
</div>
</div>
@@ -97,7 +101,8 @@ const FAQ: React.FC = () => {
<strong>Funds in Stripe</strong>
<span className="text-success ms-2">(Instant)</span>
<p className="mb-0 small text-muted">
The transfer appears in your Stripe dashboard right away.
The transfer appears in your Stripe dashboard right
away.
</p>
</div>
</div>
@@ -105,15 +110,21 @@ const FAQ: React.FC = () => {
<span className="badge bg-primary me-2">3</span>
<div>
<strong>Funds in Your Bank</strong>
<span className="text-primary ms-2">(2-7 business days)</span>
<span className="text-primary ms-2">
(2-7 business days)
</span>
<p className="mb-0 small text-muted">
Stripe automatically deposits funds to your bank account. Timing depends on your bank and Stripe's payout schedule.
Stripe automatically deposits funds to your bank
account. Timing depends on your bank and Stripe's payout
schedule.
</p>
</div>
</div>
</div>
<p className="small text-muted mb-0">
<strong>Note:</strong> If you haven't completed Stripe onboarding, payouts are held until you finish setup. Once complete, all pending payouts are processed immediately.
<strong>Note:</strong> If you haven't completed Stripe
onboarding, payouts are held until you finish setup. Once
complete, all pending payouts are processed immediately.
</p>
</div>
),
@@ -124,12 +135,24 @@ const FAQ: React.FC = () => {
<div>
<p>A payout may show as "pending" for a few reasons:</p>
<ul>
<li><strong>Rental not yet complete:</strong> The owner needs to mark the rental as complete before the payout is initiated.</li>
<li><strong>Stripe onboarding incomplete:</strong> You need to finish setting up your Stripe account. Visit your <Link to="/earnings">Earnings Dashboard</Link> to complete setup.</li>
<li><strong>Processing:</strong> Payouts are initiated immediately but may take a moment to process.</li>
<li>
<strong>Rental not yet complete:</strong> The owner needs to
mark the rental as complete before the payout is initiated.
</li>
<li>
<strong>Stripe onboarding incomplete:</strong> You need to
finish setting up your Stripe account. Visit your{" "}
<Link to="/earnings">Earnings Dashboard</Link> to complete
setup.
</li>
<li>
<strong>Processing:</strong> Payouts are initiated
immediately but may take a moment to process.
</li>
</ul>
<p className="small text-muted mb-0">
If a payout fails for any reason, we automatically retry daily until it succeeds.
If a payout fails for any reason, we automatically retry daily
until it succeeds.
</p>
</div>
),

63
infrastructure/README.md Normal file
View File

@@ -0,0 +1,63 @@
# Rentall Infrastructure
AWS CDK infrastructure for Rentall Lambda functions.
## Prerequisites
- Node.js 20+
- AWS CLI configured with appropriate credentials
- AWS CDK CLI (`npm install -g aws-cdk`)
## Setup
```bash
cd infrastructure/cdk
npm install
```
## Deploy
### Staging
```bash
npm run deploy:staging
```
### Production
```bash
npm run deploy:prod
```
## Environment Variables
The following environment variables should be set before deployment:
- `DATABASE_URL` - PostgreSQL connection string
- `CDK_DEFAULT_ACCOUNT` - AWS account ID
- `CDK_DEFAULT_REGION` - AWS region (defaults to us-east-1)
## Stacks
### ConditionCheckLambdaStack
Creates:
- Lambda function for condition check reminders
- EventBridge Scheduler group for per-rental schedules
- IAM roles for Lambda execution and Scheduler invocation
- Dead letter queue for failed invocations
## Outputs
After deployment, the following values are exported:
- `ConditionCheckLambdaArn-{env}` - Lambda function ARN
- `ConditionCheckScheduleGroup-{env}` - Schedule group name
- `ConditionCheckSchedulerRoleArn-{env}` - Scheduler IAM role ARN
- `ConditionCheckDLQUrl-{env}` - Dead letter queue URL
## Useful Commands
- `npm run synth` - Synthesize CloudFormation template
- `npm run diff` - Compare deployed stack with current state
- `npm run destroy` - Destroy all stacks

View File

@@ -0,0 +1,63 @@
#!/usr/bin/env node
import "source-map-support/register";
import * as cdk from "aws-cdk-lib";
import { ConditionCheckLambdaStack } from "../lib/condition-check-lambda-stack";
const app = new cdk.App();
// Get environment from context or default to staging
const environment = app.node.tryGetContext("env") || "staging";
// Environment-specific configurations
const envConfig: Record<
string,
{
databaseUrl: string;
frontendUrl: string;
sesFromEmail: string;
emailEnabled: boolean;
}
> = {
staging: {
// These should be passed via CDK context or SSM parameters in production
databaseUrl:
process.env.DATABASE_URL ||
"postgresql://user:password@localhost:5432/rentall_staging",
frontendUrl: "https://staging.villageshare.app",
sesFromEmail: "noreply@villageshare.app",
emailEnabled: true,
},
prod: {
databaseUrl:
process.env.DATABASE_URL ||
"postgresql://user:password@localhost:5432/rentall_prod",
frontendUrl: "https://villageshare.app",
sesFromEmail: "noreply@villageshare.app",
emailEnabled: true,
},
};
const config = envConfig[environment];
if (!config) {
throw new Error(`Unknown environment: ${environment}`);
}
// Create the Lambda stack
new ConditionCheckLambdaStack(app, `ConditionCheckLambdaStack-${environment}`, {
environment,
databaseUrl: config.databaseUrl,
frontendUrl: config.frontendUrl,
sesFromEmail: config.sesFromEmail,
emailEnabled: config.emailEnabled,
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION || "us-east-1",
},
description: `Condition Check Reminder Lambda infrastructure (${environment})`,
tags: {
Environment: environment,
Project: "rentall",
Service: "condition-check-reminder",
},
});

View File

@@ -0,0 +1,21 @@
{
"app": "npx ts-node --prefer-ts-exts bin/app.ts",
"watch": {
"include": ["**"],
"exclude": [
"README.md",
"cdk*.json",
"**/*.d.ts",
"**/*.js",
"tsconfig.json",
"package*.json",
"node_modules",
"test"
]
},
"context": {
"@aws-cdk/aws-lambda:recognizeLayerVersion": true,
"@aws-cdk/core:checkSecretUsage": true,
"@aws-cdk/core:target-partitions": ["aws"]
}
}

View File

@@ -0,0 +1,225 @@
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 { 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;
}
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,
} = 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
lambdaRole.addToPolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
],
resources: ["*"],
})
);
// SES permissions for sending emails
lambdaRole.addToPolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ["ses:SendEmail", "ses:SendRawEmail"],
resources: ["*"],
})
);
// 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}/*`,
],
})
);
// 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",
}
);
// EventBridge Scheduler group
this.scheduleGroup = new scheduler.CfnScheduleGroup(
this,
"ConditionCheckScheduleGroup",
{
name: `condition-check-reminders-${environment}`,
}
);
// IAM role for EventBridge Scheduler to invoke Lambda
this.schedulerRole = new iam.Role(this, "SchedulerRole", {
roleName: `condition-check-scheduler-role-${environment}`,
assumedBy: new iam.ServicePrincipal("scheduler.amazonaws.com"),
description: "Role for EventBridge Scheduler to invoke Lambda",
});
// Allow scheduler to invoke the Lambda
this.schedulerRole.addToPolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ["lambda:InvokeFunction"],
resources: [
this.lambdaFunction.functionArn,
`${this.lambdaFunction.functionArn}:*`,
],
})
);
// Outputs
new cdk.CfnOutput(this, "LambdaFunctionArn", {
value: this.lambdaFunction.functionArn,
description: "ARN of the Condition Check Reminder Lambda",
exportName: `ConditionCheckLambdaArn-${environment}`,
});
new cdk.CfnOutput(this, "ScheduleGroupName", {
value: this.scheduleGroup.name!,
description: "Name of the EventBridge Scheduler group",
exportName: `ConditionCheckScheduleGroup-${environment}`,
});
new cdk.CfnOutput(this, "SchedulerRoleArn", {
value: this.schedulerRole.roleArn,
description: "ARN of the EventBridge Scheduler IAM role",
exportName: `ConditionCheckSchedulerRoleArn-${environment}`,
});
new cdk.CfnOutput(this, "DLQUrl", {
value: this.deadLetterQueue.queueUrl,
description: "URL of the Dead Letter Queue",
exportName: `ConditionCheckDLQUrl-${environment}`,
});
}
}

View File

@@ -0,0 +1,25 @@
{
"name": "rentall-infrastructure",
"version": "1.0.0",
"description": "AWS CDK infrastructure for Rentall Lambda functions",
"scripts": {
"build": "tsc",
"watch": "tsc -w",
"cdk": "cdk",
"synth": "cdk synth",
"deploy": "cdk deploy --all",
"deploy:staging": "cdk deploy --all --context env=staging",
"deploy:prod": "cdk deploy --all --context env=prod",
"diff": "cdk diff",
"destroy": "cdk destroy --all"
},
"dependencies": {
"aws-cdk-lib": "^2.170.0",
"constructs": "^10.4.2"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"aws-cdk": "^2.170.0"
}
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"declaration": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": false,
"inlineSourceMap": true,
"inlineSources": true,
"experimentalDecorators": true,
"strictPropertyInitialization": false,
"outDir": "./dist",
"rootDir": ".",
"typeRoots": ["./node_modules/@types"]
},
"include": ["bin/**/*", "lib/**/*"],
"exclude": ["node_modules", "cdk.out"]
}

View File

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