condition check lambda
This commit is contained in:
@@ -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;
|
|
||||||
886
backend/package-lock.json
generated
886
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,7 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.940.0",
|
"@aws-sdk/client-s3": "^3.940.0",
|
||||||
|
"@aws-sdk/client-scheduler": "^3.896.0",
|
||||||
"@aws-sdk/client-ses": "^3.896.0",
|
"@aws-sdk/client-ses": "^3.896.0",
|
||||||
"@aws-sdk/credential-providers": "^3.901.0",
|
"@aws-sdk/credential-providers": "^3.901.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.940.0",
|
"@aws-sdk/s3-request-presigner": "^3.940.0",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const DamageAssessmentService = require("../services/damageAssessmentService");
|
|||||||
const StripeWebhookService = require("../services/stripeWebhookService");
|
const StripeWebhookService = require("../services/stripeWebhookService");
|
||||||
const StripeService = require("../services/stripeService");
|
const StripeService = require("../services/stripeService");
|
||||||
const emailServices = require("../services/email");
|
const emailServices = require("../services/email");
|
||||||
|
const EventBridgeSchedulerService = require("../services/eventBridgeSchedulerService");
|
||||||
const logger = require("../utils/logger");
|
const logger = require("../utils/logger");
|
||||||
const { PaymentError } = require("../utils/stripeErrors");
|
const { PaymentError } = require("../utils/stripeErrors");
|
||||||
const { validateS3Keys } = require("../utils/s3KeyValidator");
|
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 confirmation emails
|
||||||
// Send approval confirmation to owner with Stripe reminder
|
// Send approval confirmation to owner with Stripe reminder
|
||||||
try {
|
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 confirmation emails
|
||||||
// Send approval confirmation to owner (for free rentals, no Stripe reminder shown)
|
// Send approval confirmation to owner (for free rentals, no Stripe reminder shown)
|
||||||
try {
|
try {
|
||||||
@@ -1842,6 +1877,21 @@ router.post(
|
|||||||
paymentMethodLast4,
|
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
|
// Send confirmation emails
|
||||||
try {
|
try {
|
||||||
await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(
|
await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ const uploadRoutes = require("./routes/upload");
|
|||||||
const healthRoutes = require("./routes/health");
|
const healthRoutes = require("./routes/health");
|
||||||
|
|
||||||
const PayoutProcessor = require("./jobs/payoutProcessor");
|
const PayoutProcessor = require("./jobs/payoutProcessor");
|
||||||
const ConditionCheckReminderJob = require("./jobs/conditionCheckReminder");
|
|
||||||
const emailServices = require("./services/email");
|
const emailServices = require("./services/email");
|
||||||
const s3Service = require("./services/s3Service");
|
const s3Service = require("./services/s3Service");
|
||||||
|
|
||||||
@@ -233,11 +232,6 @@ sequelize
|
|||||||
const payoutJobs = PayoutProcessor.startScheduledPayouts();
|
const payoutJobs = PayoutProcessor.startScheduledPayouts();
|
||||||
logger.info("Payout processor started");
|
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, () => {
|
server.listen(PORT, () => {
|
||||||
logger.info(`Server is running on port ${PORT}`, {
|
logger.info(`Server is running on port ${PORT}`, {
|
||||||
port: PORT,
|
port: PORT,
|
||||||
|
|||||||
292
backend/services/eventBridgeSchedulerService.js
Normal file
292
backend/services/eventBridgeSchedulerService.js
Normal 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();
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
const { Rental } = require("../models");
|
const { Rental } = require("../models");
|
||||||
const StripeService = require("./stripeService");
|
const StripeService = require("./stripeService");
|
||||||
|
const EventBridgeSchedulerService = require("./eventBridgeSchedulerService");
|
||||||
const { isActive } = require("../utils/rentalStatus");
|
const { isActive } = require("../utils/rentalStatus");
|
||||||
const logger = require("../utils/logger");
|
const logger = require("../utils/logger");
|
||||||
|
|
||||||
@@ -187,6 +188,17 @@ class RefundService {
|
|||||||
payoutStatus: "pending",
|
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 {
|
return {
|
||||||
rental: updatedRental,
|
rental: updatedRental,
|
||||||
refund: {
|
refund: {
|
||||||
|
|||||||
@@ -67,8 +67,8 @@ const PricingForm: React.FC<PricingFormProps> = ({
|
|||||||
"Set multiple pricing tiers for flexible rental rates."
|
"Set multiple pricing tiers for flexible rental rates."
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
Community Rentals charges a 10% Community Upkeep Fee to help keep
|
Village Share charges a 10% Community Upkeep Fee to help keep us
|
||||||
us running.{" "}
|
running.{" "}
|
||||||
<Link to="/faq" target="_blank">
|
<Link to="/faq" target="_blank">
|
||||||
Calculate what you can earn here
|
Calculate what you can earn here
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const FAQ: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<p>
|
<p>
|
||||||
We charge a <strong>10% Community Upkeep Fee</strong> on
|
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>
|
||||||
<p className="mb-2">This fee covers:</p>
|
<p className="mb-2">This fee covers:</p>
|
||||||
<ul className="mb-3">
|
<ul className="mb-3">
|
||||||
@@ -70,7 +70,10 @@ const FAQ: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="small text-muted">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -87,7 +90,8 @@ const FAQ: React.FC = () => {
|
|||||||
<strong>Transfer Initiated</strong>
|
<strong>Transfer Initiated</strong>
|
||||||
<span className="text-success ms-2">(Immediate)</span>
|
<span className="text-success ms-2">(Immediate)</span>
|
||||||
<p className="mb-0 small text-muted">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,7 +101,8 @@ const FAQ: React.FC = () => {
|
|||||||
<strong>Funds in Stripe</strong>
|
<strong>Funds in Stripe</strong>
|
||||||
<span className="text-success ms-2">(Instant)</span>
|
<span className="text-success ms-2">(Instant)</span>
|
||||||
<p className="mb-0 small text-muted">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,15 +110,21 @@ const FAQ: React.FC = () => {
|
|||||||
<span className="badge bg-primary me-2">3</span>
|
<span className="badge bg-primary me-2">3</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>Funds in Your Bank</strong>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="small text-muted mb-0">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -124,12 +135,24 @@ const FAQ: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<p>A payout may show as "pending" for a few reasons:</p>
|
<p>A payout may show as "pending" for a few reasons:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Rental not yet complete:</strong> The owner needs to mark the rental as complete before the payout is initiated.</li>
|
<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>
|
<strong>Rental not yet complete:</strong> The owner needs to
|
||||||
<li><strong>Processing:</strong> Payouts are initiated immediately but may take a moment to process.</li>
|
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>
|
</ul>
|
||||||
<p className="small text-muted mb-0">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
63
infrastructure/README.md
Normal file
63
infrastructure/README.md
Normal 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
|
||||||
63
infrastructure/cdk/bin/app.ts
Normal file
63
infrastructure/cdk/bin/app.ts
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
21
infrastructure/cdk/cdk.json
Normal file
21
infrastructure/cdk/cdk.json
Normal 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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
225
infrastructure/cdk/lib/condition-check-lambda-stack.ts
Normal file
225
infrastructure/cdk/lib/condition-check-lambda-stack.ts
Normal 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}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
25
infrastructure/cdk/package.json
Normal file
25
infrastructure/cdk/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
26
infrastructure/cdk/tsconfig.json
Normal file
26
infrastructure/cdk/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
53
lambdas/conditionCheckReminder/README.md
Normal file
53
lambdas/conditionCheckReminder/README.md
Normal 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.
|
||||||
222
lambdas/conditionCheckReminder/handler.js
Normal file
222
lambdas/conditionCheckReminder/handler.js
Normal 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,
|
||||||
|
};
|
||||||
68
lambdas/conditionCheckReminder/index.js
Normal file
68
lambdas/conditionCheckReminder/index.js
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
5709
lambdas/conditionCheckReminder/package-lock.json
generated
Normal file
5709
lambdas/conditionCheckReminder/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
lambdas/conditionCheckReminder/package.json
Normal file
18
lambdas/conditionCheckReminder/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
22
lambdas/conditionCheckReminder/queries.js
Normal file
22
lambdas/conditionCheckReminder/queries.js
Normal 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,
|
||||||
|
};
|
||||||
@@ -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>© 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>
|
||||||
41
lambdas/conditionCheckReminder/test-local.js
Normal file
41
lambdas/conditionCheckReminder/test-local.js
Normal 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);
|
||||||
|
});
|
||||||
89
lambdas/shared/db/connection.js
Normal file
89
lambdas/shared/db/connection.js
Normal 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,
|
||||||
|
};
|
||||||
109
lambdas/shared/db/queries.js
Normal file
109
lambdas/shared/db/queries.js
Normal 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,
|
||||||
|
};
|
||||||
196
lambdas/shared/email/client.js
Normal file
196
lambdas/shared/email/client.js
Normal 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(/ /g, " ")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/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
15
lambdas/shared/index.js
Normal 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
5891
lambdas/shared/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
lambdas/shared/package.json
Normal file
17
lambdas/shared/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
61
lambdas/shared/utils/logger.js
Normal file
61
lambdas/shared/utils/logger.js
Normal 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),
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user