Compare commits

...

6 Commits

Author SHA1 Message Date
jackiettran
f3a356d64b test migration script 2025-11-25 21:35:09 -05:00
jackiettran
9ec3e97d9e remove sync alter true, add pending migration check 2025-11-25 17:53:49 -05:00
jackiettran
8fc269c62a migration files 2025-11-25 17:24:34 -05:00
jackiettran
31d94b1b3f simplified message model 2025-11-25 17:22:57 -05:00
jackiettran
2983f67ce8 removed metadata from condition check model 2025-11-25 16:48:54 -05:00
jackiettran
8de814fdee replaced vague notes with specific intended use, also fixed modal on top of modal for reviews 2025-11-25 16:40:42 -05:00
42 changed files with 1108 additions and 592 deletions

View File

@@ -0,0 +1,210 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable("Rentals", {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
itemId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "Items",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "CASCADE",
},
renterId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "Users",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "CASCADE",
},
ownerId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "Users",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "CASCADE",
},
startDateTime: {
type: Sequelize.DATE,
allowNull: false,
},
endDateTime: {
type: Sequelize.DATE,
allowNull: false,
},
totalAmount: {
type: Sequelize.DECIMAL(10, 2),
allowNull: false,
},
platformFee: {
type: Sequelize.DECIMAL(10, 2),
allowNull: false,
},
payoutAmount: {
type: Sequelize.DECIMAL(10, 2),
allowNull: false,
},
status: {
type: Sequelize.ENUM(
"pending",
"confirmed",
"declined",
"active",
"completed",
"cancelled",
"returned_late",
"returned_late_and_damaged",
"damaged",
"lost"
),
allowNull: false,
},
paymentStatus: {
type: Sequelize.ENUM("pending", "paid", "refunded", "not_required"),
allowNull: false,
},
payoutStatus: {
type: Sequelize.ENUM("pending", "completed", "failed"),
allowNull: true,
},
payoutProcessedAt: {
type: Sequelize.DATE,
},
stripeTransferId: {
type: Sequelize.STRING,
},
refundAmount: {
type: Sequelize.DECIMAL(10, 2),
},
refundProcessedAt: {
type: Sequelize.DATE,
},
refundReason: {
type: Sequelize.TEXT,
},
stripeRefundId: {
type: Sequelize.STRING,
},
cancelledBy: {
type: Sequelize.ENUM("renter", "owner"),
},
cancelledAt: {
type: Sequelize.DATE,
},
declineReason: {
type: Sequelize.TEXT,
},
stripePaymentMethodId: {
type: Sequelize.STRING,
},
stripePaymentIntentId: {
type: Sequelize.STRING,
},
paymentMethodBrand: {
type: Sequelize.STRING,
},
paymentMethodLast4: {
type: Sequelize.STRING,
},
chargedAt: {
type: Sequelize.DATE,
},
deliveryMethod: {
type: Sequelize.ENUM("pickup", "delivery"),
defaultValue: "pickup",
},
deliveryAddress: {
type: Sequelize.TEXT,
},
intendedUse: {
type: Sequelize.TEXT,
},
itemRating: {
type: Sequelize.INTEGER,
},
itemReview: {
type: Sequelize.TEXT,
},
itemReviewSubmittedAt: {
type: Sequelize.DATE,
},
itemReviewVisible: {
type: Sequelize.BOOLEAN,
defaultValue: false,
},
renterRating: {
type: Sequelize.INTEGER,
},
renterReview: {
type: Sequelize.TEXT,
},
renterReviewSubmittedAt: {
type: Sequelize.DATE,
},
renterReviewVisible: {
type: Sequelize.BOOLEAN,
defaultValue: false,
},
itemPrivateMessage: {
type: Sequelize.TEXT,
},
renterPrivateMessage: {
type: Sequelize.TEXT,
},
actualReturnDateTime: {
type: Sequelize.DATE,
},
lateFees: {
type: Sequelize.DECIMAL(10, 2),
defaultValue: 0.0,
},
damageFees: {
type: Sequelize.DECIMAL(10, 2),
defaultValue: 0.0,
},
replacementFees: {
type: Sequelize.DECIMAL(10, 2),
defaultValue: 0.0,
},
itemLostReportedAt: {
type: Sequelize.DATE,
},
damageAssessment: {
type: Sequelize.JSONB,
defaultValue: {},
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
// Add indexes
await queryInterface.addIndex("Rentals", ["itemId"]);
await queryInterface.addIndex("Rentals", ["renterId"]);
await queryInterface.addIndex("Rentals", ["ownerId"]);
await queryInterface.addIndex("Rentals", ["status"]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable("Rentals");
},
};

View File

@@ -0,0 +1,70 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable("ConditionChecks", {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
rentalId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "Rentals",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "CASCADE",
},
submittedBy: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "Users",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "CASCADE",
},
checkType: {
type: Sequelize.ENUM(
"pre_rental_owner",
"rental_start_renter",
"rental_end_renter",
"post_rental_owner"
),
allowNull: false,
},
photos: {
type: Sequelize.ARRAY(Sequelize.STRING),
defaultValue: [],
},
notes: {
type: Sequelize.TEXT,
},
submittedAt: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.NOW,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
// Add indexes
await queryInterface.addIndex("ConditionChecks", ["rentalId"]);
await queryInterface.addIndex("ConditionChecks", ["submittedBy"]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable("ConditionChecks");
},
};

View File

@@ -0,0 +1,61 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable("Messages", {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
senderId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "Users",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "CASCADE",
},
receiverId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "Users",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "CASCADE",
},
content: {
type: Sequelize.TEXT,
allowNull: false,
},
isRead: {
type: Sequelize.BOOLEAN,
defaultValue: false,
},
imagePath: {
type: Sequelize.STRING,
allowNull: true,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
// Add indexes
await queryInterface.addIndex("Messages", ["senderId"]);
await queryInterface.addIndex("Messages", ["receiverId"]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable("Messages");
},
};

View File

@@ -0,0 +1,129 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable("ForumPosts", {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
authorId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "Users",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "CASCADE",
},
title: {
type: Sequelize.STRING,
allowNull: false,
},
content: {
type: Sequelize.TEXT,
allowNull: false,
},
category: {
type: Sequelize.ENUM(
"item_request",
"technical_support",
"community_resources",
"general_discussion"
),
allowNull: false,
defaultValue: "general_discussion",
},
status: {
type: Sequelize.ENUM("open", "answered", "closed"),
defaultValue: "open",
},
viewCount: {
type: Sequelize.INTEGER,
defaultValue: 0,
},
commentCount: {
type: Sequelize.INTEGER,
defaultValue: 0,
},
zipCode: {
type: Sequelize.STRING,
},
latitude: {
type: Sequelize.DECIMAL(10, 8),
},
longitude: {
type: Sequelize.DECIMAL(11, 8),
},
acceptedAnswerId: {
type: Sequelize.UUID,
allowNull: true,
},
images: {
type: Sequelize.ARRAY(Sequelize.TEXT),
allowNull: true,
defaultValue: [],
},
isPinned: {
type: Sequelize.BOOLEAN,
defaultValue: false,
},
isDeleted: {
type: Sequelize.BOOLEAN,
defaultValue: false,
},
deletedBy: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: "Users",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "SET NULL",
},
deletedAt: {
type: Sequelize.DATE,
allowNull: true,
},
deletionReason: {
type: Sequelize.TEXT,
allowNull: true,
},
closedBy: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: "Users",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "SET NULL",
},
closedAt: {
type: Sequelize.DATE,
allowNull: true,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
// Add indexes
await queryInterface.addIndex("ForumPosts", ["authorId"]);
await queryInterface.addIndex("ForumPosts", ["category"]);
await queryInterface.addIndex("ForumPosts", ["status"]);
await queryInterface.addIndex("ForumPosts", ["isDeleted"]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable("ForumPosts");
},
};

View File

@@ -0,0 +1,92 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable("ForumComments", {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
postId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "ForumPosts",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "CASCADE",
},
authorId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "Users",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "CASCADE",
},
parentCommentId: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: "ForumComments",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "SET NULL",
},
content: {
type: Sequelize.TEXT,
allowNull: false,
},
images: {
type: Sequelize.ARRAY(Sequelize.TEXT),
allowNull: true,
defaultValue: [],
},
isDeleted: {
type: Sequelize.BOOLEAN,
defaultValue: false,
},
deletedBy: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: "Users",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "SET NULL",
},
deletedAt: {
type: Sequelize.DATE,
allowNull: true,
},
deletionReason: {
type: Sequelize.TEXT,
allowNull: true,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
// Add indexes
await queryInterface.addIndex("ForumComments", ["postId"]);
await queryInterface.addIndex("ForumComments", ["authorId"]);
await queryInterface.addIndex("ForumComments", ["parentCommentId"]);
await queryInterface.addIndex("ForumComments", ["isDeleted"]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable("ForumComments");
},
};

View File

@@ -0,0 +1,25 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
// Add foreign key constraint for acceptedAnswerId
await queryInterface.addConstraint("ForumPosts", {
fields: ["acceptedAnswerId"],
type: "foreign key",
name: "ForumPosts_acceptedAnswerId_fkey",
references: {
table: "ForumComments",
field: "id",
},
onDelete: "SET NULL",
onUpdate: "CASCADE",
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeConstraint(
"ForumPosts",
"ForumPosts_acceptedAnswerId_fkey"
);
},
};

View File

@@ -0,0 +1,43 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable("PostTags", {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
postId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "ForumPosts",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "CASCADE",
},
tagName: {
type: Sequelize.STRING,
allowNull: false,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
// Add indexes
await queryInterface.addIndex("PostTags", ["postId"]);
await queryInterface.addIndex("PostTags", ["tagName"]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable("PostTags");
},
};

View File

@@ -0,0 +1,50 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable("Feedbacks", {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
userId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "Users",
key: "id",
},
onUpdate: "CASCADE",
onDelete: "CASCADE",
},
feedbackText: {
type: Sequelize.TEXT,
allowNull: false,
},
userAgent: {
type: Sequelize.STRING,
allowNull: true,
},
url: {
type: Sequelize.STRING(500),
allowNull: true,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
// Add indexes
await queryInterface.addIndex("Feedbacks", ["userId"]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable("Feedbacks");
},
};

View File

@@ -44,10 +44,6 @@ const ConditionCheck = sequelize.define("ConditionCheck", {
allowNull: false,
defaultValue: DataTypes.NOW,
},
metadata: {
type: DataTypes.JSONB,
defaultValue: {},
},
});
module.exports = ConditionCheck;

View File

@@ -23,10 +23,6 @@ const Message = sequelize.define('Message', {
key: 'id'
}
},
subject: {
type: DataTypes.STRING,
allowNull: false
},
content: {
type: DataTypes.TEXT,
allowNull: false
@@ -35,14 +31,6 @@ const Message = sequelize.define('Message', {
type: DataTypes.BOOLEAN,
defaultValue: false
},
parentMessageId: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'Messages',
key: 'id'
}
},
imagePath: {
type: DataTypes.STRING,
allowNull: true

View File

@@ -124,7 +124,7 @@ const Rental = sequelize.define("Rental", {
deliveryAddress: {
type: DataTypes.TEXT,
},
notes: {
intendedUse: {
type: DataTypes.TEXT,
},
// Renter's review of the item (existing fields renamed for clarity)

View File

@@ -27,11 +27,6 @@ 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",
});
// Forum associations
User.hasMany(ForumPost, { as: "forumPosts", foreignKey: "authorId" });

View File

@@ -21,7 +21,7 @@
"db:migrate:undo:all": "sequelize-cli db:migrate:undo:all",
"db:migrate:status": "sequelize-cli db:migrate:status",
"db:create": "sequelize-cli db:create",
"test:migrations": "node scripts/test-migrations.js",
"test:migrations": "NODE_ENV=test node scripts/test-migrations.js",
"alpha:add": "NODE_ENV=dev node scripts/manageAlphaInvitations.js add",
"alpha:list": "NODE_ENV=dev node scripts/manageAlphaInvitations.js list",
"alpha:revoke": "NODE_ENV=dev node scripts/manageAlphaInvitations.js revoke",

View File

@@ -37,20 +37,12 @@ router.post(
// 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
notes
);
const reqLogger = logger.withRequestId(req.id);

View File

@@ -171,7 +171,7 @@ router.get('/sent', authenticateToken, async (req, res) => {
}
});
// Get a single message with replies
// Get a single message
router.get('/:id', authenticateToken, async (req, res) => {
try {
const message = await Message.findOne({
@@ -192,15 +192,6 @@ router.get('/:id', authenticateToken, async (req, res) => {
model: User,
as: 'receiver',
attributes: ['id', 'firstName', 'lastName', 'profileImage']
},
{
model: Message,
as: 'replies',
include: [{
model: User,
as: 'sender',
attributes: ['id', 'firstName', 'lastName', 'profileImage']
}]
}
]
});
@@ -248,7 +239,7 @@ router.get('/:id', authenticateToken, async (req, res) => {
// Send a new message
router.post('/', authenticateToken, uploadMessageImage, async (req, res) => {
try {
const { receiverId, subject, content, parentMessageId } = req.body;
const { receiverId, content } = req.body;
// Check if receiver exists
const receiver = await User.findByPk(receiverId);
@@ -267,9 +258,7 @@ router.post('/', authenticateToken, uploadMessageImage, async (req, res) => {
const message = await Message.create({
senderId: req.user.id,
receiverId,
subject,
content,
parentMessageId,
imagePath
});
@@ -308,8 +297,7 @@ router.post('/', authenticateToken, uploadMessageImage, async (req, res) => {
reqLogger.info("Message sent", {
senderId: req.user.id,
receiverId: receiverId,
messageId: message.id,
isReply: !!parentMessageId
messageId: message.id
});
res.status(201).json(messageWithSender);

View File

@@ -183,7 +183,7 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
endDateTime,
deliveryMethod,
deliveryAddress,
notes,
intendedUse,
stripePaymentMethodId,
} = req.body;
@@ -274,7 +274,7 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
status: "pending",
deliveryMethod,
deliveryAddress,
notes,
intendedUse,
};
// Only add stripePaymentMethodId if it's provided (for paid rentals)
@@ -1099,7 +1099,7 @@ router.post("/:id/cancel", authenticateToken, async (req, res) => {
// Mark item return status (owner only)
router.post("/:id/mark-return", authenticateToken, async (req, res) => {
try {
const { status, actualReturnDateTime, notes, statusOptions } = req.body;
const { status, actualReturnDateTime, statusOptions } = req.body;
const rentalId = req.params.id;
const userId = req.user.id;
@@ -1133,7 +1133,6 @@ router.post("/:id/mark-return", authenticateToken, async (req, res) => {
status: "completed",
payoutStatus: "pending",
actualReturnDateTime: actualReturnDateTime || rental.endDateTime,
notes: notes || null,
});
// Fetch full rental details with associations for email
@@ -1177,15 +1176,13 @@ router.post("/:id/mark-return", authenticateToken, async (req, res) => {
status: "damaged",
payoutStatus: "pending",
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
actualReturnDateTime
);
damageUpdates.status = "returned_late_and_damaged";
damageUpdates.lateFees = lateReturnDamaged.lateCalculation.lateFee;
@@ -1207,8 +1204,7 @@ router.post("/:id/mark-return", authenticateToken, async (req, res) => {
const lateReturn = await LateReturnService.processLateReturn(
rentalId,
actualReturnDateTime,
notes
actualReturnDateTime
);
updatedRental = lateReturn.rental;
@@ -1221,7 +1217,6 @@ router.post("/:id/mark-return", authenticateToken, async (req, res) => {
status: "lost",
payoutStatus: "pending",
itemLostReportedAt: new Date(),
notes: notes || null,
});
// Send notification to customer service

View File

@@ -353,8 +353,8 @@ async function main() {
const command = args[0];
try {
// Sync database
await sequelize.sync();
// Verify database connection
await sequelize.authenticate();
if (!command || command === "help") {
console.log(`

View File

@@ -0,0 +1,225 @@
#!/usr/bin/env node
/**
* Migration Test Script
*
* Tests that all migrations can run successfully up and down.
* This script:
* 1. Connects to a test database
* 2. Runs all migrations down (clean slate)
* 3. Runs all migrations up
* 4. Verifies tables were created
* 5. Runs all migrations down (test rollback)
* 6. Runs all migrations up again (test idempotency)
* 7. Reports results
*
* Usage:
* NODE_ENV=test npm run test:migrations
*
* Requires:
* - Test database to exist (create with: npm run db:create)
* - Environment variables set for test database connection
*/
const { execSync } = require("child_process");
const path = require("path");
// Colors for console output
const colors = {
reset: "\x1b[0m",
green: "\x1b[32m",
red: "\x1b[31m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
};
function log(message, color = colors.reset) {
console.log(`${color}${message}${colors.reset}`);
}
function logStep(step, message) {
log(`\n[${step}] ${message}`, colors.blue);
}
function logSuccess(message) {
log(`${message}`, colors.green);
}
function logError(message) {
log(`${message}`, colors.red);
}
function logWarning(message) {
log(`${message}`, colors.yellow);
}
function runCommand(command, description) {
try {
log(` Running: ${command}`, colors.yellow);
const output = execSync(command, {
cwd: path.resolve(__dirname, ".."),
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, NODE_ENV: process.env.NODE_ENV || "test" },
});
if (output.trim()) {
console.log(output);
}
logSuccess(description);
return { success: true, output };
} catch (error) {
logError(`${description} failed`);
console.error(error.stderr || error.message);
return { success: false, error };
}
}
async function main() {
log("\n========================================", colors.blue);
log(" Migration Test Suite", colors.blue);
log("========================================\n", colors.blue);
const env = process.env.NODE_ENV;
// Safety checks - only allow running against test database
if (!env) {
logError("NODE_ENV is not set!");
logError("This script will DELETE ALL DATA in the target database.");
logError("You must explicitly set NODE_ENV=test to run this script.");
log("\nUsage: NODE_ENV=test npm run test:migrations\n");
process.exit(1);
}
if (env.toLowerCase() !== "test") {
logWarning(`Unrecognized NODE_ENV: ${env}`);
logWarning("This script will DELETE ALL DATA in the target database.");
logWarning("Recommended: NODE_ENV=test npm run test:migrations");
log("");
}
log(`Environment: ${env}`);
const results = {
steps: [],
passed: 0,
failed: 0,
};
function recordResult(step, success) {
results.steps.push({ step, success });
if (success) {
results.passed++;
} else {
results.failed++;
}
}
// Step 1: Check migration status
logStep(1, "Checking current migration status");
const statusResult = runCommand(
"npx sequelize-cli db:migrate:status",
"Migration status check"
);
recordResult("Status check", statusResult.success);
// Step 2: Undo all migrations (clean slate)
logStep(2, "Undoing all migrations (clean slate)");
const undoAllResult = runCommand(
"npx sequelize-cli db:migrate:undo:all",
"Undo all migrations"
);
recordResult("Undo all migrations", undoAllResult.success);
if (!undoAllResult.success) {
logWarning("Undo failed - database may already be empty, continuing...");
}
// Step 3: Run all migrations up
logStep(3, "Running all migrations up");
const migrateUpResult = runCommand(
"npx sequelize-cli db:migrate",
"Run all migrations"
);
recordResult("Migrate up", migrateUpResult.success);
if (!migrateUpResult.success) {
logError("Migration up failed - cannot continue");
printSummary(results);
process.exit(1);
}
// Step 4: Verify migration status shows all executed
logStep(4, "Verifying all migrations executed");
const verifyResult = runCommand(
"npx sequelize-cli db:migrate:status",
"Verify migration status"
);
recordResult("Verify status", verifyResult.success);
// Step 5: Test rollback - undo all migrations
logStep(5, "Testing rollback - undoing all migrations");
const rollbackResult = runCommand(
"npx sequelize-cli db:migrate:undo:all",
"Rollback all migrations"
);
recordResult("Rollback", rollbackResult.success);
if (!rollbackResult.success) {
logError("Rollback failed - down migrations have issues");
printSummary(results);
process.exit(1);
}
// Step 6: Test idempotency - run migrations up again
logStep(6, "Testing idempotency - running migrations up again");
const idempotencyResult = runCommand(
"npx sequelize-cli db:migrate",
"Re-run all migrations"
);
recordResult("Idempotency test", idempotencyResult.success);
if (!idempotencyResult.success) {
logError("Idempotency test failed - migrations may not be repeatable");
printSummary(results);
process.exit(1);
}
// Step 7: Final status check
logStep(7, "Final migration status");
const finalStatusResult = runCommand(
"npx sequelize-cli db:migrate:status",
"Final status check"
);
recordResult("Final status", finalStatusResult.success);
printSummary(results);
if (results.failed > 0) {
process.exit(1);
}
log("\nMigration tests completed successfully!", colors.green);
process.exit(0);
}
function printSummary(results) {
log("\n========================================", colors.blue);
log(" Test Summary", colors.blue);
log("========================================\n", colors.blue);
results.steps.forEach(({ step, success }) => {
if (success) {
logSuccess(step);
} else {
logError(step);
}
});
log(`\nTotal: ${results.passed} passed, ${results.failed} failed`);
}
main().catch((error) => {
logError("Unexpected error:");
console.error(error);
process.exit(1);
});

View File

@@ -165,10 +165,23 @@ app.use(sanitizeError);
const PORT = process.env.PORT || 5000;
const { checkPendingMigrations } = require("./utils/checkMigrations");
sequelize
.sync({ alter: true })
.authenticate()
.then(async () => {
logger.info("Database synced successfully");
logger.info("Database connection established successfully");
// Check for pending migrations
const pendingMigrations = await checkPendingMigrations(sequelize);
if (pendingMigrations.length > 0) {
logger.error(
`Found ${pendingMigrations.length} pending migration(s). Please run 'npm run db:migrate'`,
{ pendingMigrations }
);
process.exit(1);
}
logger.info("All migrations are up to date");
// Initialize email services and load templates
try {
@@ -209,8 +222,9 @@ sequelize
});
})
.catch((err) => {
logger.error("Unable to sync database", {
logger.error("Unable to connect to database", {
error: err.message,
stack: err.stack,
});
process.exit(1);
});

View File

@@ -118,7 +118,6 @@ class ConditionCheckService {
* @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(
@@ -126,8 +125,7 @@ class ConditionCheckService {
checkType,
userId,
photos = [],
notes = null,
metadata = {}
notes = null
) {
// Validate the check
const validation = await this.validateConditionCheck(
@@ -145,22 +143,12 @@ class ConditionCheckService {
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;

View File

@@ -303,7 +303,7 @@ class TemplateManager {
<p><strong>Total Amount:</strong> ${{totalAmount}}</p>
<p><strong>Your Earnings:</strong> ${{payoutAmount}}</p>
<p><strong>Delivery Method:</strong> {{deliveryMethod}}</p>
<p><strong>Renter Notes:</strong> {{rentalNotes}}</p>
<p><strong>Intended Use:</strong> {{intendedUse}}</p>
<p><a href="{{approveUrl}}" class="button">Review & Respond</a></p>
<p>Please respond to this request within 24 hours.</p>
`

View File

@@ -39,7 +39,6 @@ class MessagingEmailService {
* @param {string} sender.firstName - Sender's first name
* @param {string} sender.lastName - Sender's last name
* @param {Object} message - Message object
* @param {string} message.subject - Message subject
* @param {string} message.content - Message content
* @param {Date} message.createdAt - Message creation timestamp
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
@@ -61,7 +60,6 @@ class MessagingEmailService {
const variables = {
recipientName: receiver.firstName || "there",
senderName: `${sender.firstName} ${sender.lastName}`.trim() || "A user",
subject: message.subject,
messageContent: message.content,
conversationUrl: conversationUrl,
timestamp: timestamp,

View File

@@ -53,7 +53,6 @@ class RentalFlowEmailService {
* @param {string} rental.totalAmount - Total rental amount
* @param {string} rental.payoutAmount - Owner's payout amount
* @param {string} rental.deliveryMethod - Delivery method
* @param {string} rental.notes - Rental notes from renter
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
*/
async sendRentalRequestEmail(owner, renter, rental) {
@@ -88,7 +87,7 @@ class RentalFlowEmailService {
? parseFloat(rental.payoutAmount).toFixed(2)
: "0.00",
deliveryMethod: rental.deliveryMethod || "Not specified",
rentalNotes: rental.notes || "No additional notes provided",
intendedUse: rental.intendedUse || "Not specified",
approveUrl: approveUrl,
};

View File

@@ -60,10 +60,9 @@ class LateReturnService {
* 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) {
static async processLateReturn(rentalId, actualReturnDateTime) {
const rental = await Rental.findByPk(rentalId, {
include: [{ model: Item, as: "item" }],
});
@@ -84,10 +83,6 @@ class LateReturnService {
payoutStatus: "pending",
};
if (notes) {
updates.notes = notes;
}
const updatedRental = await rental.update(updates);
// Send notification to customer service if late return detected

View File

@@ -134,13 +134,6 @@
border-radius: 6px;
}
.message-box .subject {
font-size: 16px;
font-weight: 600;
color: #495057;
margin: 0 0 15px 0;
}
.message-box .content-text {
color: #212529;
line-height: 1.6;
@@ -226,7 +219,6 @@
<p>{{senderName}} sent you a message on RentAll.</p>
<div class="message-box">
<div class="subject">Subject: {{subject}}</div>
<div class="content-text">{{messageContent}}</div>
<div class="timestamp">Sent {{timestamp}}</div>
</div>

View File

@@ -295,8 +295,8 @@
</table>
<div class="info-box">
<p><strong>Renter's Notes:</strong></p>
<p>{{rentalNotes}}</p>
<p><strong>Intended Use:</strong></p>
<p>{{intendedUse}}</p>
</div>
<div style="text-align: center">

View File

@@ -56,7 +56,6 @@ describe('Messages Routes', () => {
id: 1,
senderId: 2,
receiverId: 1,
subject: 'Test Message',
content: 'Hello there!',
isRead: false,
createdAt: '2024-01-15T10:00:00.000Z',
@@ -71,7 +70,6 @@ describe('Messages Routes', () => {
id: 2,
senderId: 3,
receiverId: 1,
subject: 'Another Message',
content: 'Hi!',
isRead: true,
createdAt: '2024-01-14T10:00:00.000Z',
@@ -122,7 +120,6 @@ describe('Messages Routes', () => {
id: 3,
senderId: 1,
receiverId: 2,
subject: 'My Message',
content: 'Hello Jane!',
isRead: false,
createdAt: '2024-01-15T12:00:00.000Z',
@@ -171,7 +168,6 @@ describe('Messages Routes', () => {
id: 1,
senderId: 2,
receiverId: 1,
subject: 'Test Message',
content: 'Hello there!',
isRead: false,
createdAt: '2024-01-15T10:00:00.000Z',
@@ -187,19 +183,6 @@ describe('Messages Routes', () => {
lastName: 'Doe',
profileImage: 'john.jpg'
},
replies: [
{
id: 4,
senderId: 1,
content: 'Reply message',
sender: {
id: 1,
firstName: 'John',
lastName: 'Doe',
profileImage: 'john.jpg'
}
}
],
update: jest.fn()
};
@@ -207,7 +190,7 @@ describe('Messages Routes', () => {
mockMessageFindOne.mockResolvedValue(mockMessage);
});
it('should get message with replies for receiver', async () => {
it('should get message for receiver and mark as read', async () => {
mockMessage.update.mockResolvedValue();
const response = await request(app)
@@ -218,7 +201,6 @@ describe('Messages Routes', () => {
id: 1,
senderId: 2,
receiverId: 1,
subject: 'Test Message',
content: 'Hello there!',
isRead: false,
createdAt: '2024-01-15T10:00:00.000Z',
@@ -233,20 +215,7 @@ describe('Messages Routes', () => {
firstName: 'John',
lastName: 'Doe',
profileImage: 'john.jpg'
},
replies: [
{
id: 4,
senderId: 1,
content: 'Reply message',
sender: {
id: 1,
firstName: 'John',
lastName: 'Doe',
profileImage: 'john.jpg'
}
}
]
});
expect(mockMessage.update).toHaveBeenCalledWith({ isRead: true });
});
@@ -263,7 +232,6 @@ describe('Messages Routes', () => {
id: 1,
senderId: 1,
receiverId: 2,
subject: 'Test Message',
content: 'Hello there!',
isRead: false,
createdAt: '2024-01-15T10:00:00.000Z',
@@ -278,20 +246,7 @@ describe('Messages Routes', () => {
firstName: 'John',
lastName: 'Doe',
profileImage: 'john.jpg'
},
replies: [
{
id: 4,
senderId: 1,
content: 'Reply message',
sender: {
id: 1,
firstName: 'John',
lastName: 'Doe',
profileImage: 'john.jpg'
}
}
]
});
expect(mockMessage.update).not.toHaveBeenCalled();
});
@@ -340,9 +295,7 @@ describe('Messages Routes', () => {
id: 5,
senderId: 1,
receiverId: 2,
subject: 'New Message',
content: 'Hello Jane!',
parentMessageId: null
content: 'Hello Jane!'
};
const mockMessageWithSender = {
@@ -364,9 +317,7 @@ describe('Messages Routes', () => {
it('should create a new message', async () => {
const messageData = {
receiverId: 2,
subject: 'New Message',
content: 'Hello Jane!',
parentMessageId: null
content: 'Hello Jane!'
};
const response = await request(app)
@@ -378,31 +329,8 @@ describe('Messages Routes', () => {
expect(mockMessageCreate).toHaveBeenCalledWith({
senderId: 1,
receiverId: 2,
subject: 'New Message',
content: 'Hello Jane!',
parentMessageId: null
});
});
it('should create a reply message with parentMessageId', async () => {
const replyData = {
receiverId: 2,
subject: 'Re: Original Message',
content: 'This is a reply',
parentMessageId: 1
};
const response = await request(app)
.post('/messages')
.send(replyData);
expect(response.status).toBe(201);
expect(mockMessageCreate).toHaveBeenCalledWith({
senderId: 1,
receiverId: 2,
subject: 'Re: Original Message',
content: 'This is a reply',
parentMessageId: 1
imagePath: null
});
});
@@ -413,7 +341,6 @@ describe('Messages Routes', () => {
.post('/messages')
.send({
receiverId: 999,
subject: 'Test',
content: 'Test message'
});
@@ -426,7 +353,6 @@ describe('Messages Routes', () => {
.post('/messages')
.send({
receiverId: 1, // Same as sender ID
subject: 'Self Message',
content: 'Hello self!'
});
@@ -441,7 +367,6 @@ describe('Messages Routes', () => {
.post('/messages')
.send({
receiverId: 2,
subject: 'Test',
content: 'Test message'
});
@@ -596,62 +521,5 @@ describe('Messages Routes', () => {
expect(response.status).toBe(200);
expect(response.body).toEqual([]);
});
it('should handle message with no replies', async () => {
const messageWithoutReplies = {
id: 1,
senderId: 2,
receiverId: 1,
subject: 'Test Message',
content: 'Hello there!',
isRead: false,
replies: [],
update: jest.fn()
};
mockMessageFindOne.mockResolvedValue(messageWithoutReplies);
const response = await request(app)
.get('/messages/1');
expect(response.status).toBe(200);
expect(response.body.replies).toEqual([]);
});
it('should handle missing optional fields in message creation', async () => {
const mockReceiver = { id: 2, firstName: 'Jane', lastName: 'Smith' };
const mockCreatedMessage = {
id: 6,
senderId: 1,
receiverId: 2,
subject: undefined,
content: 'Just content',
parentMessageId: undefined
};
const mockMessageWithSender = {
...mockCreatedMessage,
sender: { id: 1, firstName: 'John', lastName: 'Doe' }
};
mockUserFindByPk.mockResolvedValue(mockReceiver);
mockMessageCreate.mockResolvedValue(mockCreatedMessage);
mockMessageFindByPk.mockResolvedValue(mockMessageWithSender);
const response = await request(app)
.post('/messages')
.send({
receiverId: 2,
content: 'Just content'
// subject and parentMessageId omitted
});
expect(response.status).toBe(201);
expect(mockMessageCreate).toHaveBeenCalledWith({
senderId: 1,
receiverId: 2,
subject: undefined,
content: 'Just content',
parentMessageId: undefined
});
});
});
});

View File

@@ -289,7 +289,6 @@ describe('Rentals Routes', () => {
endDateTime: '2024-01-15T18:00:00.000Z',
deliveryMethod: 'pickup',
deliveryAddress: null,
notes: 'Test rental',
stripePaymentMethodId: 'pm_test123',
};

View File

@@ -51,7 +51,6 @@ describe('ConditionCheckService', () => {
submittedBy: 'renter-789',
photos: mockPhotos,
notes: 'Item received in good condition',
metadata: expect.any(Object)
})
);

View File

@@ -449,7 +449,6 @@ describe('EmailService', () => {
totalAmount: 150.00,
payoutAmount: 135.00,
deliveryMethod: 'pickup',
notes: 'Please have it ready by 9am',
item: { name: 'Power Drill' }
};
@@ -522,7 +521,6 @@ describe('EmailService', () => {
totalAmount: 0,
payoutAmount: 0,
deliveryMethod: 'pickup',
notes: null,
item: { name: 'Free Item' }
};
@@ -531,39 +529,6 @@ describe('EmailService', () => {
expect(result.success).toBe(true);
});
it('should handle missing rental notes', async () => {
const mockOwner = {
email: 'owner@example.com',
firstName: 'John'
};
const mockRenter = {
firstName: 'Jane',
lastName: 'Doe'
};
User.findByPk
.mockResolvedValueOnce(mockOwner)
.mockResolvedValueOnce(mockRenter);
const rental = {
id: 1,
ownerId: 10,
renterId: 20,
startDateTime: new Date('2024-12-01T10:00:00Z'),
endDateTime: new Date('2024-12-03T10:00:00Z'),
totalAmount: 100,
payoutAmount: 90,
deliveryMethod: 'delivery',
notes: null, // No notes
item: { name: 'Test Item' }
};
const result = await emailService.sendRentalRequestEmail(rental);
expect(result.success).toBe(true);
expect(mockSend).toHaveBeenCalled();
});
it('should generate correct approval URL', async () => {
const mockOwner = {
email: 'owner@example.com',
@@ -589,7 +554,6 @@ describe('EmailService', () => {
totalAmount: 100,
payoutAmount: 90,
deliveryMethod: 'pickup',
notes: 'Test notes',
item: { name: 'Test Item' }
};

View File

@@ -0,0 +1,96 @@
const fs = require("fs");
const path = require("path");
/**
* Check for pending database migrations
*
* Compares migration files in the filesystem against the SequelizeMeta table
* to determine which migrations have not yet been executed.
*
* @param {Sequelize} sequelize - Sequelize instance
* @returns {Promise<string[]>} Array of pending migration filenames
*/
async function checkPendingMigrations(sequelize) {
const migrationsPath = path.resolve(__dirname, "..", "migrations");
// Get all migration files from filesystem
const migrationFiles = fs
.readdirSync(migrationsPath)
.filter((file) => file.endsWith(".js"))
.sort();
// Check if SequelizeMeta table exists
const [results] = await sequelize.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'SequelizeMeta'
) as exists
`);
if (!results[0].exists) {
// No migrations have ever been run
return migrationFiles;
}
// Get executed migrations from SequelizeMeta
const [executedMigrations] = await sequelize.query(
'SELECT name FROM "SequelizeMeta" ORDER BY name'
);
const executedSet = new Set(executedMigrations.map((row) => row.name));
// Find migrations that haven't been executed
const pendingMigrations = migrationFiles.filter(
(file) => !executedSet.has(file)
);
return pendingMigrations;
}
/**
* Get migration status summary
*
* @param {Sequelize} sequelize - Sequelize instance
* @returns {Promise<{executed: string[], pending: string[]}>} Migration status
*/
async function getMigrationStatus(sequelize) {
const migrationsPath = path.resolve(__dirname, "..", "migrations");
const migrationFiles = fs
.readdirSync(migrationsPath)
.filter((file) => file.endsWith(".js"))
.sort();
// Check if SequelizeMeta table exists
const [results] = await sequelize.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'SequelizeMeta'
) as exists
`);
if (!results[0].exists) {
return {
executed: [],
pending: migrationFiles,
};
}
const [executedMigrations] = await sequelize.query(
'SELECT name FROM "SequelizeMeta" ORDER BY name'
);
const executedSet = new Set(executedMigrations.map((row) => row.name));
return {
executed: migrationFiles.filter((file) => executedSet.has(file)),
pending: migrationFiles.filter((file) => !executedSet.has(file)),
};
}
module.exports = {
checkPendingMigrations,
getMigrationStatus,
};

View File

@@ -21,7 +21,6 @@ import Owning from './pages/Owning';
import Profile from './pages/Profile';
import PublicProfile from './pages/PublicProfile';
import Messages from './pages/Messages';
import MessageDetail from './pages/MessageDetail';
import ForumPosts from './pages/ForumPosts';
import ForumPostDetail from './pages/ForumPostDetail';
import CreateForumPost from './pages/CreateForumPost';
@@ -150,14 +149,6 @@ const AppContent: React.FC = () => {
</PrivateRoute>
}
/>
<Route
path="/messages/:id"
element={
<PrivateRoute>
<MessageDetail />
</PrivateRoute>
}
/>
<Route path="/forum" element={<ForumPosts />} />
<Route path="/forum/:id" element={<ForumPostDetail />} />
<Route

View File

@@ -322,7 +322,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient, onMes
// Build FormData for message (with or without image)
const formData = new FormData();
formData.append('receiverId', recipient.id);
formData.append('subject', `Message from ${currentUser?.firstName}`);
formData.append('content', messageContent || ' '); // Send space if only image
if (imageToSend) {
formData.append('image', imageToSend);

View File

@@ -10,7 +10,6 @@ interface MessageModalProps {
}
const MessageModal: React.FC<MessageModalProps> = ({ show, onClose, recipient, onSuccess }) => {
const [subject, setSubject] = useState('');
const [content, setContent] = useState('');
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -23,12 +22,10 @@ const MessageModal: React.FC<MessageModalProps> = ({ show, onClose, recipient, o
try {
const formData = new FormData();
formData.append('receiverId', recipient.id);
formData.append('subject', subject);
formData.append('content', content);
await messageAPI.sendMessage(formData);
setSubject('');
setContent('');
onClose();
if (onSuccess) {
@@ -59,19 +56,6 @@ const MessageModal: React.FC<MessageModalProps> = ({ show, onClose, recipient, o
</div>
)}
<div className="mb-3">
<label htmlFor="subject" className="form-label">Subject</label>
<input
type="text"
className="form-control"
id="subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
required
disabled={sending}
/>
</div>
<div className="mb-3">
<label htmlFor="content" className="form-label">Message</label>
<textarea
@@ -98,7 +82,7 @@ const MessageModal: React.FC<MessageModalProps> = ({ show, onClose, recipient, o
<button
type="submit"
className="btn btn-primary"
disabled={sending || !subject || !content}
disabled={sending || !content}
>
{sending ? (
<>

View File

@@ -24,7 +24,6 @@ const ReturnStatusModal: React.FC<ReturnStatusModalProps> = ({
lost: false,
});
const [actualReturnDateTime, setActualReturnDateTime] = useState("");
const [notes, setNotes] = useState("");
const [conditionNotes, setConditionNotes] = useState("");
const [photos, setPhotos] = useState<File[]>([]);
const [processing, setProcessing] = useState(false);
@@ -56,7 +55,6 @@ const ReturnStatusModal: React.FC<ReturnStatusModalProps> = ({
lost: false,
});
setActualReturnDateTime("");
setNotes("");
setConditionNotes("");
setPhotos([]);
setError(null);
@@ -71,13 +69,13 @@ const ReturnStatusModal: React.FC<ReturnStatusModalProps> = ({
// Create blob URLs for photo previews and clean them up
const photoBlobUrls = useMemo(() => {
return photos.map(photo => URL.createObjectURL(photo));
return photos.map((photo) => URL.createObjectURL(photo));
}, [photos]);
// Cleanup blob URLs when photos change or component unmounts
useEffect(() => {
return () => {
photoBlobUrls.forEach(url => URL.revokeObjectURL(url));
photoBlobUrls.forEach((url) => URL.revokeObjectURL(url));
};
}, [photoBlobUrls]);
@@ -202,6 +200,7 @@ const ReturnStatusModal: React.FC<ReturnStatusModalProps> = ({
);
if (!hasSelection) {
setError("Please select at least one return status option");
setProcessing(false);
return;
}
@@ -316,7 +315,6 @@ const ReturnStatusModal: React.FC<ReturnStatusModalProps> = ({
const data: any = {
status: primaryStatus,
statusOptions, // Send all selected options
notes: notes.trim() || undefined,
};
if (statusOptions.returned_late) {
@@ -346,7 +344,6 @@ const ReturnStatusModal: React.FC<ReturnStatusModalProps> = ({
lost: false,
});
setActualReturnDateTime("");
setNotes("");
setConditionNotes("");
setPhotos([]);
setError(null);
@@ -950,7 +947,7 @@ const ReturnStatusModal: React.FC<ReturnStatusModalProps> = ({
>
<span className="visually-hidden">Loading...</span>
</div>
Processing...
Submitting...
</>
) : (
"Submit"

View File

@@ -69,6 +69,18 @@ const ReviewItemModal: React.FC<ReviewItemModalProps> = ({
if (!show) return null;
// Show success modal instead of review modal after successful submission
if (showSuccessModal) {
return (
<SuccessModal
show={true}
onClose={handleSuccessModalClose}
title="Thank you for your review!"
message={successMessage}
/>
);
}
return (
<div
className="modal d-block"
@@ -212,13 +224,6 @@ const ReviewItemModal: React.FC<ReviewItemModalProps> = ({
</form>
</div>
</div>
<SuccessModal
show={showSuccessModal}
onClose={handleSuccessModalClose}
title="Thank you for your review!"
message={successMessage}
/>
</div>
);
};

View File

@@ -69,6 +69,18 @@ const ReviewRenterModal: React.FC<ReviewRenterModalProps> = ({
if (!show) return null;
// Show success modal instead of review modal after successful submission
if (showSuccessModal) {
return (
<SuccessModal
show={true}
onClose={handleSuccessModalClose}
title="Thank you for your review!"
message={successMessage}
/>
);
}
return (
<div
className="modal d-block"
@@ -212,13 +224,6 @@ const ReviewRenterModal: React.FC<ReviewRenterModalProps> = ({
</form>
</div>
</div>
<SuccessModal
show={showSuccessModal}
onClose={handleSuccessModalClose}
title="Thank you for your review!"
message={successMessage}
/>
</div>
);
};

View File

@@ -1,260 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Message } from '../types';
import { messageAPI } from '../services/api';
import { useAuth } from '../contexts/AuthContext';
import { useSocket } from '../contexts/SocketContext';
const MessageDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { user } = useAuth();
const { isConnected, onNewMessage } = useSocket();
const [message, setMessage] = useState<Message | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [replyContent, setReplyContent] = useState('');
const [sending, setSending] = useState(false);
useEffect(() => {
fetchMessage();
}, [id]);
// Listen for new replies in real-time
useEffect(() => {
if (!isConnected || !message) return;
const cleanup = onNewMessage((newMessage: Message) => {
// Check if this is a reply to the current thread
if (newMessage.parentMessageId === message.id) {
setMessage((prevMessage) => {
if (!prevMessage) return prevMessage;
// Check if reply already exists (avoid duplicates)
const replies = prevMessage.replies || [];
if (replies.some(r => r.id === newMessage.id)) {
return prevMessage;
}
// Add new reply to the thread
return {
...prevMessage,
replies: [...replies, newMessage]
};
});
}
});
return cleanup;
}, [isConnected, message?.id, onNewMessage]);
const fetchMessage = async () => {
try {
const response = await messageAPI.getMessage(id!);
setMessage(response.data);
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to fetch message');
} finally {
setLoading(false);
}
};
const handleReply = async (e: React.FormEvent) => {
e.preventDefault();
if (!message) return;
setSending(true);
setError(null);
try {
const recipientId = message.senderId === user?.id ? message.receiverId : message.senderId;
const formData = new FormData();
formData.append('receiverId', recipientId);
formData.append('subject', `Re: ${message.subject}`);
formData.append('content', replyContent);
formData.append('parentMessageId', message.id);
const response = await messageAPI.sendMessage(formData);
setReplyContent('');
// Note: Socket will automatically add the reply to the thread
// But we add it manually for immediate feedback if socket is disconnected
if (!isConnected) {
setMessage((prevMessage) => {
if (!prevMessage) return prevMessage;
const replies = prevMessage.replies || [];
return {
...prevMessage,
replies: [...replies, response.data]
};
});
}
alert('Reply sent successfully!');
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to send reply');
} finally {
setSending(false);
}
};
const formatDateTime = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit'
});
};
if (loading) {
return (
<div className="container mt-5">
<div className="text-center">
<div className="spinner-border" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
</div>
);
}
if (!message) {
return (
<div className="container mt-5">
<div className="alert alert-danger" role="alert">
Message not found
</div>
</div>
);
}
const isReceiver = message.receiverId === user?.id;
const otherUser = isReceiver ? message.sender : message.receiver;
return (
<div className="container mt-4">
<div className="row justify-content-center">
<div className="col-md-8">
<button
className="btn btn-link text-decoration-none mb-3"
onClick={() => navigate('/messages')}
>
<i className="bi bi-arrow-left"></i> Back to Messages
</button>
<div className="card">
<div className="card-header">
<div className="d-flex align-items-center">
{otherUser?.profileImage ? (
<img
src={otherUser.profileImage}
alt={`${otherUser.firstName} ${otherUser.lastName}`}
className="rounded-circle me-3"
style={{ width: '50px', height: '50px', objectFit: 'cover' }}
/>
) : (
<div
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center me-3"
style={{ width: '50px', height: '50px' }}
>
<i className="bi bi-person-fill text-white"></i>
</div>
)}
<div>
<h5 className="mb-0">{message.subject}</h5>
<small className="text-muted">
{isReceiver ? 'From' : 'To'}: {otherUser?.firstName} {otherUser?.lastName} {formatDateTime(message.createdAt)}
</small>
</div>
</div>
</div>
<div className="card-body">
<p style={{ whiteSpace: 'pre-wrap' }}>{message.content}</p>
</div>
</div>
{message.replies && message.replies.length > 0 && (
<div className="mt-4">
<h6>Replies</h6>
{message.replies.map((reply) => (
<div key={reply.id} className="card mb-2">
<div className="card-body">
<div className="d-flex align-items-center mb-2">
{reply.sender?.profileImage ? (
<img
src={reply.sender.profileImage}
alt={`${reply.sender.firstName} ${reply.sender.lastName}`}
className="rounded-circle me-2"
style={{ width: '30px', height: '30px', objectFit: 'cover' }}
/>
) : (
<div
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center me-2"
style={{ width: '30px', height: '30px' }}
>
<i className="bi bi-person-fill text-white" style={{ fontSize: '0.8rem' }}></i>
</div>
)}
<div>
<strong>{reply.sender?.firstName} {reply.sender?.lastName}</strong>
<small className="text-muted ms-2">{formatDateTime(reply.createdAt)}</small>
</div>
</div>
<p className="mb-0" style={{ whiteSpace: 'pre-wrap' }}>{reply.content}</p>
</div>
</div>
))}
</div>
)}
<div className="card mt-4">
<div className="card-body">
<h6>Send Reply</h6>
{error && (
<div className="alert alert-danger" role="alert">
{error}
</div>
)}
<form onSubmit={handleReply}>
<div className="mb-3">
<textarea
className="form-control"
rows={4}
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
placeholder="Type your reply..."
required
disabled={sending}
></textarea>
</div>
<button
type="submit"
className="btn btn-primary"
disabled={sending || !replyContent.trim()}
>
{sending ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
Sending...
</>
) : (
<>
<i className="bi bi-send-fill me-2"></i>Send Reply
</>
)}
</button>
</form>
</div>
</div>
</div>
</div>
</div>
);
};
export default MessageDetail;

View File

@@ -359,6 +359,14 @@ const Owning: React.FC = () => {
<strong>Total:</strong> ${rental.totalAmount}
</p>
{rental.intendedUse && rental.status === "pending" && (
<div className="alert alert-light mt-2 mb-2 p-2 small">
<strong>Intended Use:</strong>
<br />
{rental.intendedUse}
</div>
)}
{rental.status === "cancelled" &&
rental.refundAmount !== undefined && (
<div className="alert alert-info mt-2 mb-2 p-2 small">
@@ -620,9 +628,7 @@ const Owning: React.FC = () => {
onClick={() => toggleAvailability(item)}
className="btn btn-sm btn-outline-info"
>
{item.isAvailable
? "Mark Unavailable"
: "Mark Available"}
{item.isAvailable ? "Mark Unavailable" : "Mark Available"}
</button>
<button
onClick={() => handleDelete(item.id)}

View File

@@ -17,6 +17,7 @@ const RentItem: React.FC = () => {
const [formData, setFormData] = useState({
deliveryMethod: "pickup" as "pickup" | "delivery",
deliveryAddress: "",
intendedUse: "",
});
const [manualSelection, setManualSelection] = useState({
@@ -143,6 +144,7 @@ const RentItem: React.FC = () => {
endDateTime,
deliveryMethod: formData.deliveryMethod,
deliveryAddress: formData.deliveryAddress,
intendedUse: formData.intendedUse || undefined,
totalAmount: totalCost,
};
} catch (error: any) {
@@ -261,6 +263,26 @@ const RentItem: React.FC = () => {
</p>
)}
<div className="mb-3">
<label htmlFor="intendedUse" className="form-label">
What will you use this for?{" "}
<span className="text-muted">(Optional)</span>
</label>
<textarea
id="intendedUse"
name="intendedUse"
className="form-control"
rows={3}
value={formData.intendedUse}
onChange={handleChange}
placeholder="Let the owner know how you plan to use their item..."
maxLength={500}
/>
<div className="form-text">
{formData.intendedUse.length}/500 characters
</div>
</div>
{!manualSelection.startDate ||
!manualSelection.endDate ||
!getRentalData() ? (

View File

@@ -232,7 +232,7 @@ export const rentalAPI = {
// Return status marking
markReturn: (
id: string,
data: { status: string; actualReturnDateTime?: string; notes?: string }
data: { status: string; actualReturnDateTime?: string }
) => api.post(`/rentals/${id}/mark-return`, data),
reportDamage: (id: string, data: any) =>
api.post(`/rentals/${id}/report-damage`, data),

View File

@@ -39,14 +39,11 @@ export interface Message {
id: string;
senderId: string;
receiverId: string;
subject: string;
content: string;
isRead: boolean;
parentMessageId?: string;
imagePath?: string;
sender?: User;
receiver?: User;
replies?: Message[];
createdAt: string;
updatedAt: string;
}
@@ -152,7 +149,7 @@ export interface Rental {
stripeTransferId?: string;
deliveryMethod: "pickup" | "delivery";
deliveryAddress?: string;
notes?: string;
intendedUse?: string;
rating?: number;
review?: string;
declineReason?: string;
@@ -194,7 +191,6 @@ export interface ConditionCheck {
notes?: string;
submittedBy: string;
submittedAt: string;
metadata: any;
submittedByUser?: User;
createdAt: string;
updatedAt: string;