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