From 5c3d5059889ddd1424b08d75eee941d90374e269 Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Mon, 6 Oct 2025 15:41:48 -0400 Subject: [PATCH] email plus return item statuses --- backend/config/aws.js | 36 + backend/jobs/conditionCheckReminder.js | 258 ++++ backend/jobs/rentalStatusJob.js | 101 ++ backend/models/ConditionCheck.js | 53 + backend/models/Rental.js | 29 +- backend/models/index.js | 101 +- backend/package-lock.json | 1271 +++++++++++++++++ backend/package.json | 1 + backend/routes/conditionChecks.js | 165 +++ backend/routes/rentals.js | 357 ++++- backend/server.js | 24 +- backend/services/conditionCheckService.js | 363 +++++ backend/services/damageAssessmentService.js | 138 ++ backend/services/emailService.js | 497 +++++++ backend/services/lateReturnService.js | 113 ++ backend/services/stripeService.js | 3 + .../emails/conditionCheckReminder.html | 241 ++++ backend/templates/emails/damageReportCS.html | 61 + backend/templates/emails/lateReturnCS.html | 39 + backend/templates/emails/lostItemCS.html | 40 + .../templates/emails/rentalConfirmation.html | 281 ++++ .../src/components/ConditionCheckModal.tsx | 262 ++++ frontend/src/components/ReturnStatusModal.tsx | 954 +++++++++++++ frontend/src/contexts/AuthContext.tsx | 37 +- frontend/src/pages/MyListings.tsx | 318 ++++- frontend/src/pages/MyRentals.tsx | 204 ++- frontend/src/services/api.ts | 80 +- frontend/src/types/index.ts | 93 +- 28 files changed, 5861 insertions(+), 259 deletions(-) create mode 100644 backend/config/aws.js create mode 100644 backend/jobs/conditionCheckReminder.js create mode 100644 backend/jobs/rentalStatusJob.js create mode 100644 backend/models/ConditionCheck.js create mode 100644 backend/routes/conditionChecks.js create mode 100644 backend/services/conditionCheckService.js create mode 100644 backend/services/damageAssessmentService.js create mode 100644 backend/services/emailService.js create mode 100644 backend/services/lateReturnService.js create mode 100644 backend/templates/emails/conditionCheckReminder.html create mode 100644 backend/templates/emails/damageReportCS.html create mode 100644 backend/templates/emails/lateReturnCS.html create mode 100644 backend/templates/emails/lostItemCS.html create mode 100644 backend/templates/emails/rentalConfirmation.html create mode 100644 frontend/src/components/ConditionCheckModal.tsx create mode 100644 frontend/src/components/ReturnStatusModal.tsx diff --git a/backend/config/aws.js b/backend/config/aws.js new file mode 100644 index 0000000..5570521 --- /dev/null +++ b/backend/config/aws.js @@ -0,0 +1,36 @@ +const { fromIni } = require("@aws-sdk/credential-providers"); + +/** + * Get AWS configuration based on environment + * - Development: Uses AWS credential profiles from ~/.aws/credentials + * - Production: Uses IAM roles (EC2/Lambda/ECS instance roles) + */ +function getAWSCredentials() { + if (process.env.NODE_ENV === "dev") { + // Local development: use profile from ~/.aws/credentials + const profile = process.env.AWS_PROFILE; + return fromIni({ profile }); + } +} + +/** + * Get complete AWS client configuration + */ +function getAWSConfig() { + const config = { + region: process.env.AWS_REGION || "us-east-1", + }; + + const credentials = getAWSCredentials(); + + if (credentials) { + config.credentials = credentials; + } + + return config; +} + +module.exports = { + getAWSConfig, + getAWSCredentials, +}; diff --git a/backend/jobs/conditionCheckReminder.js b/backend/jobs/conditionCheckReminder.js new file mode 100644 index 0000000..df792d1 --- /dev/null +++ b/backend/jobs/conditionCheckReminder.js @@ -0,0 +1,258 @@ +const cron = require("node-cron"); +const { + Rental, + User, + Item, + ConditionCheck, +} = require("../models"); +const { Op } = require("sequelize"); +const emailService = require("../services/emailService"); +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 emailService.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 emailService.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 emailService.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 emailService.sendConditionCheckReminder( + rental.owner.email, + notificationData, + rental + ); + + console.log(`Post-rental owner reminder sent for rental ${rental.id}`); + } +} + +module.exports = ConditionCheckReminderJob; diff --git a/backend/jobs/rentalStatusJob.js b/backend/jobs/rentalStatusJob.js new file mode 100644 index 0000000..8281767 --- /dev/null +++ b/backend/jobs/rentalStatusJob.js @@ -0,0 +1,101 @@ +const cron = require("node-cron"); +const { Rental } = require("../models"); +const { Op } = require("sequelize"); +const logger = require("../utils/logger"); + +const statusUpdateSchedule = "*/15 * * * *"; // Run every 15 minutes + +class RentalStatusJob { + static startScheduledStatusUpdates() { + console.log("Starting automated rental status updates..."); + + const statusJob = cron.schedule( + statusUpdateSchedule, + async () => { + try { + await this.activateStartedRentals(); + } catch (error) { + logger.error("Error in scheduled rental status update", { + error: error.message, + stack: error.stack + }); + } + }, + { + scheduled: false, + timezone: "America/New_York", + } + ); + + // Start the job + statusJob.start(); + + console.log("Rental status job scheduled:"); + console.log("- Status updates every 15 minutes: " + statusUpdateSchedule); + + return { + statusJob, + + stop() { + statusJob.stop(); + console.log("Rental status job stopped"); + }, + + getStatus() { + return { + statusJobRunning: statusJob.getStatus() === "scheduled", + }; + }, + }; + } + + static async activateStartedRentals() { + try { + const now = new Date(); + + // Find all confirmed rentals where start time has arrived + const rentalsToActivate = await Rental.findAll({ + where: { + status: "confirmed", + startDateTime: { + [Op.lte]: now, + }, + }, + }); + + if (rentalsToActivate.length === 0) { + return { activated: 0 }; + } + + // Update all matching rentals to active status + const rentalIds = rentalsToActivate.map((r) => r.id); + const [updateCount] = await Rental.update( + { status: "active" }, + { + where: { + id: { + [Op.in]: rentalIds, + }, + }, + } + ); + + logger.info("Activated started rentals", { + count: updateCount, + rentalIds: rentalIds, + }); + + console.log(`Activated ${updateCount} rentals that have started`); + + return { activated: updateCount, rentalIds }; + } catch (error) { + logger.error("Error activating started rentals", { + error: error.message, + stack: error.stack, + }); + throw error; + } + } +} + +module.exports = RentalStatusJob; diff --git a/backend/models/ConditionCheck.js b/backend/models/ConditionCheck.js new file mode 100644 index 0000000..803ece7 --- /dev/null +++ b/backend/models/ConditionCheck.js @@ -0,0 +1,53 @@ +const { DataTypes } = require("sequelize"); +const sequelize = require("../config/database"); + +const ConditionCheck = sequelize.define("ConditionCheck", { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + rentalId: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: "Rentals", + key: "id", + }, + }, + checkType: { + type: DataTypes.ENUM( + "pre_rental_owner", + "rental_start_renter", + "rental_end_renter", + "post_rental_owner" + ), + allowNull: false, + }, + photos: { + type: DataTypes.ARRAY(DataTypes.STRING), + defaultValue: [], + }, + notes: { + type: DataTypes.TEXT, + }, + submittedBy: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: "Users", + key: "id", + }, + }, + submittedAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + metadata: { + type: DataTypes.JSONB, + defaultValue: {}, + }, +}); + +module.exports = ConditionCheck; \ No newline at end of file diff --git a/backend/models/Rental.js b/backend/models/Rental.js index e848377..653ef2a 100644 --- a/backend/models/Rental.js +++ b/backend/models/Rental.js @@ -57,7 +57,11 @@ const Rental = sequelize.define("Rental", { "confirmed", "active", "completed", - "cancelled" + "cancelled", + "returned_late", + "returned_late_and_damaged", + "damaged", + "lost" ), defaultValue: "pending", }, @@ -153,6 +157,29 @@ const Rental = sequelize.define("Rental", { renterPrivateMessage: { type: DataTypes.TEXT, }, + // Condition check and return handling fields + actualReturnDateTime: { + type: DataTypes.DATE, + }, + lateFees: { + type: DataTypes.DECIMAL(10, 2), + defaultValue: 0.0, + }, + damageFees: { + type: DataTypes.DECIMAL(10, 2), + defaultValue: 0.0, + }, + replacementFees: { + type: DataTypes.DECIMAL(10, 2), + defaultValue: 0.0, + }, + itemLostReportedAt: { + type: DataTypes.DATE, + }, + damageAssessment: { + type: DataTypes.JSONB, + defaultValue: {}, + }, }); module.exports = Rental; diff --git a/backend/models/index.js b/backend/models/index.js index 1cb26d0..5fa46a3 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -1,41 +1,75 @@ -const sequelize = require('../config/database'); -const User = require('./User'); -const Item = require('./Item'); -const Rental = require('./Rental'); -const Message = require('./Message'); -const ItemRequest = require('./ItemRequest'); -const ItemRequestResponse = require('./ItemRequestResponse'); -const UserAddress = require('./UserAddress'); +const sequelize = require("../config/database"); +const User = require("./User"); +const Item = require("./Item"); +const Rental = require("./Rental"); +const Message = require("./Message"); +const ItemRequest = require("./ItemRequest"); +const ItemRequestResponse = require("./ItemRequestResponse"); +const UserAddress = require("./UserAddress"); +const ConditionCheck = require("./ConditionCheck"); -User.hasMany(Item, { as: 'ownedItems', foreignKey: 'ownerId' }); -Item.belongsTo(User, { as: 'owner', foreignKey: 'ownerId' }); +User.hasMany(Item, { as: "ownedItems", foreignKey: "ownerId" }); +Item.belongsTo(User, { as: "owner", foreignKey: "ownerId" }); -User.hasMany(Rental, { as: 'rentalsAsRenter', foreignKey: 'renterId' }); -User.hasMany(Rental, { as: 'rentalsAsOwner', foreignKey: 'ownerId' }); +User.hasMany(Rental, { as: "rentalsAsRenter", foreignKey: "renterId" }); +User.hasMany(Rental, { as: "rentalsAsOwner", foreignKey: "ownerId" }); -Item.hasMany(Rental, { as: 'rentals', foreignKey: 'itemId' }); -Rental.belongsTo(Item, { as: 'item', foreignKey: 'itemId' }); -Rental.belongsTo(User, { as: 'renter', foreignKey: 'renterId' }); -Rental.belongsTo(User, { as: 'owner', foreignKey: 'ownerId' }); +Item.hasMany(Rental, { as: "rentals", foreignKey: "itemId" }); +Rental.belongsTo(Item, { as: "item", foreignKey: "itemId" }); +Rental.belongsTo(User, { as: "renter", foreignKey: "renterId" }); +Rental.belongsTo(User, { as: "owner", foreignKey: "ownerId" }); -User.hasMany(Message, { as: 'sentMessages', foreignKey: 'senderId' }); -User.hasMany(Message, { as: 'receivedMessages', foreignKey: 'receiverId' }); -Message.belongsTo(User, { as: 'sender', foreignKey: 'senderId' }); -Message.belongsTo(User, { as: 'receiver', foreignKey: 'receiverId' }); -Message.hasMany(Message, { as: 'replies', foreignKey: 'parentMessageId' }); -Message.belongsTo(Message, { as: 'parentMessage', foreignKey: 'parentMessageId' }); +User.hasMany(Message, { as: "sentMessages", foreignKey: "senderId" }); +User.hasMany(Message, { as: "receivedMessages", foreignKey: "receiverId" }); +Message.belongsTo(User, { as: "sender", foreignKey: "senderId" }); +Message.belongsTo(User, { as: "receiver", foreignKey: "receiverId" }); +Message.hasMany(Message, { as: "replies", foreignKey: "parentMessageId" }); +Message.belongsTo(Message, { + as: "parentMessage", + foreignKey: "parentMessageId", +}); -User.hasMany(ItemRequest, { as: 'itemRequests', foreignKey: 'requesterId' }); -ItemRequest.belongsTo(User, { as: 'requester', foreignKey: 'requesterId' }); +User.hasMany(ItemRequest, { as: "itemRequests", foreignKey: "requesterId" }); +ItemRequest.belongsTo(User, { as: "requester", foreignKey: "requesterId" }); -User.hasMany(ItemRequestResponse, { as: 'itemRequestResponses', foreignKey: 'responderId' }); -ItemRequest.hasMany(ItemRequestResponse, { as: 'responses', foreignKey: 'itemRequestId' }); -ItemRequestResponse.belongsTo(User, { as: 'responder', foreignKey: 'responderId' }); -ItemRequestResponse.belongsTo(ItemRequest, { as: 'itemRequest', foreignKey: 'itemRequestId' }); -ItemRequestResponse.belongsTo(Item, { as: 'existingItem', foreignKey: 'existingItemId' }); +User.hasMany(ItemRequestResponse, { + as: "itemRequestResponses", + foreignKey: "responderId", +}); +ItemRequest.hasMany(ItemRequestResponse, { + as: "responses", + foreignKey: "itemRequestId", +}); +ItemRequestResponse.belongsTo(User, { + as: "responder", + foreignKey: "responderId", +}); +ItemRequestResponse.belongsTo(ItemRequest, { + as: "itemRequest", + foreignKey: "itemRequestId", +}); +ItemRequestResponse.belongsTo(Item, { + as: "existingItem", + foreignKey: "existingItemId", +}); -User.hasMany(UserAddress, { as: 'addresses', foreignKey: 'userId' }); -UserAddress.belongsTo(User, { as: 'user', foreignKey: 'userId' }); +User.hasMany(UserAddress, { as: "addresses", foreignKey: "userId" }); +UserAddress.belongsTo(User, { as: "user", foreignKey: "userId" }); + +// ConditionCheck associations +Rental.hasMany(ConditionCheck, { + as: "conditionChecks", + foreignKey: "rentalId", +}); +ConditionCheck.belongsTo(Rental, { as: "rental", foreignKey: "rentalId" }); +User.hasMany(ConditionCheck, { + as: "conditionChecks", + foreignKey: "submittedBy", +}); +ConditionCheck.belongsTo(User, { + as: "submittedByUser", + foreignKey: "submittedBy", +}); module.exports = { sequelize, @@ -45,5 +79,6 @@ module.exports = { Message, ItemRequest, ItemRequestResponse, - UserAddress -}; \ No newline at end of file + UserAddress, + ConditionCheck, +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index 55f8061..8354b85 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@aws-sdk/client-ses": "^3.896.0", "@googlemaps/google-maps-services-js": "^3.4.2", "bcryptjs": "^3.0.2", "body-parser": "^2.2.0", @@ -84,6 +85,640 @@ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", "license": "MIT" }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-ses": { + "version": "3.896.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ses/-/client-ses-3.896.0.tgz", + "integrity": "sha512-L5C1ZLdTnAAZJqngRxt6RB6boHnx1Jp1U/awmLsBcnW3tEax5iCLtaNkDRZ6XrccYktVcy2lIUXnFJ7G7WunoQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.896.0", + "@aws-sdk/credential-provider-node": "3.896.0", + "@aws-sdk/middleware-host-header": "3.893.0", + "@aws-sdk/middleware-logger": "3.893.0", + "@aws-sdk/middleware-recursion-detection": "3.893.0", + "@aws-sdk/middleware-user-agent": "3.896.0", + "@aws-sdk/region-config-resolver": "3.893.0", + "@aws-sdk/types": "3.893.0", + "@aws-sdk/util-endpoints": "3.895.0", + "@aws-sdk/util-user-agent-browser": "3.893.0", + "@aws-sdk/util-user-agent-node": "3.896.0", + "@smithy/config-resolver": "^4.2.2", + "@smithy/core": "^3.12.0", + "@smithy/fetch-http-handler": "^5.2.1", + "@smithy/hash-node": "^4.1.1", + "@smithy/invalid-dependency": "^4.1.1", + "@smithy/middleware-content-length": "^4.1.1", + "@smithy/middleware-endpoint": "^4.2.4", + "@smithy/middleware-retry": "^4.3.0", + "@smithy/middleware-serde": "^4.1.1", + "@smithy/middleware-stack": "^4.1.1", + "@smithy/node-config-provider": "^4.2.2", + "@smithy/node-http-handler": "^4.2.1", + "@smithy/protocol-http": "^5.2.1", + "@smithy/smithy-client": "^4.6.4", + "@smithy/types": "^4.5.0", + "@smithy/url-parser": "^4.1.1", + "@smithy/util-base64": "^4.1.0", + "@smithy/util-body-length-browser": "^4.1.0", + "@smithy/util-body-length-node": "^4.1.0", + "@smithy/util-defaults-mode-browser": "^4.1.4", + "@smithy/util-defaults-mode-node": "^4.1.4", + "@smithy/util-endpoints": "^3.1.2", + "@smithy/util-middleware": "^4.1.1", + "@smithy/util-retry": "^4.1.2", + "@smithy/util-utf8": "^4.1.0", + "@smithy/util-waiter": "^4.1.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.896.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.896.0.tgz", + "integrity": "sha512-mpE3mrNili1dcvEvxaYjyoib8HlRXkb2bY5a3WeK++KObFY+HUujKtgQmiNSRX5YwQszm//fTrmGMmv9zpMcKg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.896.0", + "@aws-sdk/middleware-host-header": "3.893.0", + "@aws-sdk/middleware-logger": "3.893.0", + "@aws-sdk/middleware-recursion-detection": "3.893.0", + "@aws-sdk/middleware-user-agent": "3.896.0", + "@aws-sdk/region-config-resolver": "3.893.0", + "@aws-sdk/types": "3.893.0", + "@aws-sdk/util-endpoints": "3.895.0", + "@aws-sdk/util-user-agent-browser": "3.893.0", + "@aws-sdk/util-user-agent-node": "3.896.0", + "@smithy/config-resolver": "^4.2.2", + "@smithy/core": "^3.12.0", + "@smithy/fetch-http-handler": "^5.2.1", + "@smithy/hash-node": "^4.1.1", + "@smithy/invalid-dependency": "^4.1.1", + "@smithy/middleware-content-length": "^4.1.1", + "@smithy/middleware-endpoint": "^4.2.4", + "@smithy/middleware-retry": "^4.3.0", + "@smithy/middleware-serde": "^4.1.1", + "@smithy/middleware-stack": "^4.1.1", + "@smithy/node-config-provider": "^4.2.2", + "@smithy/node-http-handler": "^4.2.1", + "@smithy/protocol-http": "^5.2.1", + "@smithy/smithy-client": "^4.6.4", + "@smithy/types": "^4.5.0", + "@smithy/url-parser": "^4.1.1", + "@smithy/util-base64": "^4.1.0", + "@smithy/util-body-length-browser": "^4.1.0", + "@smithy/util-body-length-node": "^4.1.0", + "@smithy/util-defaults-mode-browser": "^4.1.4", + "@smithy/util-defaults-mode-node": "^4.1.4", + "@smithy/util-endpoints": "^3.1.2", + "@smithy/util-middleware": "^4.1.1", + "@smithy/util-retry": "^4.1.2", + "@smithy/util-utf8": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.896.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.896.0.tgz", + "integrity": "sha512-uJaoyWKeGNyCyeI+cIJrD7LEB4iF/W8/x2ij7zg32OFpAAJx96N34/e+XSKp/xkJpO5FKiBOskKLnHeUsJsAPA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.893.0", + "@aws-sdk/xml-builder": "3.894.0", + "@smithy/core": "^3.12.0", + "@smithy/node-config-provider": "^4.2.2", + "@smithy/property-provider": "^4.1.1", + "@smithy/protocol-http": "^5.2.1", + "@smithy/signature-v4": "^5.2.1", + "@smithy/smithy-client": "^4.6.4", + "@smithy/types": "^4.5.0", + "@smithy/util-base64": "^4.1.0", + "@smithy/util-middleware": "^4.1.1", + "@smithy/util-utf8": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.896.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.896.0.tgz", + "integrity": "sha512-Cnqhupdkp825ICySrz4QTI64Nq3AmUAscPW8dueanni0avYBDp7RBppX4H0+6icqN569B983XNfQ0YSImQhfhg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.896.0", + "@aws-sdk/types": "3.893.0", + "@smithy/property-provider": "^4.1.1", + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.896.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.896.0.tgz", + "integrity": "sha512-CN0fTCKCUA1OTSx1c76o8XyJCy2WoI/av3J8r8mL6GmxTerhLRyzDy/MwxzPjTYPoL+GLEg6V4a9fRkWj1hBUA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.896.0", + "@aws-sdk/types": "3.893.0", + "@smithy/fetch-http-handler": "^5.2.1", + "@smithy/node-http-handler": "^4.2.1", + "@smithy/property-provider": "^4.1.1", + "@smithy/protocol-http": "^5.2.1", + "@smithy/smithy-client": "^4.6.4", + "@smithy/types": "^4.5.0", + "@smithy/util-stream": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.896.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.896.0.tgz", + "integrity": "sha512-+rbYG98czzwZLTYHJasK+VBjnIeXk73mRpZXHvaa4kDNxBezdN2YsoGNpLlPSxPdbpq18LY3LRtkdFTaT6DIQA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.896.0", + "@aws-sdk/credential-provider-env": "3.896.0", + "@aws-sdk/credential-provider-http": "3.896.0", + "@aws-sdk/credential-provider-process": "3.896.0", + "@aws-sdk/credential-provider-sso": "3.896.0", + "@aws-sdk/credential-provider-web-identity": "3.896.0", + "@aws-sdk/nested-clients": "3.896.0", + "@aws-sdk/types": "3.893.0", + "@smithy/credential-provider-imds": "^4.1.2", + "@smithy/property-provider": "^4.1.1", + "@smithy/shared-ini-file-loader": "^4.2.0", + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.896.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.896.0.tgz", + "integrity": "sha512-J0Jm+56MNngk1PIyqoJFf5FC2fjA4CYXlqODqNRDtid7yk7HB9W3UTtvxofmii5KJOLcHGNPdGnHWKkUc+xYgw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.896.0", + "@aws-sdk/credential-provider-http": "3.896.0", + "@aws-sdk/credential-provider-ini": "3.896.0", + "@aws-sdk/credential-provider-process": "3.896.0", + "@aws-sdk/credential-provider-sso": "3.896.0", + "@aws-sdk/credential-provider-web-identity": "3.896.0", + "@aws-sdk/types": "3.893.0", + "@smithy/credential-provider-imds": "^4.1.2", + "@smithy/property-provider": "^4.1.1", + "@smithy/shared-ini-file-loader": "^4.2.0", + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.896.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.896.0.tgz", + "integrity": "sha512-UfWVMQPZy7dus40c4LWxh5vQ+I51z0q4vf09Eqas5848e9DrGRG46GYIuc/gy+4CqEypjbg/XNMjnZfGLHxVnQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.896.0", + "@aws-sdk/types": "3.893.0", + "@smithy/property-provider": "^4.1.1", + "@smithy/shared-ini-file-loader": "^4.2.0", + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.896.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.896.0.tgz", + "integrity": "sha512-77Te8WrVdLABKlv7QyetXP6aYEX1UORiahLA1PXQb/p66aFBw18Xc6JiN/6zJ4RqdyV1Xr9rwYBwGYua93ANIA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.896.0", + "@aws-sdk/core": "3.896.0", + "@aws-sdk/token-providers": "3.896.0", + "@aws-sdk/types": "3.893.0", + "@smithy/property-provider": "^4.1.1", + "@smithy/shared-ini-file-loader": "^4.2.0", + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.896.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.896.0.tgz", + "integrity": "sha512-gwMwZWumo+V0xJplO8j2HIb1TfPsF9fbcRGXS0CanEvjg4fF2Xs1pOQl2oCw3biPZpxHB0plNZjqSF2eneGg9g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.896.0", + "@aws-sdk/nested-clients": "3.896.0", + "@aws-sdk/types": "3.893.0", + "@smithy/property-provider": "^4.1.1", + "@smithy/shared-ini-file-loader": "^4.2.0", + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.893.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.893.0.tgz", + "integrity": "sha512-qL5xYRt80ahDfj9nDYLhpCNkDinEXvjLe/Qen/Y/u12+djrR2MB4DRa6mzBCkLkdXDtf0WAoW2EZsNCfGrmOEQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.893.0", + "@smithy/protocol-http": "^5.2.1", + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.893.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.893.0.tgz", + "integrity": "sha512-ZqzMecjju5zkBquSIfVfCORI/3Mge21nUY4nWaGQy+NUXehqCGG4W7AiVpiHGOcY2cGJa7xeEkYcr2E2U9U0AA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.893.0", + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.893.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.893.0.tgz", + "integrity": "sha512-H7Zotd9zUHQAr/wr3bcWHULYhEeoQrF54artgsoUGIf/9emv6LzY89QUccKIxYd6oHKNTrTyXm9F0ZZrzXNxlg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.893.0", + "@aws/lambda-invoke-store": "^0.0.1", + "@smithy/protocol-http": "^5.2.1", + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.896.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.896.0.tgz", + "integrity": "sha512-so/3tZH34YIeqG/QJgn5ZinnmHRdXV1ehsj4wVUrezL/dVW86jfwIkQIwpw8roOC657UoUf91c9FDhCxs3J5aQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.896.0", + "@aws-sdk/types": "3.893.0", + "@aws-sdk/util-endpoints": "3.895.0", + "@smithy/core": "^3.12.0", + "@smithy/protocol-http": "^5.2.1", + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.896.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.896.0.tgz", + "integrity": "sha512-KaHALB6DIXScJL/ExmonADr3jtTV6dpOHoEeTRSskJ/aW+rhZo7kH8SLmrwOT/qX8d5tza17YyR/oRkIKY6Eaw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.896.0", + "@aws-sdk/middleware-host-header": "3.893.0", + "@aws-sdk/middleware-logger": "3.893.0", + "@aws-sdk/middleware-recursion-detection": "3.893.0", + "@aws-sdk/middleware-user-agent": "3.896.0", + "@aws-sdk/region-config-resolver": "3.893.0", + "@aws-sdk/types": "3.893.0", + "@aws-sdk/util-endpoints": "3.895.0", + "@aws-sdk/util-user-agent-browser": "3.893.0", + "@aws-sdk/util-user-agent-node": "3.896.0", + "@smithy/config-resolver": "^4.2.2", + "@smithy/core": "^3.12.0", + "@smithy/fetch-http-handler": "^5.2.1", + "@smithy/hash-node": "^4.1.1", + "@smithy/invalid-dependency": "^4.1.1", + "@smithy/middleware-content-length": "^4.1.1", + "@smithy/middleware-endpoint": "^4.2.4", + "@smithy/middleware-retry": "^4.3.0", + "@smithy/middleware-serde": "^4.1.1", + "@smithy/middleware-stack": "^4.1.1", + "@smithy/node-config-provider": "^4.2.2", + "@smithy/node-http-handler": "^4.2.1", + "@smithy/protocol-http": "^5.2.1", + "@smithy/smithy-client": "^4.6.4", + "@smithy/types": "^4.5.0", + "@smithy/url-parser": "^4.1.1", + "@smithy/util-base64": "^4.1.0", + "@smithy/util-body-length-browser": "^4.1.0", + "@smithy/util-body-length-node": "^4.1.0", + "@smithy/util-defaults-mode-browser": "^4.1.4", + "@smithy/util-defaults-mode-node": "^4.1.4", + "@smithy/util-endpoints": "^3.1.2", + "@smithy/util-middleware": "^4.1.1", + "@smithy/util-retry": "^4.1.2", + "@smithy/util-utf8": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.893.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.893.0.tgz", + "integrity": "sha512-/cJvh3Zsa+Of0Zbg7vl9wp/kZtdb40yk/2+XcroAMVPO9hPvmS9r/UOm6tO7FeX4TtkRFwWaQJiTZTgSdsPY+Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.893.0", + "@smithy/node-config-provider": "^4.2.2", + "@smithy/types": "^4.5.0", + "@smithy/util-config-provider": "^4.1.0", + "@smithy/util-middleware": "^4.1.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.896.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.896.0.tgz", + "integrity": "sha512-WBoD+RY7tUfW9M+wGrZ2vdveR+ziZOjGHWFY3lcGnDvI8KE+fcSccEOTxgJBNBS5Z8B+WHKU2sZjb+Z7QqGwjw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.896.0", + "@aws-sdk/nested-clients": "3.896.0", + "@aws-sdk/types": "3.893.0", + "@smithy/property-provider": "^4.1.1", + "@smithy/shared-ini-file-loader": "^4.2.0", + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.893.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.893.0.tgz", + "integrity": "sha512-Aht1nn5SnA0N+Tjv0dzhAY7CQbxVtmq1bBR6xI0MhG7p2XYVh1wXuKTzrldEvQWwA3odOYunAfT9aBiKZx9qIg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.895.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.895.0.tgz", + "integrity": "sha512-MhxBvWbwxmKknuggO2NeMwOVkHOYL98pZ+1ZRI5YwckoCL3AvISMnPJgfN60ww6AIXHGpkp+HhpFdKOe8RHSEg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.893.0", + "@smithy/types": "^4.5.0", + "@smithy/url-parser": "^4.1.1", + "@smithy/util-endpoints": "^3.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.893.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.893.0.tgz", + "integrity": "sha512-T89pFfgat6c8nMmpI8eKjBcDcgJq36+m9oiXbcUzeU55MP9ZuGgBomGjGnHaEyF36jenW9gmg3NfZDm0AO2XPg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.893.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.893.0.tgz", + "integrity": "sha512-PE9NtbDBW6Kgl1bG6A5fF3EPo168tnkj8TgMcT0sg4xYBWsBpq0bpJZRh+Jm5Bkwiw9IgTCLjEU7mR6xWaMB9w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.893.0", + "@smithy/types": "^4.5.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.896.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.896.0.tgz", + "integrity": "sha512-jegizucAwoxyBddKl0kRGNEgRHcfGuMeyhP1Nf+wIUmHz/9CxobIajqcVk/KRNLdZY5mSn7YG2VtP3z0BcBb0w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.896.0", + "@aws-sdk/types": "3.893.0", + "@smithy/node-config-provider": "^4.2.2", + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.894.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.894.0.tgz", + "integrity": "sha512-E6EAMc9dT1a2DOdo4zyOf3fp5+NJ2wI+mcm7RaW1baFIWDwcb99PpvWoV7YEiK7oaBDshuOEGWKUSYXdW+JYgA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.5.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.0.1.tgz", + "integrity": "sha512-ORHRQ2tmvnBXc8t/X9Z8IcSbBA4xTLKuN873FopzklHMeqBst7YG0d+AX97inkvDX+NChYtSr+qGfcqGFaI8Zw==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1317,6 +1952,600 @@ "node": ">=4" } }, + "node_modules/@smithy/abort-controller": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.1.1.tgz", + "integrity": "sha512-vkzula+IwRvPR6oKQhMYioM3A/oX/lFCZiwuxkQbRhqJS2S4YRY2k7k/SyR2jMf3607HLtbEwlRxi0ndXHMjRg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.2.2.tgz", + "integrity": "sha512-IT6MatgBWagLybZl1xQcURXRICvqz1z3APSCAI9IqdvfCkrA7RaQIEfgC6G/KvfxnDfQUDqFV+ZlixcuFznGBQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.2.2", + "@smithy/types": "^4.5.0", + "@smithy/util-config-provider": "^4.1.0", + "@smithy/util-middleware": "^4.1.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.13.0.tgz", + "integrity": "sha512-BI6ALLPOKnPOU1Cjkc+1TPhOlP3JXSR/UH14JmnaLq41t3ma+IjuXrKfhycVjr5IQ0XxRh2NnQo3olp+eCVrGg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.1.1", + "@smithy/protocol-http": "^5.2.1", + "@smithy/types": "^4.5.0", + "@smithy/util-base64": "^4.1.0", + "@smithy/util-body-length-browser": "^4.1.0", + "@smithy/util-middleware": "^4.1.1", + "@smithy/util-stream": "^4.3.2", + "@smithy/util-utf8": "^4.1.0", + "@smithy/uuid": "^1.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.1.2.tgz", + "integrity": "sha512-JlYNq8TShnqCLg0h+afqe2wLAwZpuoSgOyzhYvTgbiKBWRov+uUve+vrZEQO6lkdLOWPh7gK5dtb9dS+KGendg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.2.2", + "@smithy/property-provider": "^4.1.1", + "@smithy/types": "^4.5.0", + "@smithy/url-parser": "^4.1.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.2.1.tgz", + "integrity": "sha512-5/3wxKNtV3wO/hk1is+CZUhL8a1yy/U+9u9LKQ9kZTkMsHaQjJhc3stFfiujtMnkITjzWfndGA2f7g9Uh9vKng==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.2.1", + "@smithy/querystring-builder": "^4.1.1", + "@smithy/types": "^4.5.0", + "@smithy/util-base64": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.1.1.tgz", + "integrity": "sha512-H9DIU9WBLhYrvPs9v4sYvnZ1PiAI0oc8CgNQUJ1rpN3pP7QADbTOUjchI2FB764Ub0DstH5xbTqcMJu1pnVqxA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.5.0", + "@smithy/util-buffer-from": "^4.1.0", + "@smithy/util-utf8": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.1.1.tgz", + "integrity": "sha512-1AqLyFlfrrDkyES8uhINRlJXmHA2FkG+3DY8X+rmLSqmFwk3DJnvhyGzyByPyewh2jbmV+TYQBEfngQax8IFGg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.1.0.tgz", + "integrity": "sha512-ePTYUOV54wMogio+he4pBybe8fwg4sDvEVDBU8ZlHOZXbXK3/C0XfJgUCu6qAZcawv05ZhZzODGUerFBPsPUDQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.1.1.tgz", + "integrity": "sha512-9wlfBBgTsRvC2JxLJxv4xDGNBrZuio3AgSl0lSFX7fneW2cGskXTYpFxCdRYD2+5yzmsiTuaAJD1Wp7gWt9y9w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.2.1", + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.2.5.tgz", + "integrity": "sha512-DdOIpssQ5LFev7hV6GX9TMBW5ChTsQBxqgNW1ZGtJNSAi5ksd5klwPwwMY0ejejfEzwXXGqxgVO3cpaod4veiA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.13.0", + "@smithy/middleware-serde": "^4.1.1", + "@smithy/node-config-provider": "^4.2.2", + "@smithy/shared-ini-file-loader": "^4.2.0", + "@smithy/types": "^4.5.0", + "@smithy/url-parser": "^4.1.1", + "@smithy/util-middleware": "^4.1.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.3.1.tgz", + "integrity": "sha512-aH2bD1bzb6FB04XBhXA5mgedEZPKx3tD/qBuYCAKt5iieWvWO1Y2j++J9uLqOndXb9Pf/83Xka/YjSnMbcPchA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.2.2", + "@smithy/protocol-http": "^5.2.1", + "@smithy/service-error-classification": "^4.1.2", + "@smithy/smithy-client": "^4.6.5", + "@smithy/types": "^4.5.0", + "@smithy/util-middleware": "^4.1.1", + "@smithy/util-retry": "^4.1.2", + "@smithy/uuid": "^1.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.1.1.tgz", + "integrity": "sha512-lh48uQdbCoj619kRouev5XbWhCwRKLmphAif16c4J6JgJ4uXjub1PI6RL38d3BLliUvSso6klyB/LTNpWSNIyg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.2.1", + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.1.1.tgz", + "integrity": "sha512-ygRnniqNcDhHzs6QAPIdia26M7e7z9gpkIMUe/pK0RsrQ7i5MblwxY8078/QCnGq6AmlUUWgljK2HlelsKIb/A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.2.2.tgz", + "integrity": "sha512-SYGTKyPvyCfEzIN5rD8q/bYaOPZprYUPD2f5g9M7OjaYupWOoQFYJ5ho+0wvxIRf471i2SR4GoiZ2r94Jq9h6A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.1.1", + "@smithy/shared-ini-file-loader": "^4.2.0", + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.2.1.tgz", + "integrity": "sha512-REyybygHlxo3TJICPF89N2pMQSf+p+tBJqpVe1+77Cfi9HBPReNjTgtZ1Vg73exq24vkqJskKDpfF74reXjxfw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.1.1", + "@smithy/protocol-http": "^5.2.1", + "@smithy/querystring-builder": "^4.1.1", + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.1.1.tgz", + "integrity": "sha512-gm3ZS7DHxUbzC2wr8MUCsAabyiXY0gaj3ROWnhSx/9sPMc6eYLMM4rX81w1zsMaObj2Lq3PZtNCC1J6lpEY7zg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.2.1.tgz", + "integrity": "sha512-T8SlkLYCwfT/6m33SIU/JOVGNwoelkrvGjFKDSDtVvAXj/9gOT78JVJEas5a+ETjOu4SVvpCstKgd0PxSu/aHw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.1.1.tgz", + "integrity": "sha512-J9b55bfimP4z/Jg1gNo+AT84hr90p716/nvxDkPGCD4W70MPms0h8KF50RDRgBGZeL83/u59DWNqJv6tEP/DHA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.5.0", + "@smithy/util-uri-escape": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.1.1.tgz", + "integrity": "sha512-63TEp92YFz0oQ7Pj9IuI3IgnprP92LrZtRAkE3c6wLWJxfy/yOPRt39IOKerVr0JS770olzl0kGafXlAXZ1vng==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.1.2.tgz", + "integrity": "sha512-Kqd8wyfmBWHZNppZSMfrQFpc3M9Y/kjyN8n8P4DqJJtuwgK1H914R471HTw7+RL+T7+kI1f1gOnL7Vb5z9+NgQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.2.0.tgz", + "integrity": "sha512-OQTfmIEp2LLuWdxa8nEEPhZmiOREO6bcB6pjs0AySf4yiZhl6kMOfqmcwcY8BaBPX+0Tb+tG7/Ia/6mwpoZ7Pw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.2.1.tgz", + "integrity": "sha512-M9rZhWQLjlQVCCR37cSjHfhriGRN+FQ8UfgrYNufv66TJgk+acaggShl3KS5U/ssxivvZLlnj7QH2CUOKlxPyA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.1.0", + "@smithy/protocol-http": "^5.2.1", + "@smithy/types": "^4.5.0", + "@smithy/util-hex-encoding": "^4.1.0", + "@smithy/util-middleware": "^4.1.1", + "@smithy/util-uri-escape": "^4.1.0", + "@smithy/util-utf8": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.6.5.tgz", + "integrity": "sha512-6J2hhuWu7EjnvLBIGltPCqzNswL1cW/AkaZx6i56qLsQ0ix17IAhmDD9aMmL+6CN9nCJODOXpBTCQS6iKAA7/g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.13.0", + "@smithy/middleware-endpoint": "^4.2.5", + "@smithy/middleware-stack": "^4.1.1", + "@smithy/protocol-http": "^5.2.1", + "@smithy/types": "^4.5.0", + "@smithy/util-stream": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.5.0.tgz", + "integrity": "sha512-RkUpIOsVlAwUIZXO1dsz8Zm+N72LClFfsNqf173catVlvRZiwPy0x2u0JLEA4byreOPKDZPGjmPDylMoP8ZJRg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.1.1.tgz", + "integrity": "sha512-bx32FUpkhcaKlEoOMbScvc93isaSiRM75pQ5IgIBaMkT7qMlIibpPRONyx/0CvrXHzJLpOn/u6YiDX2hcvs7Dg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.1.1", + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.1.0.tgz", + "integrity": "sha512-RUGd4wNb8GeW7xk+AY5ghGnIwM96V0l2uzvs/uVHf+tIuVX2WSvynk5CxNoBCsM2rQRSZElAo9rt3G5mJ/gktQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.1.0", + "@smithy/util-utf8": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.1.0.tgz", + "integrity": "sha512-V2E2Iez+bo6bUMOTENPr6eEmepdY8Hbs+Uc1vkDKgKNA/brTJqOW/ai3JO1BGj9GbCeLqw90pbbH7HFQyFotGQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.1.0.tgz", + "integrity": "sha512-BOI5dYjheZdgR9XiEM3HJcEMCXSoqbzu7CzIgYrx0UtmvtC3tC2iDGpJLsSRFffUpy8ymsg2ARMP5fR8mtuUQQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.1.0.tgz", + "integrity": "sha512-N6yXcjfe/E+xKEccWEKzK6M+crMrlwaCepKja0pNnlSkm6SjAeLKKA++er5Ba0I17gvKfN/ThV+ZOx/CntKTVw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.1.0.tgz", + "integrity": "sha512-swXz2vMjrP1ZusZWVTB/ai5gK+J8U0BWvP10v9fpcFvg+Xi/87LHvHfst2IgCs1i0v4qFZfGwCmeD/KNCdJZbQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.1.5.tgz", + "integrity": "sha512-FGBhlmFZVSRto816l6IwrmDcQ9pUYX6ikdR1mmAhdtSS1m77FgADukbQg7F7gurXfAvloxE/pgsrb7SGja6FQA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.1.1", + "@smithy/smithy-client": "^4.6.5", + "@smithy/types": "^4.5.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.1.5.tgz", + "integrity": "sha512-Gwj8KLgJ/+MHYjVubJF0EELEh9/Ir7z7DFqyYlwgmp4J37KE+5vz6b3pWUnSt53tIe5FjDfVjDmHGYKjwIvW0Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.2.2", + "@smithy/credential-provider-imds": "^4.1.2", + "@smithy/node-config-provider": "^4.2.2", + "@smithy/property-provider": "^4.1.1", + "@smithy/smithy-client": "^4.6.5", + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.1.2.tgz", + "integrity": "sha512-+AJsaaEGb5ySvf1SKMRrPZdYHRYSzMkCoK16jWnIMpREAnflVspMIDeCVSZJuj+5muZfgGpNpijE3mUNtjv01Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.2.2", + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.1.0.tgz", + "integrity": "sha512-1LcueNN5GYC4tr8mo14yVYbh/Ur8jHhWOxniZXii+1+ePiIbsLZ5fEI0QQGtbRRP5mOhmooos+rLmVASGGoq5w==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.1.1.tgz", + "integrity": "sha512-CGmZ72mL29VMfESz7S6dekqzCh8ZISj3B+w0g1hZFXaOjGTVaSqfAEFAq8EGp8fUL+Q2l8aqNmt8U1tglTikeg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.1.2.tgz", + "integrity": "sha512-NCgr1d0/EdeP6U5PSZ9Uv5SMR5XRRYoVr1kRVtKZxWL3tixEL3UatrPIMFZSKwHlCcp2zPLDvMubVDULRqeunA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.1.2", + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.3.2.tgz", + "integrity": "sha512-Ka+FA2UCC/Q1dEqUanCdpqwxOFdf5Dg2VXtPtB1qxLcSGh5C1HdzklIt18xL504Wiy9nNUKwDMRTVCbKGoK69g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.2.1", + "@smithy/node-http-handler": "^4.2.1", + "@smithy/types": "^4.5.0", + "@smithy/util-base64": "^4.1.0", + "@smithy/util-buffer-from": "^4.1.0", + "@smithy/util-hex-encoding": "^4.1.0", + "@smithy/util-utf8": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.1.0.tgz", + "integrity": "sha512-b0EFQkq35K5NHUYxU72JuoheM6+pytEVUGlTwiFxWFpmddA+Bpz3LgsPRIpBk8lnPE47yT7AF2Egc3jVnKLuPg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.1.0.tgz", + "integrity": "sha512-mEu1/UIXAdNYuBcyEPbjScKi/+MQVXNIuY/7Cm5XLIWe319kDrT5SizBE95jqtmEXoDbGoZxKLCMttdZdqTZKQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.1.1.tgz", + "integrity": "sha512-PJBmyayrlfxM7nbqjomF4YcT1sApQwZio0NHSsT0EzhJqljRmvhzqZua43TyEs80nJk2Cn2FGPg/N8phH6KeCQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.1.1", + "@smithy/types": "^4.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.0.0.tgz", + "integrity": "sha512-OlA/yZHh0ekYFnbUkmYBDQPE6fGfdrvgz39ktp8Xf+FA6BfxLejPTMDOG0Nfk5/rDySAz1dRbFf24zaAFYVXlQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1846,6 +3075,12 @@ "node": ">=18" } }, + "node_modules/bowser": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.12.1.tgz", + "integrity": "sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -2991,6 +4226,24 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-parser": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", + "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -6841,6 +8094,18 @@ } } }, + "node_modules/strnum": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", + "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/superagent": { "version": "10.2.3", "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.3.tgz", @@ -7080,6 +8345,12 @@ "node": ">= 14.0.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/tsscmp": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", diff --git a/backend/package.json b/backend/package.json index c091d76..7cb551d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,6 +21,7 @@ "author": "", "license": "ISC", "dependencies": { + "@aws-sdk/client-ses": "^3.896.0", "@googlemaps/google-maps-services-js": "^3.4.2", "bcryptjs": "^3.0.2", "body-parser": "^2.2.0", diff --git a/backend/routes/conditionChecks.js b/backend/routes/conditionChecks.js new file mode 100644 index 0000000..5179be3 --- /dev/null +++ b/backend/routes/conditionChecks.js @@ -0,0 +1,165 @@ +const express = require("express"); +const multer = require("multer"); +const { authenticateToken } = require("../middleware/auth"); +const ConditionCheckService = require("../services/conditionCheckService"); +const logger = require("../utils/logger"); + +const router = express.Router(); + +// Configure multer for photo uploads +const upload = multer({ + dest: "uploads/condition-checks/", + limits: { + fileSize: 10 * 1024 * 1024, // 10MB limit + files: 20, // Maximum 20 files + }, + fileFilter: (req, file, cb) => { + // Accept only image files + if (file.mimetype.startsWith("image/")) { + cb(null, true); + } else { + cb(new Error("Only image files are allowed"), false); + } + }, +}); + +// Submit a condition check +router.post( + "/:rentalId", + authenticateToken, + upload.array("photos"), + async (req, res) => { + try { + const { rentalId } = req.params; + const { checkType, notes } = req.body; + const userId = req.user.id; + + // Get uploaded file paths + const photos = req.files ? req.files.map((file) => file.path) : []; + + // Extract metadata from request + const metadata = { + userAgent: req.get("User-Agent"), + ipAddress: req.ip, + deviceType: req.get("X-Device-Type") || "web", + }; + + const conditionCheck = await ConditionCheckService.submitConditionCheck( + rentalId, + checkType, + userId, + photos, + notes, + metadata + ); + + const reqLogger = logger.withRequestId(req.id); + reqLogger.info("Condition check submitted", { + rentalId, + checkType, + userId, + photoCount: photos.length, + }); + + res.status(201).json({ + success: true, + conditionCheck, + }); + } catch (error) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Error submitting condition check", { + error: error.message, + rentalId: req.params.rentalId, + userId: req.user?.id, + }); + + res.status(400).json({ + success: false, + error: error.message, + }); + } + } +); + +// Get condition checks for a rental +router.get("/:rentalId", authenticateToken, async (req, res) => { + try { + const { rentalId } = req.params; + + const conditionChecks = await ConditionCheckService.getConditionChecks( + rentalId + ); + + res.json({ + success: true, + conditionChecks, + }); + } catch (error) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Error fetching condition checks", { + error: error.message, + rentalId: req.params.rentalId, + }); + + res.status(500).json({ + success: false, + error: "Failed to fetch condition checks", + }); + } +}); + +// Get condition check timeline for a rental +router.get("/:rentalId/timeline", authenticateToken, async (req, res) => { + try { + const { rentalId } = req.params; + + const timeline = await ConditionCheckService.getConditionCheckTimeline( + rentalId + ); + + res.json({ + success: true, + timeline, + }); + } catch (error) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Error fetching condition check timeline", { + error: error.message, + rentalId: req.params.rentalId, + }); + + res.status(500).json({ + success: false, + error: error.message, + }); + } +}); + +// Get available condition checks for current user +router.get("/", authenticateToken, async (req, res) => { + try { + const userId = req.user.id; + + const availableChecks = await ConditionCheckService.getAvailableChecks( + userId + ); + + res.json({ + success: true, + availableChecks, + }); + } catch (error) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Error fetching available checks", { + error: error.message, + userId: req.user?.id, + }); + + res.status(500).json({ + success: false, + error: "Failed to fetch available checks", + }); + } +}); + +module.exports = router; diff --git a/backend/routes/rentals.js b/backend/routes/rentals.js index 0a5aaf1..199cb55 100644 --- a/backend/routes/rentals.js +++ b/backend/routes/rentals.js @@ -4,6 +4,9 @@ const { Rental, Item, User } = require("../models"); // Import from models/index const { authenticateToken } = require("../middleware/auth"); const FeeCalculator = require("../utils/feeCalculator"); const RefundService = require("../services/refundService"); +const LateReturnService = require("../services/lateReturnService"); +const DamageAssessmentService = require("../services/damageAssessmentService"); +const emailService = require("../services/emailService"); const logger = require("../utils/logger"); const router = express.Router(); @@ -72,7 +75,7 @@ router.get("/my-rentals", authenticateToken, async (req, res) => { reqLogger.error("Error in my-rentals route", { error: error.message, stack: error.stack, - userId: req.user.id + userId: req.user.id, }); res.status(500).json({ error: "Failed to fetch rentals" }); } @@ -100,7 +103,7 @@ router.get("/my-listings", authenticateToken, async (req, res) => { reqLogger.error("Error in my-listings route", { error: error.message, stack: error.stack, - userId: req.user.id + userId: req.user.id, }); res.status(500).json({ error: "Failed to fetch listings" }); } @@ -131,7 +134,9 @@ router.get("/:id", authenticateToken, async (req, res) => { // Check if user is authorized to view this rental if (rental.ownerId !== req.user.id && rental.renterId !== req.user.id) { - return res.status(403).json({ error: "Unauthorized to view this rental" }); + return res + .status(403) + .json({ error: "Unauthorized to view this rental" }); } res.json(rental); @@ -141,7 +146,7 @@ router.get("/:id", authenticateToken, async (req, res) => { error: error.message, stack: error.stack, rentalId: req.params.id, - userId: req.user.id + userId: req.user.id, }); res.status(500).json({ error: "Failed to fetch rental" }); } @@ -235,7 +240,9 @@ router.post("/", authenticateToken, async (req, res) => { // Validate that payment method was provided for paid rentals if (totalAmount > 0 && !stripePaymentMethodId) { - return res.status(400).json({ error: "Payment method is required for paid rentals" }); + return res + .status(400) + .json({ error: "Payment method is required for paid rentals" }); } const rentalData = { @@ -313,7 +320,9 @@ router.put("/:id/status", authenticateToken, async (req, res) => { } if (rental.ownerId !== req.user.id && rental.renterId !== req.user.id) { - return res.status(403).json({ error: "Unauthorized to update this rental" }); + return res + .status(403) + .json({ error: "Unauthorized to update this rental" }); } // If owner is approving a pending rental, handle payment for paid rentals @@ -330,73 +339,76 @@ router.put("/:id/status", authenticateToken, async (req, res) => { .json({ error: "No payment method found for this rental" }); } - try { - // Import StripeService to process the payment - const StripeService = require("../services/stripeService"); + try { + // Import StripeService to process the payment + const StripeService = require("../services/stripeService"); - // Check if renter has a stripe customer ID - if (!rental.renter.stripeCustomerId) { - return res - .status(400) - .json({ error: "Renter does not have a Stripe customer account" }); - } - - // Create payment intent and charge the stored payment method - const paymentResult = await StripeService.chargePaymentMethod( - rental.stripePaymentMethodId, - rental.totalAmount, - rental.renter.stripeCustomerId, - { - rentalId: rental.id, - itemName: rental.item.name, - renterId: rental.renterId, - ownerId: rental.ownerId, + // Check if renter has a stripe customer ID + if (!rental.renter.stripeCustomerId) { + return res.status(400).json({ + error: "Renter does not have a Stripe customer account", + }); } - ); - // Update rental with payment completion - await rental.update({ - status: "confirmed", - paymentStatus: "paid", - stripePaymentIntentId: paymentResult.paymentIntentId, - }); - - const updatedRental = await Rental.findByPk(rental.id, { - include: [ - { model: Item, as: "item" }, + // Create payment intent and charge the stored payment method + const paymentResult = await StripeService.chargePaymentMethod( + rental.stripePaymentMethodId, + rental.totalAmount, + rental.renter.stripeCustomerId, { - model: User, - as: "owner", - attributes: ["id", "username", "firstName", "lastName"], - }, - { - model: User, - as: "renter", - attributes: ["id", "username", "firstName", "lastName"], - }, - ], - }); + rentalId: rental.id, + itemName: rental.item.name, + renterId: rental.renterId, + ownerId: rental.ownerId, + } + ); - res.json(updatedRental); - return; - } catch (paymentError) { - const reqLogger = logger.withRequestId(req.id); - reqLogger.error("Payment failed during approval", { - error: paymentError.message, - stack: paymentError.stack, - rentalId: req.params.id, - userId: req.user.id - }); - // Keep rental as pending, but inform of payment failure - return res.status(400).json({ - error: "Payment failed during approval", - details: paymentError.message, - }); - } + // Update rental with payment completion + await rental.update({ + status: "confirmed", + paymentStatus: "paid", + stripePaymentIntentId: paymentResult.paymentIntentId, + }); + + const updatedRental = await Rental.findByPk(rental.id, { + include: [ + { model: Item, as: "item" }, + { + model: User, + as: "owner", + attributes: ["id", "username", "firstName", "lastName"], + }, + { + model: User, + as: "renter", + attributes: ["id", "username", "firstName", "lastName"], + }, + ], + }); + + // Send confirmation emails + await emailService.sendRentalConfirmationEmails(updatedRental); + + res.json(updatedRental); + return; + } catch (paymentError) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Payment failed during approval", { + error: paymentError.message, + stack: paymentError.stack, + rentalId: req.params.id, + userId: req.user.id, + }); + // Keep rental as pending, but inform of payment failure + return res.status(400).json({ + error: "Payment failed during approval", + details: paymentError.message, + }); + } } else { // For free rentals, just update status directly await rental.update({ - status: "confirmed" + status: "confirmed", }); const updatedRental = await Rental.findByPk(rental.id, { @@ -415,6 +427,9 @@ router.put("/:id/status", authenticateToken, async (req, res) => { ], }); + // Send confirmation emails + await emailService.sendRentalConfirmationEmails(updatedRental); + res.json(updatedRental); return; } @@ -601,7 +616,7 @@ router.post("/calculate-fees", authenticateToken, async (req, res) => { userId: req.user.id, startDate: req.query.startDate, endDate: req.query.endDate, - itemId: req.query.itemId + itemId: req.query.itemId, }); res.status(500).json({ error: "Failed to calculate fees" }); } @@ -634,7 +649,7 @@ router.get("/earnings/status", authenticateToken, async (req, res) => { reqLogger.error("Error getting earnings status", { error: error.message, stack: error.stack, - userId: req.user.id + userId: req.user.id, }); res.status(500).json({ error: error.message }); } @@ -654,7 +669,47 @@ router.get("/:id/refund-preview", authenticateToken, async (req, res) => { error: error.message, stack: error.stack, rentalId: req.params.id, - userId: req.user.id + userId: req.user.id, + }); + res.status(400).json({ error: error.message }); + } +}); + +// Get late fee preview +router.get("/:id/late-fee-preview", authenticateToken, async (req, res) => { + try { + const { actualReturnDateTime } = req.query; + + if (!actualReturnDateTime) { + return res.status(400).json({ error: "actualReturnDateTime is required" }); + } + + const rental = await Rental.findByPk(req.params.id, { + include: [{ model: Item, as: "item" }], + }); + + if (!rental) { + return res.status(404).json({ error: "Rental not found" }); + } + + // Check authorization + if (rental.ownerId !== req.user.id && rental.renterId !== req.user.id) { + return res.status(403).json({ error: "Unauthorized" }); + } + + const lateCalculation = LateReturnService.calculateLateFee( + rental, + actualReturnDateTime + ); + + res.json(lateCalculation); + } catch (error) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Error getting late fee preview", { + error: error.message, + stack: error.stack, + rentalId: req.params.id, + userId: req.user.id, }); res.status(400).json({ error: error.message }); } @@ -698,10 +753,174 @@ router.post("/:id/cancel", authenticateToken, async (req, res) => { error: error.message, stack: error.stack, rentalId: req.params.id, - userId: req.user.id + userId: req.user.id, }); res.status(400).json({ error: error.message }); } }); +// Mark item return status (owner only) +router.post("/:id/mark-return", authenticateToken, async (req, res) => { + try { + const { status, actualReturnDateTime, notes, statusOptions } = req.body; + const rentalId = req.params.id; + const userId = req.user.id; + + const rental = await Rental.findByPk(rentalId, { + include: [{ model: Item, as: "item" }], + }); + + if (!rental) { + return res.status(404).json({ error: "Rental not found" }); + } + + if (rental.ownerId !== userId) { + return res + .status(403) + .json({ error: "Only the item owner can mark return status" }); + } + + if (!["confirmed", "active"].includes(rental.status)) { + return res.status(400).json({ + error: "Can only mark return status for confirmed or active rentals", + }); + } + + let updatedRental; + let additionalInfo = {}; + + switch (status) { + case "returned": + // Item returned on time + updatedRental = await rental.update({ + status: "completed", + actualReturnDateTime: actualReturnDateTime || rental.endDateTime, + notes: notes || null, + }); + break; + + case "damaged": + // Item returned damaged + const damageUpdates = { + status: "damaged", + actualReturnDateTime: actualReturnDateTime || rental.endDateTime, + notes: notes || null, + }; + + // Check if ALSO returned late + if (statusOptions?.returned_late && actualReturnDateTime) { + const lateReturnDamaged = await LateReturnService.processLateReturn( + rentalId, + actualReturnDateTime, + notes + ); + damageUpdates.status = "returned_late_and_damaged"; + damageUpdates.lateFees = lateReturnDamaged.lateCalculation.lateFee; + damageUpdates.actualReturnDateTime = + lateReturnDamaged.rental.actualReturnDateTime; + additionalInfo.lateCalculation = lateReturnDamaged.lateCalculation; + } + + updatedRental = await rental.update(damageUpdates); + break; + + case "returned_late": + // Item returned late - calculate late fees + if (!actualReturnDateTime) { + return res.status(400).json({ + error: "Actual return date/time is required for late returns", + }); + } + + const lateReturn = await LateReturnService.processLateReturn( + rentalId, + actualReturnDateTime, + notes + ); + + updatedRental = lateReturn.rental; + additionalInfo.lateCalculation = lateReturn.lateCalculation; + break; + + case "lost": + // Item reported as lost + updatedRental = await rental.update({ + status: "lost", + itemLostReportedAt: new Date(), + notes: notes || null, + }); + + // Send notification to customer service + await emailService.sendLostItemToCustomerService(updatedRental); + break; + + default: + return res.status(400).json({ + error: + "Invalid status. Use 'returned', 'returned_late', 'damaged', or 'lost'", + }); + } + + const reqLogger = logger.withRequestId(req.id); + reqLogger.info("Return status marked", { + rentalId, + status, + ownerId: userId, + lateFee: updatedRental.lateFees || 0, + }); + + res.json({ + success: true, + rental: updatedRental, + ...additionalInfo, + }); + } catch (error) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Error marking return status", { + error: error.message, + rentalId: req.params.id, + userId: req.user.id, + }); + + res.status(400).json({ error: error.message }); + } +}); + +// Report item as damaged (owner only) +router.post("/:id/report-damage", authenticateToken, async (req, res) => { + try { + const rentalId = req.params.id; + const userId = req.user.id; + const damageInfo = req.body; + + const result = await DamageAssessmentService.processDamageAssessment( + rentalId, + damageInfo, + userId + ); + + const reqLogger = logger.withRequestId(req.id); + reqLogger.info("Damage reported", { + rentalId, + ownerId: userId, + damageFee: result.damageAssessment.feeCalculation.amount, + lateFee: result.lateCalculation?.lateFee || 0, + }); + + res.json({ + success: true, + ...result, + }); + } catch (error) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Error reporting damage", { + error: error.message, + rentalId: req.params.id, + userId: req.user.id, + }); + + res.status(400).json({ error: error.message }); + } +}); + module.exports = router; diff --git a/backend/server.js b/backend/server.js index 618465c..aa65c4b 100644 --- a/backend/server.js +++ b/backend/server.js @@ -23,8 +23,11 @@ const messageRoutes = require("./routes/messages"); const itemRequestRoutes = require("./routes/itemRequests"); const stripeRoutes = require("./routes/stripe"); const mapsRoutes = require("./routes/maps"); +const conditionCheckRoutes = require("./routes/conditionChecks"); const PayoutProcessor = require("./jobs/payoutProcessor"); +const RentalStatusJob = require("./jobs/rentalStatusJob"); +const ConditionCheckReminderJob = require("./jobs/conditionCheckReminder"); const app = express(); @@ -65,7 +68,7 @@ app.use( app.use(cookieParser); // HTTP request logging -app.use(morgan('combined', { stream: logger.stream })); +app.use(morgan("combined", { stream: logger.stream })); // API request/response logging app.use("/api/", apiLogger); @@ -111,6 +114,7 @@ app.use("/api/messages", messageRoutes); app.use("/api/item-requests", itemRequestRoutes); app.use("/api/stripe", stripeRoutes); app.use("/api/maps", mapsRoutes); +app.use("/api/condition-checks", conditionCheckRoutes); app.get("/", (req, res) => { res.json({ message: "CommunityRentals.App API is running!" }); @@ -131,10 +135,24 @@ sequelize const payoutJobs = PayoutProcessor.startScheduledPayouts(); logger.info("Payout processor started"); + // Start the rental status job + const rentalStatusJobs = RentalStatusJob.startScheduledStatusUpdates(); + logger.info("Rental status job started"); + + // Start the condition check reminder job + const conditionCheckJobs = ConditionCheckReminderJob.startScheduledReminders(); + logger.info("Condition check reminder job started"); + app.listen(PORT, () => { - logger.info(`Server is running on port ${PORT}`, { port: PORT, environment: env }); + logger.info(`Server is running on port ${PORT}`, { + port: PORT, + environment: env, + }); }); }) .catch((err) => { - logger.error("Unable to sync database", { error: err.message, stack: err.stack }); + logger.error("Unable to sync database", { + error: err.message, + stack: err.stack, + }); }); diff --git a/backend/services/conditionCheckService.js b/backend/services/conditionCheckService.js new file mode 100644 index 0000000..8d31a31 --- /dev/null +++ b/backend/services/conditionCheckService.js @@ -0,0 +1,363 @@ +const { ConditionCheck, Rental, User } = require("../models"); +const { Op } = require("sequelize"); + +class ConditionCheckService { + /** + * Validate if a condition check can be submitted + * @param {string} rentalId - Rental ID + * @param {string} checkType - Type of check (pre_rental_owner, rental_start_renter, etc.) + * @param {string} userId - User attempting to submit + * @returns {Object} - { canSubmit, reason, timeWindow } + */ + static async validateConditionCheck(rentalId, checkType, userId) { + const rental = await Rental.findByPk(rentalId); + + if (!rental) { + return { canSubmit: false, reason: "Rental not found" }; + } + + // Check user permissions + const isOwner = rental.ownerId === userId; + const isRenter = rental.renterId === userId; + + if (checkType.includes("owner") && !isOwner) { + return { + canSubmit: false, + reason: "Only the item owner can submit owner condition checks", + }; + } + + if (checkType.includes("renter") && !isRenter) { + return { + canSubmit: false, + reason: "Only the renter can submit renter condition checks", + }; + } + + // Check if already submitted + const existingCheck = await ConditionCheck.findOne({ + where: { rentalId, checkType }, + }); + + if (existingCheck) { + return { + canSubmit: false, + reason: "Condition check already submitted for this type", + }; + } + + // Check time windows (24 hour windows) + const now = new Date(); + const startDate = new Date(rental.startDateTime); + const endDate = new Date(rental.endDateTime); + const twentyFourHours = 24 * 60 * 60 * 1000; + + let timeWindow = {}; + let canSubmit = false; + + switch (checkType) { + case "pre_rental_owner": + // 24 hours before rental starts + timeWindow.start = new Date(startDate.getTime() - twentyFourHours); + timeWindow.end = startDate; + canSubmit = now >= timeWindow.start && now <= timeWindow.end; + break; + + case "rental_start_renter": + // 24 hours after rental starts + timeWindow.start = startDate; + timeWindow.end = new Date(startDate.getTime() + twentyFourHours); + canSubmit = + now >= timeWindow.start && + now <= timeWindow.end && + rental.status === "active"; + break; + + case "rental_end_renter": + // 24 hours before rental ends + timeWindow.start = new Date(endDate.getTime() - twentyFourHours); + timeWindow.end = endDate; + canSubmit = + now >= timeWindow.start && + now <= timeWindow.end && + rental.status === "active"; + break; + + case "post_rental_owner": + // Can be submitted anytime (integrated into return flow) + timeWindow.start = endDate; + timeWindow.end = null; // No time limit + canSubmit = true; // Always allowed when owner marks return + break; + + default: + return { canSubmit: false, reason: "Invalid check type" }; + } + + if (!canSubmit) { + const isBeforeWindow = now < timeWindow.start; + const isAfterWindow = now > timeWindow.end; + + let reason = "Outside of allowed time window"; + if (isBeforeWindow) { + reason = `Too early. Check can be submitted starting ${timeWindow.start.toLocaleString()}`; + } else if (isAfterWindow) { + reason = `Pre-Rental Condition can only be submitted before start of rental period`; + } + + return { canSubmit: false, reason, timeWindow }; + } + + return { canSubmit: true, timeWindow }; + } + + /** + * Submit a condition check with photos + * @param {string} rentalId - Rental ID + * @param {string} checkType - Type of check + * @param {string} userId - User submitting the check + * @param {Array} photos - Array of photo URLs + * @param {string} notes - Optional notes + * @param {Object} metadata - Additional metadata (device info, location, etc.) + * @returns {Object} - Created condition check + */ + static async submitConditionCheck( + rentalId, + checkType, + userId, + photos = [], + notes = null, + metadata = {} + ) { + // Validate the check + const validation = await this.validateConditionCheck( + rentalId, + checkType, + userId + ); + + if (!validation.canSubmit) { + throw new Error(validation.reason); + } + + // Validate photos (basic validation) + if (photos.length > 20) { + throw new Error("Maximum 20 photos allowed per condition check"); + } + + // Add timestamp and user agent to metadata + const enrichedMetadata = { + ...metadata, + submittedAt: new Date().toISOString(), + userAgent: metadata.userAgent || "Unknown", + ipAddress: metadata.ipAddress || "Unknown", + deviceType: metadata.deviceType || "Unknown", + }; + + const conditionCheck = await ConditionCheck.create({ + rentalId, + checkType, + submittedBy: userId, + photos, + notes, + metadata: enrichedMetadata, + }); + + return conditionCheck; + } + + /** + * Get all condition checks for a rental + * @param {string} rentalId - Rental ID + * @returns {Array} - Array of condition checks with user info + */ + static async getConditionChecks(rentalId) { + const checks = await ConditionCheck.findAll({ + where: { rentalId }, + include: [ + { + model: User, + as: "submittedByUser", + attributes: ["id", "username", "firstName", "lastName"], + }, + ], + order: [["submittedAt", "ASC"]], + }); + + return checks; + } + + /** + * Get condition check timeline for a rental + * @param {string} rentalId - Rental ID + * @returns {Object} - Timeline showing what checks are available/completed + */ + static async getConditionCheckTimeline(rentalId) { + const rental = await Rental.findByPk(rentalId); + if (!rental) { + throw new Error("Rental not found"); + } + + const existingChecks = await ConditionCheck.findAll({ + where: { rentalId }, + include: [ + { + model: User, + as: "submittedByUser", + attributes: ["id", "username", "firstName", "lastName"], + }, + ], + }); + + const checkTypes = [ + "pre_rental_owner", + "rental_start_renter", + "rental_end_renter", + "post_rental_owner", + ]; + + const timeline = {}; + + for (const checkType of checkTypes) { + const existingCheck = existingChecks.find( + (check) => check.checkType === checkType + ); + + if (existingCheck) { + timeline[checkType] = { + status: "completed", + submittedAt: existingCheck.submittedAt, + submittedBy: existingCheck.submittedBy, + photoCount: existingCheck.photos.length, + hasNotes: !!existingCheck.notes, + }; + } else { + // Calculate if this check type is available + const now = new Date(); + const startDate = new Date(rental.startDateTime); + const endDate = new Date(rental.endDateTime); + const twentyFourHours = 24 * 60 * 60 * 1000; + + let timeWindow = {}; + let status = "not_available"; + + switch (checkType) { + case "pre_rental_owner": + timeWindow.start = new Date(startDate.getTime() - twentyFourHours); + timeWindow.end = startDate; + break; + case "rental_start_renter": + timeWindow.start = startDate; + timeWindow.end = new Date(startDate.getTime() + twentyFourHours); + break; + case "rental_end_renter": + timeWindow.start = new Date(endDate.getTime() - twentyFourHours); + timeWindow.end = endDate; + break; + case "post_rental_owner": + timeWindow.start = endDate; + timeWindow.end = new Date(endDate.getTime() + twentyFourHours); + break; + } + + if (now >= timeWindow.start && now <= timeWindow.end) { + status = "available"; + } else if (now < timeWindow.start) { + status = "pending"; + } else { + status = "expired"; + } + + timeline[checkType] = { + status, + timeWindow, + availableFrom: timeWindow.start, + availableUntil: timeWindow.end, + }; + } + } + + return { + rental: { + id: rental.id, + startDateTime: rental.startDateTime, + endDateTime: rental.endDateTime, + status: rental.status, + }, + timeline, + }; + } + + /** + * Get available condition checks for a user + * @param {string} userId - User ID + * @returns {Array} - Array of available condition checks + */ + static async getAvailableChecks(userId) { + const now = new Date(); + const twentyFourHours = 24 * 60 * 60 * 1000; + + // Find rentals where user is owner or renter + const rentals = await Rental.findAll({ + where: { + [Op.or]: [{ ownerId: userId }, { renterId: userId }], + status: { + [Op.in]: ["confirmed", "active", "completed"], + }, + }, + }); + + const availableChecks = []; + + for (const rental of rentals) { + const isOwner = rental.ownerId === userId; + const isRenter = rental.renterId === userId; + const startDate = new Date(rental.startDateTime); + const endDate = new Date(rental.endDateTime); + + // Check each type of condition check + const checkTypes = []; + + if (isOwner) { + // Only include pre_rental_owner; post_rental is now part of return flow + checkTypes.push("pre_rental_owner"); + } + if (isRenter) { + checkTypes.push("rental_start_renter", "rental_end_renter"); + } + + for (const checkType of checkTypes) { + // Check if already submitted + const existing = await ConditionCheck.findOne({ + where: { rentalId: rental.id, checkType }, + }); + + if (!existing) { + const validation = await this.validateConditionCheck( + rental.id, + checkType, + userId + ); + + if (validation.canSubmit) { + availableChecks.push({ + rentalId: rental.id, + checkType, + rental: { + id: rental.id, + itemId: rental.itemId, + startDateTime: rental.startDateTime, + endDateTime: rental.endDateTime, + }, + timeWindow: validation.timeWindow, + }); + } + } + } + } + + return availableChecks; + } +} + +module.exports = ConditionCheckService; diff --git a/backend/services/damageAssessmentService.js b/backend/services/damageAssessmentService.js new file mode 100644 index 0000000..36899a0 --- /dev/null +++ b/backend/services/damageAssessmentService.js @@ -0,0 +1,138 @@ +const { Rental, Item, ConditionCheck } = require("../models"); +const LateReturnService = require("./lateReturnService"); +const emailService = require("./emailService"); + +class DamageAssessmentService { + /** + * Process damage assessment and calculate fees + * @param {string} rentalId - Rental ID + * @param {Object} damageInfo - Damage assessment information + * @param {string} userId - Owner reporting the damage + * @returns {Object} - Updated rental with damage fees + */ + static async processDamageAssessment(rentalId, damageInfo, userId) { + const { + description, + canBeFixed, + repairCost, + needsReplacement, + replacementCost, + proofOfOwnership, + actualReturnDateTime, + photos = [], + } = damageInfo; + + const rental = await Rental.findByPk(rentalId, { + include: [{ model: Item, as: "item" }], + }); + + if (!rental) { + throw new Error("Rental not found"); + } + + if (rental.ownerId !== userId) { + throw new Error("Only the item owner can report damage"); + } + + if (rental.status !== "active") { + throw new Error("Can only assess damage for active rentals"); + } + + // Validate required fields + if (!description || description.trim().length === 0) { + throw new Error("Damage description is required"); + } + + if (canBeFixed && (!repairCost || repairCost <= 0)) { + throw new Error("Repair cost is required when item can be fixed"); + } + + if (needsReplacement && (!replacementCost || replacementCost <= 0)) { + throw new Error( + "Replacement cost is required when item needs replacement" + ); + } + + // Calculate damage fees + let damageFees = 0; + let feeCalculation = {}; + + if (needsReplacement) { + // Full replacement cost + damageFees = parseFloat(replacementCost); + feeCalculation = { + type: "replacement", + amount: damageFees, + originalCost: replacementCost, + depreciation: 0, + }; + } else if (canBeFixed && repairCost > 0) { + // Repair cost + damageFees = parseFloat(repairCost); + feeCalculation = { + type: "repair", + amount: damageFees, + repairCost: repairCost, + }; + } + + // Process late return if applicable + let lateFees = 0; + let lateCalculation = null; + + if (actualReturnDateTime) { + const lateReturn = await LateReturnService.processLateReturn( + rentalId, + actualReturnDateTime, + `Item returned damaged: ${description}` + ); + lateFees = lateReturn.lateCalculation.lateFee; + lateCalculation = lateReturn.lateCalculation; + } + + // Create damage assessment record as metadata + const damageAssessment = { + description, + canBeFixed, + repairCost: canBeFixed ? parseFloat(repairCost) : null, + needsReplacement, + replacementCost: needsReplacement ? parseFloat(replacementCost) : null, + proofOfOwnership: proofOfOwnership || [], + photos, + assessedAt: new Date(), + assessedBy: userId, + feeCalculation, + }; + + // Update rental + const updates = { + status: "damaged", + damageFees: damageFees, + damageAssessment: damageAssessment, + }; + + // Add late fees if applicable + if (lateFees > 0) { + updates.lateFees = lateFees; + updates.actualReturnDateTime = new Date(actualReturnDateTime); + } + + const updatedRental = await rental.update(updates); + + // Send damage report to customer service for review + await emailService.sendDamageReportToCustomerService( + updatedRental, + damageAssessment, + lateCalculation + ); + + return { + rental: updatedRental, + damageAssessment, + lateCalculation, + totalAdditionalFees: damageFees + lateFees, + }; + } +} + +module.exports = DamageAssessmentService; diff --git a/backend/services/emailService.js b/backend/services/emailService.js new file mode 100644 index 0000000..4b3f103 --- /dev/null +++ b/backend/services/emailService.js @@ -0,0 +1,497 @@ +const { SESClient, SendEmailCommand } = require("@aws-sdk/client-ses"); +const fs = require("fs").promises; +const path = require("path"); +const { getAWSConfig } = require("../config/aws"); +const { User } = require("../models"); + +class EmailService { + constructor() { + this.sesClient = null; + this.initialized = false; + this.templates = new Map(); + } + + async initialize() { + if (this.initialized) return; + + try { + // Use centralized AWS configuration with credential profiles + const awsConfig = getAWSConfig(); + this.sesClient = new SESClient(awsConfig); + + await this.loadEmailTemplates(); + this.initialized = true; + console.log("SES Email Service initialized successfully"); + } catch (error) { + console.error("Failed to initialize SES Email Service:", error); + throw error; + } + } + + async loadEmailTemplates() { + const templatesDir = path.join(__dirname, "..", "templates", "emails"); + + try { + const templateFiles = [ + "conditionCheckReminder.html", + "rentalConfirmation.html", + "lateReturnCS.html", + "damageReportCS.html", + "lostItemCS.html", + ]; + + for (const templateFile of templateFiles) { + try { + const templatePath = path.join(templatesDir, templateFile); + const templateContent = await fs.readFile(templatePath, "utf-8"); + const templateName = path.basename(templateFile, ".html"); + this.templates.set(templateName, templateContent); + } catch (error) { + console.warn(`Template ${templateFile} not found, will use fallback`); + } + } + + console.log(`Loaded ${this.templates.size} email templates`); + } catch (error) { + console.warn("Templates directory not found, using fallback templates"); + } + } + + async sendEmail(to, subject, htmlContent, textContent = null) { + if (!this.initialized) { + await this.initialize(); + } + + if (!process.env.EMAIL_ENABLED || process.env.EMAIL_ENABLED !== "true") { + console.log("Email sending disabled in environment"); + return { success: true, messageId: "disabled" }; + } + + const params = { + Source: process.env.SES_FROM_EMAIL, + Destination: { + ToAddresses: Array.isArray(to) ? to : [to], + }, + Message: { + Subject: { + Data: subject, + Charset: "UTF-8", + }, + Body: { + Html: { + Data: htmlContent, + Charset: "UTF-8", + }, + }, + }, + }; + + if (textContent) { + params.Message.Body.Text = { + Data: textContent, + Charset: "UTF-8", + }; + } + + if (process.env.SES_REPLY_TO_EMAIL) { + params.ReplyToAddresses = [process.env.SES_REPLY_TO_EMAIL]; + } + + try { + const command = new SendEmailCommand(params); + const result = await this.sesClient.send(command); + + console.log( + `Email sent successfully to ${to}, MessageId: ${result.MessageId}` + ); + return { success: true, messageId: result.MessageId }; + } catch (error) { + console.error("Failed to send email:", error); + return { success: false, error: error.message }; + } + } + + renderTemplate(templateName, variables = {}) { + let template = this.templates.get(templateName); + + if (!template) { + template = this.getFallbackTemplate(templateName); + } + + let rendered = template; + + Object.keys(variables).forEach((key) => { + const regex = new RegExp(`{{${key}}}`, "g"); + rendered = rendered.replace(regex, variables[key] || ""); + }); + + return rendered; + } + + getFallbackTemplate(templateName) { + const baseTemplate = ` + + +
+ + +{{message}}
+Rental Item: {{itemName}}
+Deadline: {{deadline}}
+Please complete this condition check as soon as possible to ensure proper documentation.
+ ` + ), + + rentalConfirmation: baseTemplate.replace( + "{{content}}", + ` +{{message}}
+Item: {{itemName}}
+Rental Period: {{startDate}} to {{endDate}}
+Thank you for using RentAll!
+ ` + ), + + damageClaimNotification: baseTemplate.replace( + "{{content}}", + ` +{{message}}
+Item: {{itemName}}
+Claim Amount: ${{ claimAmount }}
+Description: {{description}}
+Please review this claim and respond accordingly through your account.
+ ` + ), + + returnIssueNotification: baseTemplate.replace( + "{{content}}", + ` +{{message}}
+Item: {{itemName}}
+Return Status: {{returnStatus}}
+Please check your account for more details and take appropriate action.
+ ` + ), + }; + + return ( + templates[templateName] || + baseTemplate.replace( + "{{content}}", + ` +{{message}}
+ ` + ) + ); + } + + async sendConditionCheckReminder(userEmail, notification, rental) { + const variables = { + title: notification.title, + message: notification.message, + itemName: rental?.item?.name || "Unknown Item", + deadline: notification.metadata?.deadline + ? new Date(notification.metadata.deadline).toLocaleDateString() + : "Not specified", + }; + + const htmlContent = this.renderTemplate( + "conditionCheckReminder", + variables + ); + + return await this.sendEmail( + userEmail, + `RentAll: ${notification.title}`, + htmlContent + ); + } + + async sendRentalConfirmation(userEmail, notification, rental) { + const variables = { + title: notification.title, + message: notification.message, + itemName: rental?.item?.name || "Unknown Item", + startDate: rental?.startDateTime + ? new Date(rental.startDateTime).toLocaleDateString() + : "Not specified", + endDate: rental?.endDateTime + ? new Date(rental.endDateTime).toLocaleDateString() + : "Not specified", + }; + + const htmlContent = this.renderTemplate("rentalConfirmation", variables); + + return await this.sendEmail( + userEmail, + `RentAll: ${notification.title}`, + htmlContent + ); + } + + async sendTemplateEmail(toEmail, subject, templateName, variables = {}) { + const htmlContent = this.renderTemplate(templateName, variables); + return await this.sendEmail(toEmail, subject, htmlContent); + } + + async sendLateReturnToCustomerService(rental, lateCalculation) { + try { + // Get owner and renter details + const owner = await User.findByPk(rental.ownerId); + const renter = await User.findByPk(rental.renterId); + + if (!owner || !renter) { + console.error("Owner or renter not found for late return notification"); + return; + } + + // Format dates + const scheduledEnd = new Date(rental.endDateTime).toLocaleString(); + const actualReturn = new Date( + rental.actualReturnDateTime + ).toLocaleString(); + + // Send email to customer service + await this.sendTemplateEmail( + process.env.CUSTOMER_SUPPORT_EMAIL, + "Late Return Detected - Action Required", + "lateReturnCS", + { + rentalId: rental.id, + itemName: rental.item.name, + ownerName: owner.name, + ownerEmail: owner.email, + renterName: renter.name, + renterEmail: renter.email, + scheduledEnd, + actualReturn, + hoursLate: lateCalculation.lateHours.toFixed(1), + lateFee: lateCalculation.lateFee.toFixed(2), + } + ); + + console.log( + `Late return notification sent to customer service for rental ${rental.id}` + ); + } catch (error) { + console.error( + "Failed to send late return notification to customer service:", + error + ); + } + } + + async sendDamageReportToCustomerService( + rental, + damageAssessment, + lateCalculation = null + ) { + try { + // Get owner and renter details + const owner = await User.findByPk(rental.ownerId); + const renter = await User.findByPk(rental.renterId); + + if (!owner || !renter) { + console.error( + "Owner or renter not found for damage report notification" + ); + return; + } + + // Calculate total fees + const damageFee = damageAssessment.feeCalculation.amount; + const lateFee = lateCalculation?.lateFee || 0; + const totalFees = damageFee + lateFee; + + // Determine fee type description + let feeTypeDescription = ""; + if (damageAssessment.feeCalculation.type === "repair") { + feeTypeDescription = "Repair Cost"; + } else if (damageAssessment.feeCalculation.type === "replacement") { + feeTypeDescription = "Replacement Cost"; + } else { + feeTypeDescription = "Damage Assessment Fee"; + } + + // Send email to customer service + await this.sendTemplateEmail( + process.env.CUSTOMER_SUPPORT_EMAIL, + "Damage Report Filed - Action Required", + "damageReportCS", + { + rentalId: rental.id, + itemName: rental.item.name, + ownerName: `${owner.firstName} ${owner.lastName}`, + ownerEmail: owner.email, + renterName: `${renter.firstName} ${renter.lastName}`, + renterEmail: renter.email, + damageDescription: damageAssessment.description, + canBeFixed: damageAssessment.canBeFixed ? "Yes" : "No", + repairCost: damageAssessment.repairCost + ? damageAssessment.repairCost.toFixed(2) + : "N/A", + needsReplacement: damageAssessment.needsReplacement ? "Yes" : "No", + replacementCost: damageAssessment.replacementCost + ? damageAssessment.replacementCost.toFixed(2) + : "N/A", + feeTypeDescription, + damageFee: damageFee.toFixed(2), + lateFee: lateFee.toFixed(2), + totalFees: totalFees.toFixed(2), + hasProofOfOwnership: + damageAssessment.proofOfOwnership && + damageAssessment.proofOfOwnership.length > 0 + ? "Yes" + : "No", + } + ); + + console.log( + `Damage report notification sent to customer service for rental ${rental.id}` + ); + } catch (error) { + console.error( + "Failed to send damage report notification to customer service:", + error + ); + } + } + + async sendLostItemToCustomerService(rental) { + try { + // Get owner and renter details + const owner = await User.findByPk(rental.ownerId); + const renter = await User.findByPk(rental.renterId); + + if (!owner || !renter) { + console.error("Owner or renter not found for lost item notification"); + return; + } + + // Format dates + const reportedAt = new Date(rental.itemLostReportedAt).toLocaleString(); + const scheduledReturnDate = new Date(rental.endDateTime).toLocaleString(); + + // Send email to customer service + await this.sendTemplateEmail( + process.env.CUSTOMER_SUPPORT_EMAIL, + "Lost Item Claim Filed - Action Required", + "lostItemCS", + { + rentalId: rental.id, + itemName: rental.item.name, + ownerName: `${owner.firstName} ${owner.lastName}`, + ownerEmail: owner.email, + renterName: `${renter.firstName} ${renter.lastName}`, + renterEmail: renter.email, + reportedAt, + scheduledReturnDate, + replacementCost: parseFloat(rental.item.replacementCost).toFixed(2), + } + ); + + console.log( + `Lost item notification sent to customer service for rental ${rental.id}` + ); + } catch (error) { + console.error( + "Failed to send lost item notification to customer service:", + error + ); + } + } + + async sendRentalConfirmationEmails(rental) { + try { + // Get owner and renter emails + const owner = await User.findByPk(rental.ownerId, { + attributes: ["email"], + }); + const renter = await User.findByPk(rental.renterId, { + attributes: ["email"], + }); + + // Create notification data for owner + const ownerNotification = { + type: "rental_confirmed", + title: "Rental Confirmed", + message: `Your "${rental.item.name}" has been confirmed for rental.`, + rentalId: rental.id, + userId: rental.ownerId, + metadata: { rentalStart: rental.startDateTime }, + }; + + // Create notification data for renter + const renterNotification = { + type: "rental_confirmed", + title: "Rental Confirmed", + message: `Your rental of "${rental.item.name}" has been confirmed.`, + rentalId: rental.id, + userId: rental.renterId, + metadata: { rentalStart: rental.startDateTime }, + }; + + // Send email to owner + if (owner?.email) { + await this.sendRentalConfirmation( + owner.email, + ownerNotification, + rental + ); + console.log(`Rental confirmation email sent to owner: ${owner.email}`); + } + + // Send email to renter + if (renter?.email) { + await this.sendRentalConfirmation( + renter.email, + renterNotification, + rental + ); + console.log( + `Rental confirmation email sent to renter: ${renter.email}` + ); + } + } catch (error) { + console.error("Error sending rental confirmation emails:", error); + } + } +} + +module.exports = new EmailService(); diff --git a/backend/services/lateReturnService.js b/backend/services/lateReturnService.js new file mode 100644 index 0000000..d51bee3 --- /dev/null +++ b/backend/services/lateReturnService.js @@ -0,0 +1,113 @@ +const { Rental, Item } = require("../models"); +const emailService = require("./emailService"); + +class LateReturnService { + /** + * Calculate late fees based on actual return time vs scheduled end time + * @param {Object} rental - Rental instance with populated item data + * @param {Date} actualReturnDateTime - When the item was actually returned + * @returns {Object} - { lateHours, lateFee, isLate } + */ + static calculateLateFee(rental, actualReturnDateTime) { + const scheduledEnd = new Date(rental.endDateTime); + const actualReturn = new Date(actualReturnDateTime); + + // Calculate hours late + const hoursLate = (actualReturn - scheduledEnd) / (1000 * 60 * 60); + + if (hoursLate <= 0) { + return { + lateHours: 0, + lateFee: 0.0, + isLate: false, + }; + } + + let lateFee = 0; + let pricingType = "daily"; + + // Check if item has hourly or daily pricing + if (rental.item?.pricePerHour && rental.item.pricePerHour > 0) { + // Hourly pricing - charge per hour late + lateFee = hoursLate * parseFloat(rental.item.pricePerHour); + pricingType = "hourly"; + } else if (rental.item?.pricePerDay && rental.item.pricePerDay > 0) { + // Daily pricing - charge per day late (rounded up) + const billableDays = Math.ceil(hoursLate / 24); + lateFee = billableDays * parseFloat(rental.item.pricePerDay); + pricingType = "daily"; + } else { + // Free borrows: determine pricing type based on rental duration + const rentalStart = new Date(rental.startDateTime); + const rentalEnd = new Date(rental.endDateTime); + const rentalDurationHours = (rentalEnd - rentalStart) / (1000 * 60 * 60); + + if (rentalDurationHours <= 24) { + // Hourly rental - charge $10 per hour late + lateFee = hoursLate * 10.0; + pricingType = "hourly"; + } else { + // Daily rental - charge $10 per day late + const billableDays = Math.ceil(hoursLate / 24); + lateFee = billableDays * 10.0; + pricingType = "daily"; + } + } + + return { + lateHours: hoursLate, + lateFee: parseFloat(lateFee.toFixed(2)), + isLate: true, + pricingType, + }; + } + + /** + * Process late return and update rental with fees + * @param {string} rentalId - Rental ID + * @param {Date} actualReturnDateTime - When item was returned + * @param {string} notes - Optional notes about the return + * @returns {Object} - Updated rental with late fee information + */ + static async processLateReturn(rentalId, actualReturnDateTime, notes = null) { + const rental = await Rental.findByPk(rentalId, { + include: [{ model: Item, as: "item" }], + }); + + if (!rental) { + throw new Error("Rental not found"); + } + + if (rental.status !== "active") { + throw new Error("Can only process late returns for active rentals"); + } + + const lateCalculation = this.calculateLateFee(rental, actualReturnDateTime); + + const updates = { + actualReturnDateTime: new Date(actualReturnDateTime), + status: lateCalculation.isLate ? "returned_late" : "completed", + }; + + if (notes) { + updates.notes = notes; + } + + const updatedRental = await rental.update(updates); + + // Send notification to customer service if late return detected + if (lateCalculation.isLate && lateCalculation.lateFee > 0) { + await emailService.sendLateReturnToCustomerService( + updatedRental, + lateCalculation + ); + } + + return { + rental: updatedRental, + lateCalculation, + }; + } +} + +module.exports = LateReturnService; diff --git a/backend/services/stripeService.js b/backend/services/stripeService.js index ce46b6a..b68419f 100644 --- a/backend/services/stripeService.js +++ b/backend/services/stripeService.js @@ -162,6 +162,9 @@ class StripeService { mode: 'setup', ui_mode: 'embedded', redirect_on_completion: 'never', + setup_intent_data: { + usage: 'off_session' + }, metadata: { type: 'payment_method_setup', ...metadata diff --git a/backend/templates/emails/conditionCheckReminder.html b/backend/templates/emails/conditionCheckReminder.html new file mode 100644 index 0000000..423de72 --- /dev/null +++ b/backend/templates/emails/conditionCheckReminder.html @@ -0,0 +1,241 @@ + + + + + + +{{message}}
+ +Rental Item: {{itemName}}
+Deadline: {{deadline}}
+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.
+ +Important: Please complete this condition check as soon as possible. Missing this deadline may affect dispute resolution if issues arise.
+If you have any questions about the condition check process, please don't hesitate to contact our support team.
+Hello Customer Service Team,
+ +A damage report has been filed by an item owner and requires review and processing:
+ +Rental ID: {{rentalId}}
+Item: {{itemName}}
+Owner: {{ownerName}} ({{ownerEmail}})
+Renter: {{renterName}} ({{renterEmail}})
+Description:
+{{damageDescription}}
+ +Can item be fixed? {{canBeFixed}}
+ {{#if repairCost}} +Repair Cost: ${{repairCost}}
+ {{/if}} + +Needs replacement? {{needsReplacement}}
+ {{#if replacementCost}} +Replacement Cost: ${{replacementCost}}
+ {{/if}} + +Proof of Ownership Provided: {{hasProofOfOwnership}}
+{{feeTypeDescription}}: ${{damageFee}}
+ {{#if lateFee}} +Late Return Fee: ${{lateFee}}
+ {{/if}} +Total Additional Fees: ${{totalFees}}
+Please follow this process:
+ +Note: The damage fees have NOT been automatically charged. Manual review and processing is required.
+Thank you for your attention to this matter.
diff --git a/backend/templates/emails/lateReturnCS.html b/backend/templates/emails/lateReturnCS.html new file mode 100644 index 0000000..ebb78ba --- /dev/null +++ b/backend/templates/emails/lateReturnCS.html @@ -0,0 +1,39 @@ +Hello Customer Service Team,
+ +A late return has been reported and requires manual processing:
+ +Rental ID: {{rentalId}}
+Item: {{itemName}}
+Owner: {{ownerName}} ({{ownerEmail}})
+Renter: {{renterName}} ({{renterEmail}})
+Scheduled End: {{scheduledEnd}}
+Actual Return: {{actualReturn}}
+Hours Late: {{hoursLate}}
+Calculated Late Fee: ${{lateFee}}
+Please follow this process:
+ +Note: The late fee has NOT been automatically charged. Manual processing is required.
+Thank you for your attention to this matter.
diff --git a/backend/templates/emails/lostItemCS.html b/backend/templates/emails/lostItemCS.html new file mode 100644 index 0000000..1868384 --- /dev/null +++ b/backend/templates/emails/lostItemCS.html @@ -0,0 +1,40 @@ +Hello Customer Service Team,
+ +A lost item claim has been filed by an item owner and requires review and processing:
+ +Rental ID: {{rentalId}}
+Item: {{itemName}}
+Owner: {{ownerName}} ({{ownerEmail}})
+Renter: {{renterName}} ({{renterEmail}})
+Reported Lost At: {{reportedAt}}
+Scheduled Return Date: {{scheduledReturnDate}}
+Replacement Cost: ${{replacementCost}}
+Please follow this process:
+ +Note: The replacement fee has NOT been automatically charged. Manual review and processing is required.
+Thank you for your attention to this matter.
diff --git a/backend/templates/emails/rentalConfirmation.html b/backend/templates/emails/rentalConfirmation.html new file mode 100644 index 0000000..9505fed --- /dev/null +++ b/backend/templates/emails/rentalConfirmation.html @@ -0,0 +1,281 @@ + + + + + + +{{message}}
+ +Great news! Your rental has been successfully confirmed and you're all set.
+| Item | +{{itemName}} | +
|---|---|
| Start Date | +{{startDate}} | +
| End Date | +{{endDate}} | +
Important reminders:
+Thank you for choosing RentAll! We hope you have a great rental experience.
+{typeInfo.description}
++ Item: {rental.item?.name} +
++ Renter: {rental.renter?.firstName}{" "} + {rental.renter?.lastName} +
+
+ Scheduled End:
+
+ {new Date(rental.endDateTime).toLocaleString()}
+
Rental Period:
Start:{" "}
- {new Date(rental.startDateTime).toLocaleString()}
+ {formatDateTime(rental.startDateTime)}
- End:{" "}
- {new Date(rental.endDateTime).toLocaleString()}
+ End: {formatDateTime(rental.endDateTime)}
@@ -237,26 +328,70 @@ const MyRentals: React.FC = () => { > )} -