Compare commits
6 Commits
13268784fd
...
f3a356d64b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3a356d64b | ||
|
|
9ec3e97d9e | ||
|
|
8fc269c62a | ||
|
|
31d94b1b3f | ||
|
|
2983f67ce8 | ||
|
|
8de814fdee |
210
backend/migrations/20241124000005-create-rentals.js
Normal file
210
backend/migrations/20241124000005-create-rentals.js
Normal 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");
|
||||
},
|
||||
};
|
||||
70
backend/migrations/20241124000006-create-condition-checks.js
Normal file
70
backend/migrations/20241124000006-create-condition-checks.js
Normal 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");
|
||||
},
|
||||
};
|
||||
61
backend/migrations/20241124000007-create-messages.js
Normal file
61
backend/migrations/20241124000007-create-messages.js
Normal 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");
|
||||
},
|
||||
};
|
||||
129
backend/migrations/20241124000008-create-forum-posts.js
Normal file
129
backend/migrations/20241124000008-create-forum-posts.js
Normal 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");
|
||||
},
|
||||
};
|
||||
92
backend/migrations/20241124000009-create-forum-comments.js
Normal file
92
backend/migrations/20241124000009-create-forum-comments.js
Normal 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");
|
||||
},
|
||||
};
|
||||
@@ -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"
|
||||
);
|
||||
},
|
||||
};
|
||||
43
backend/migrations/20241124000011-create-post-tags.js
Normal file
43
backend/migrations/20241124000011-create-post-tags.js
Normal 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");
|
||||
},
|
||||
};
|
||||
50
backend/migrations/20241124000012-create-feedback.js
Normal file
50
backend/migrations/20241124000012-create-feedback.js
Normal 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");
|
||||
},
|
||||
};
|
||||
@@ -44,10 +44,6 @@ const ConditionCheck = sequelize.define("ConditionCheck", {
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
metadata: {
|
||||
type: DataTypes.JSONB,
|
||||
defaultValue: {},
|
||||
},
|
||||
});
|
||||
|
||||
module.exports = ConditionCheck;
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -171,11 +171,11 @@ 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({
|
||||
where: {
|
||||
where: {
|
||||
id: req.params.id,
|
||||
[require('sequelize').Op.or]: [
|
||||
{ senderId: req.user.id },
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(`
|
||||
|
||||
225
backend/scripts/test-migrations.js
Normal file
225
backend/scripts/test-migrations.js
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -289,7 +289,6 @@ describe('Rentals Routes', () => {
|
||||
endDateTime: '2024-01-15T18:00:00.000Z',
|
||||
deliveryMethod: 'pickup',
|
||||
deliveryAddress: null,
|
||||
notes: 'Test rental',
|
||||
stripePaymentMethodId: 'pm_test123',
|
||||
};
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ describe('ConditionCheckService', () => {
|
||||
submittedBy: 'renter-789',
|
||||
photos: mockPhotos,
|
||||
notes: 'Item received in good condition',
|
||||
metadata: expect.any(Object)
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -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' }
|
||||
};
|
||||
|
||||
|
||||
96
backend/utils/checkMigrations.js
Normal file
96
backend/utils/checkMigrations.js
Normal 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,
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
@@ -58,19 +55,6 @@ const MessageModal: React.FC<MessageModalProps> = ({ show, onClose, recipient, o
|
||||
{error}
|
||||
</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>
|
||||
@@ -95,10 +79,10 @@ const MessageModal: React.FC<MessageModalProps> = ({ show, onClose, recipient, o
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={sending || !subject || !content}
|
||||
disabled={sending || !content}
|
||||
>
|
||||
{sending ? (
|
||||
<>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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)}
|
||||
|
||||
@@ -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() ? (
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user