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,
|
allowNull: false,
|
||||||
defaultValue: DataTypes.NOW,
|
defaultValue: DataTypes.NOW,
|
||||||
},
|
},
|
||||||
metadata: {
|
|
||||||
type: DataTypes.JSONB,
|
|
||||||
defaultValue: {},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = ConditionCheck;
|
module.exports = ConditionCheck;
|
||||||
@@ -23,10 +23,6 @@ const Message = sequelize.define('Message', {
|
|||||||
key: 'id'
|
key: 'id'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
subject: {
|
|
||||||
type: DataTypes.STRING,
|
|
||||||
allowNull: false
|
|
||||||
},
|
|
||||||
content: {
|
content: {
|
||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
allowNull: false
|
allowNull: false
|
||||||
@@ -35,14 +31,6 @@ const Message = sequelize.define('Message', {
|
|||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
defaultValue: false
|
defaultValue: false
|
||||||
},
|
},
|
||||||
parentMessageId: {
|
|
||||||
type: DataTypes.UUID,
|
|
||||||
allowNull: true,
|
|
||||||
references: {
|
|
||||||
model: 'Messages',
|
|
||||||
key: 'id'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
imagePath: {
|
imagePath: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
allowNull: true
|
allowNull: true
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ const Rental = sequelize.define("Rental", {
|
|||||||
deliveryAddress: {
|
deliveryAddress: {
|
||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
},
|
},
|
||||||
notes: {
|
intendedUse: {
|
||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
},
|
},
|
||||||
// Renter's review of the item (existing fields renamed for clarity)
|
// 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" });
|
User.hasMany(Message, { as: "receivedMessages", foreignKey: "receiverId" });
|
||||||
Message.belongsTo(User, { as: "sender", foreignKey: "senderId" });
|
Message.belongsTo(User, { as: "sender", foreignKey: "senderId" });
|
||||||
Message.belongsTo(User, { as: "receiver", foreignKey: "receiverId" });
|
Message.belongsTo(User, { as: "receiver", foreignKey: "receiverId" });
|
||||||
Message.hasMany(Message, { as: "replies", foreignKey: "parentMessageId" });
|
|
||||||
Message.belongsTo(Message, {
|
|
||||||
as: "parentMessage",
|
|
||||||
foreignKey: "parentMessageId",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Forum associations
|
// Forum associations
|
||||||
User.hasMany(ForumPost, { as: "forumPosts", foreignKey: "authorId" });
|
User.hasMany(ForumPost, { as: "forumPosts", foreignKey: "authorId" });
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"db:migrate:undo:all": "sequelize-cli db:migrate:undo:all",
|
"db:migrate:undo:all": "sequelize-cli db:migrate:undo:all",
|
||||||
"db:migrate:status": "sequelize-cli db:migrate:status",
|
"db:migrate:status": "sequelize-cli db:migrate:status",
|
||||||
"db:create": "sequelize-cli db:create",
|
"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:add": "NODE_ENV=dev node scripts/manageAlphaInvitations.js add",
|
||||||
"alpha:list": "NODE_ENV=dev node scripts/manageAlphaInvitations.js list",
|
"alpha:list": "NODE_ENV=dev node scripts/manageAlphaInvitations.js list",
|
||||||
"alpha:revoke": "NODE_ENV=dev node scripts/manageAlphaInvitations.js revoke",
|
"alpha:revoke": "NODE_ENV=dev node scripts/manageAlphaInvitations.js revoke",
|
||||||
|
|||||||
@@ -37,20 +37,12 @@ router.post(
|
|||||||
// Get uploaded file paths
|
// Get uploaded file paths
|
||||||
const photos = req.files ? req.files.map((file) => file.path) : [];
|
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(
|
const conditionCheck = await ConditionCheckService.submitConditionCheck(
|
||||||
rentalId,
|
rentalId,
|
||||||
checkType,
|
checkType,
|
||||||
userId,
|
userId,
|
||||||
photos,
|
photos,
|
||||||
notes,
|
notes
|
||||||
metadata
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
|||||||
@@ -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) => {
|
router.get('/:id', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const message = await Message.findOne({
|
const message = await Message.findOne({
|
||||||
@@ -192,15 +192,6 @@ router.get('/:id', authenticateToken, async (req, res) => {
|
|||||||
model: User,
|
model: User,
|
||||||
as: 'receiver',
|
as: 'receiver',
|
||||||
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
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
|
// Send a new message
|
||||||
router.post('/', authenticateToken, uploadMessageImage, async (req, res) => {
|
router.post('/', authenticateToken, uploadMessageImage, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { receiverId, subject, content, parentMessageId } = req.body;
|
const { receiverId, content } = req.body;
|
||||||
|
|
||||||
// Check if receiver exists
|
// Check if receiver exists
|
||||||
const receiver = await User.findByPk(receiverId);
|
const receiver = await User.findByPk(receiverId);
|
||||||
@@ -267,9 +258,7 @@ router.post('/', authenticateToken, uploadMessageImage, async (req, res) => {
|
|||||||
const message = await Message.create({
|
const message = await Message.create({
|
||||||
senderId: req.user.id,
|
senderId: req.user.id,
|
||||||
receiverId,
|
receiverId,
|
||||||
subject,
|
|
||||||
content,
|
content,
|
||||||
parentMessageId,
|
|
||||||
imagePath
|
imagePath
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -308,8 +297,7 @@ router.post('/', authenticateToken, uploadMessageImage, async (req, res) => {
|
|||||||
reqLogger.info("Message sent", {
|
reqLogger.info("Message sent", {
|
||||||
senderId: req.user.id,
|
senderId: req.user.id,
|
||||||
receiverId: receiverId,
|
receiverId: receiverId,
|
||||||
messageId: message.id,
|
messageId: message.id
|
||||||
isReply: !!parentMessageId
|
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(201).json(messageWithSender);
|
res.status(201).json(messageWithSender);
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
|
|||||||
endDateTime,
|
endDateTime,
|
||||||
deliveryMethod,
|
deliveryMethod,
|
||||||
deliveryAddress,
|
deliveryAddress,
|
||||||
notes,
|
intendedUse,
|
||||||
stripePaymentMethodId,
|
stripePaymentMethodId,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
@@ -274,7 +274,7 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
|
|||||||
status: "pending",
|
status: "pending",
|
||||||
deliveryMethod,
|
deliveryMethod,
|
||||||
deliveryAddress,
|
deliveryAddress,
|
||||||
notes,
|
intendedUse,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only add stripePaymentMethodId if it's provided (for paid rentals)
|
// 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)
|
// Mark item return status (owner only)
|
||||||
router.post("/:id/mark-return", authenticateToken, async (req, res) => {
|
router.post("/:id/mark-return", authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { status, actualReturnDateTime, notes, statusOptions } = req.body;
|
const { status, actualReturnDateTime, statusOptions } = req.body;
|
||||||
const rentalId = req.params.id;
|
const rentalId = req.params.id;
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
|
|
||||||
@@ -1133,7 +1133,6 @@ router.post("/:id/mark-return", authenticateToken, async (req, res) => {
|
|||||||
status: "completed",
|
status: "completed",
|
||||||
payoutStatus: "pending",
|
payoutStatus: "pending",
|
||||||
actualReturnDateTime: actualReturnDateTime || rental.endDateTime,
|
actualReturnDateTime: actualReturnDateTime || rental.endDateTime,
|
||||||
notes: notes || null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch full rental details with associations for email
|
// Fetch full rental details with associations for email
|
||||||
@@ -1177,15 +1176,13 @@ router.post("/:id/mark-return", authenticateToken, async (req, res) => {
|
|||||||
status: "damaged",
|
status: "damaged",
|
||||||
payoutStatus: "pending",
|
payoutStatus: "pending",
|
||||||
actualReturnDateTime: actualReturnDateTime || rental.endDateTime,
|
actualReturnDateTime: actualReturnDateTime || rental.endDateTime,
|
||||||
notes: notes || null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if ALSO returned late
|
// Check if ALSO returned late
|
||||||
if (statusOptions?.returned_late && actualReturnDateTime) {
|
if (statusOptions?.returned_late && actualReturnDateTime) {
|
||||||
const lateReturnDamaged = await LateReturnService.processLateReturn(
|
const lateReturnDamaged = await LateReturnService.processLateReturn(
|
||||||
rentalId,
|
rentalId,
|
||||||
actualReturnDateTime,
|
actualReturnDateTime
|
||||||
notes
|
|
||||||
);
|
);
|
||||||
damageUpdates.status = "returned_late_and_damaged";
|
damageUpdates.status = "returned_late_and_damaged";
|
||||||
damageUpdates.lateFees = lateReturnDamaged.lateCalculation.lateFee;
|
damageUpdates.lateFees = lateReturnDamaged.lateCalculation.lateFee;
|
||||||
@@ -1207,8 +1204,7 @@ router.post("/:id/mark-return", authenticateToken, async (req, res) => {
|
|||||||
|
|
||||||
const lateReturn = await LateReturnService.processLateReturn(
|
const lateReturn = await LateReturnService.processLateReturn(
|
||||||
rentalId,
|
rentalId,
|
||||||
actualReturnDateTime,
|
actualReturnDateTime
|
||||||
notes
|
|
||||||
);
|
);
|
||||||
|
|
||||||
updatedRental = lateReturn.rental;
|
updatedRental = lateReturn.rental;
|
||||||
@@ -1221,7 +1217,6 @@ router.post("/:id/mark-return", authenticateToken, async (req, res) => {
|
|||||||
status: "lost",
|
status: "lost",
|
||||||
payoutStatus: "pending",
|
payoutStatus: "pending",
|
||||||
itemLostReportedAt: new Date(),
|
itemLostReportedAt: new Date(),
|
||||||
notes: notes || null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send notification to customer service
|
// Send notification to customer service
|
||||||
|
|||||||
@@ -353,8 +353,8 @@ async function main() {
|
|||||||
const command = args[0];
|
const command = args[0];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Sync database
|
// Verify database connection
|
||||||
await sequelize.sync();
|
await sequelize.authenticate();
|
||||||
|
|
||||||
if (!command || command === "help") {
|
if (!command || command === "help") {
|
||||||
console.log(`
|
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 PORT = process.env.PORT || 5000;
|
||||||
|
|
||||||
|
const { checkPendingMigrations } = require("./utils/checkMigrations");
|
||||||
|
|
||||||
sequelize
|
sequelize
|
||||||
.sync({ alter: true })
|
.authenticate()
|
||||||
.then(async () => {
|
.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
|
// Initialize email services and load templates
|
||||||
try {
|
try {
|
||||||
@@ -209,8 +222,9 @@ sequelize
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
logger.error("Unable to sync database", {
|
logger.error("Unable to connect to database", {
|
||||||
error: err.message,
|
error: err.message,
|
||||||
stack: err.stack,
|
stack: err.stack,
|
||||||
});
|
});
|
||||||
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -118,7 +118,6 @@ class ConditionCheckService {
|
|||||||
* @param {string} userId - User submitting the check
|
* @param {string} userId - User submitting the check
|
||||||
* @param {Array} photos - Array of photo URLs
|
* @param {Array} photos - Array of photo URLs
|
||||||
* @param {string} notes - Optional notes
|
* @param {string} notes - Optional notes
|
||||||
* @param {Object} metadata - Additional metadata (device info, location, etc.)
|
|
||||||
* @returns {Object} - Created condition check
|
* @returns {Object} - Created condition check
|
||||||
*/
|
*/
|
||||||
static async submitConditionCheck(
|
static async submitConditionCheck(
|
||||||
@@ -126,8 +125,7 @@ class ConditionCheckService {
|
|||||||
checkType,
|
checkType,
|
||||||
userId,
|
userId,
|
||||||
photos = [],
|
photos = [],
|
||||||
notes = null,
|
notes = null
|
||||||
metadata = {}
|
|
||||||
) {
|
) {
|
||||||
// Validate the check
|
// Validate the check
|
||||||
const validation = await this.validateConditionCheck(
|
const validation = await this.validateConditionCheck(
|
||||||
@@ -145,22 +143,12 @@ class ConditionCheckService {
|
|||||||
throw new Error("Maximum 20 photos allowed per condition check");
|
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({
|
const conditionCheck = await ConditionCheck.create({
|
||||||
rentalId,
|
rentalId,
|
||||||
checkType,
|
checkType,
|
||||||
submittedBy: userId,
|
submittedBy: userId,
|
||||||
photos,
|
photos,
|
||||||
notes,
|
notes,
|
||||||
metadata: enrichedMetadata,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return conditionCheck;
|
return conditionCheck;
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ class TemplateManager {
|
|||||||
<p><strong>Total Amount:</strong> ${{totalAmount}}</p>
|
<p><strong>Total Amount:</strong> ${{totalAmount}}</p>
|
||||||
<p><strong>Your Earnings:</strong> ${{payoutAmount}}</p>
|
<p><strong>Your Earnings:</strong> ${{payoutAmount}}</p>
|
||||||
<p><strong>Delivery Method:</strong> {{deliveryMethod}}</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><a href="{{approveUrl}}" class="button">Review & Respond</a></p>
|
||||||
<p>Please respond to this request within 24 hours.</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.firstName - Sender's first name
|
||||||
* @param {string} sender.lastName - Sender's last name
|
* @param {string} sender.lastName - Sender's last name
|
||||||
* @param {Object} message - Message object
|
* @param {Object} message - Message object
|
||||||
* @param {string} message.subject - Message subject
|
|
||||||
* @param {string} message.content - Message content
|
* @param {string} message.content - Message content
|
||||||
* @param {Date} message.createdAt - Message creation timestamp
|
* @param {Date} message.createdAt - Message creation timestamp
|
||||||
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
|
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
|
||||||
@@ -61,7 +60,6 @@ class MessagingEmailService {
|
|||||||
const variables = {
|
const variables = {
|
||||||
recipientName: receiver.firstName || "there",
|
recipientName: receiver.firstName || "there",
|
||||||
senderName: `${sender.firstName} ${sender.lastName}`.trim() || "A user",
|
senderName: `${sender.firstName} ${sender.lastName}`.trim() || "A user",
|
||||||
subject: message.subject,
|
|
||||||
messageContent: message.content,
|
messageContent: message.content,
|
||||||
conversationUrl: conversationUrl,
|
conversationUrl: conversationUrl,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ class RentalFlowEmailService {
|
|||||||
* @param {string} rental.totalAmount - Total rental amount
|
* @param {string} rental.totalAmount - Total rental amount
|
||||||
* @param {string} rental.payoutAmount - Owner's payout amount
|
* @param {string} rental.payoutAmount - Owner's payout amount
|
||||||
* @param {string} rental.deliveryMethod - Delivery method
|
* @param {string} rental.deliveryMethod - Delivery method
|
||||||
* @param {string} rental.notes - Rental notes from renter
|
|
||||||
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
|
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
|
||||||
*/
|
*/
|
||||||
async sendRentalRequestEmail(owner, renter, rental) {
|
async sendRentalRequestEmail(owner, renter, rental) {
|
||||||
@@ -88,7 +87,7 @@ class RentalFlowEmailService {
|
|||||||
? parseFloat(rental.payoutAmount).toFixed(2)
|
? parseFloat(rental.payoutAmount).toFixed(2)
|
||||||
: "0.00",
|
: "0.00",
|
||||||
deliveryMethod: rental.deliveryMethod || "Not specified",
|
deliveryMethod: rental.deliveryMethod || "Not specified",
|
||||||
rentalNotes: rental.notes || "No additional notes provided",
|
intendedUse: rental.intendedUse || "Not specified",
|
||||||
approveUrl: approveUrl,
|
approveUrl: approveUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -60,10 +60,9 @@ class LateReturnService {
|
|||||||
* Process late return and update rental with fees
|
* Process late return and update rental with fees
|
||||||
* @param {string} rentalId - Rental ID
|
* @param {string} rentalId - Rental ID
|
||||||
* @param {Date} actualReturnDateTime - When item was returned
|
* @param {Date} actualReturnDateTime - When item was returned
|
||||||
* @param {string} notes - Optional notes about the return
|
|
||||||
* @returns {Object} - Updated rental with late fee information
|
* @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, {
|
const rental = await Rental.findByPk(rentalId, {
|
||||||
include: [{ model: Item, as: "item" }],
|
include: [{ model: Item, as: "item" }],
|
||||||
});
|
});
|
||||||
@@ -84,10 +83,6 @@ class LateReturnService {
|
|||||||
payoutStatus: "pending",
|
payoutStatus: "pending",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (notes) {
|
|
||||||
updates.notes = notes;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedRental = await rental.update(updates);
|
const updatedRental = await rental.update(updates);
|
||||||
|
|
||||||
// Send notification to customer service if late return detected
|
// Send notification to customer service if late return detected
|
||||||
|
|||||||
@@ -134,13 +134,6 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-box .subject {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #495057;
|
|
||||||
margin: 0 0 15px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-box .content-text {
|
.message-box .content-text {
|
||||||
color: #212529;
|
color: #212529;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
@@ -226,7 +219,6 @@
|
|||||||
<p>{{senderName}} sent you a message on RentAll.</p>
|
<p>{{senderName}} sent you a message on RentAll.</p>
|
||||||
|
|
||||||
<div class="message-box">
|
<div class="message-box">
|
||||||
<div class="subject">Subject: {{subject}}</div>
|
|
||||||
<div class="content-text">{{messageContent}}</div>
|
<div class="content-text">{{messageContent}}</div>
|
||||||
<div class="timestamp">Sent {{timestamp}}</div>
|
<div class="timestamp">Sent {{timestamp}}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -295,8 +295,8 @@
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div class="info-box">
|
<div class="info-box">
|
||||||
<p><strong>Renter's Notes:</strong></p>
|
<p><strong>Intended Use:</strong></p>
|
||||||
<p>{{rentalNotes}}</p>
|
<p>{{intendedUse}}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="text-align: center">
|
<div style="text-align: center">
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ describe('Messages Routes', () => {
|
|||||||
id: 1,
|
id: 1,
|
||||||
senderId: 2,
|
senderId: 2,
|
||||||
receiverId: 1,
|
receiverId: 1,
|
||||||
subject: 'Test Message',
|
|
||||||
content: 'Hello there!',
|
content: 'Hello there!',
|
||||||
isRead: false,
|
isRead: false,
|
||||||
createdAt: '2024-01-15T10:00:00.000Z',
|
createdAt: '2024-01-15T10:00:00.000Z',
|
||||||
@@ -71,7 +70,6 @@ describe('Messages Routes', () => {
|
|||||||
id: 2,
|
id: 2,
|
||||||
senderId: 3,
|
senderId: 3,
|
||||||
receiverId: 1,
|
receiverId: 1,
|
||||||
subject: 'Another Message',
|
|
||||||
content: 'Hi!',
|
content: 'Hi!',
|
||||||
isRead: true,
|
isRead: true,
|
||||||
createdAt: '2024-01-14T10:00:00.000Z',
|
createdAt: '2024-01-14T10:00:00.000Z',
|
||||||
@@ -122,7 +120,6 @@ describe('Messages Routes', () => {
|
|||||||
id: 3,
|
id: 3,
|
||||||
senderId: 1,
|
senderId: 1,
|
||||||
receiverId: 2,
|
receiverId: 2,
|
||||||
subject: 'My Message',
|
|
||||||
content: 'Hello Jane!',
|
content: 'Hello Jane!',
|
||||||
isRead: false,
|
isRead: false,
|
||||||
createdAt: '2024-01-15T12:00:00.000Z',
|
createdAt: '2024-01-15T12:00:00.000Z',
|
||||||
@@ -171,7 +168,6 @@ describe('Messages Routes', () => {
|
|||||||
id: 1,
|
id: 1,
|
||||||
senderId: 2,
|
senderId: 2,
|
||||||
receiverId: 1,
|
receiverId: 1,
|
||||||
subject: 'Test Message',
|
|
||||||
content: 'Hello there!',
|
content: 'Hello there!',
|
||||||
isRead: false,
|
isRead: false,
|
||||||
createdAt: '2024-01-15T10:00:00.000Z',
|
createdAt: '2024-01-15T10:00:00.000Z',
|
||||||
@@ -187,19 +183,6 @@ describe('Messages Routes', () => {
|
|||||||
lastName: 'Doe',
|
lastName: 'Doe',
|
||||||
profileImage: 'john.jpg'
|
profileImage: 'john.jpg'
|
||||||
},
|
},
|
||||||
replies: [
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
senderId: 1,
|
|
||||||
content: 'Reply message',
|
|
||||||
sender: {
|
|
||||||
id: 1,
|
|
||||||
firstName: 'John',
|
|
||||||
lastName: 'Doe',
|
|
||||||
profileImage: 'john.jpg'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
update: jest.fn()
|
update: jest.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -207,7 +190,7 @@ describe('Messages Routes', () => {
|
|||||||
mockMessageFindOne.mockResolvedValue(mockMessage);
|
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();
|
mockMessage.update.mockResolvedValue();
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
@@ -218,7 +201,6 @@ describe('Messages Routes', () => {
|
|||||||
id: 1,
|
id: 1,
|
||||||
senderId: 2,
|
senderId: 2,
|
||||||
receiverId: 1,
|
receiverId: 1,
|
||||||
subject: 'Test Message',
|
|
||||||
content: 'Hello there!',
|
content: 'Hello there!',
|
||||||
isRead: false,
|
isRead: false,
|
||||||
createdAt: '2024-01-15T10:00:00.000Z',
|
createdAt: '2024-01-15T10:00:00.000Z',
|
||||||
@@ -233,20 +215,7 @@ describe('Messages Routes', () => {
|
|||||||
firstName: 'John',
|
firstName: 'John',
|
||||||
lastName: 'Doe',
|
lastName: 'Doe',
|
||||||
profileImage: 'john.jpg'
|
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 });
|
expect(mockMessage.update).toHaveBeenCalledWith({ isRead: true });
|
||||||
});
|
});
|
||||||
@@ -263,7 +232,6 @@ describe('Messages Routes', () => {
|
|||||||
id: 1,
|
id: 1,
|
||||||
senderId: 1,
|
senderId: 1,
|
||||||
receiverId: 2,
|
receiverId: 2,
|
||||||
subject: 'Test Message',
|
|
||||||
content: 'Hello there!',
|
content: 'Hello there!',
|
||||||
isRead: false,
|
isRead: false,
|
||||||
createdAt: '2024-01-15T10:00:00.000Z',
|
createdAt: '2024-01-15T10:00:00.000Z',
|
||||||
@@ -278,20 +246,7 @@ describe('Messages Routes', () => {
|
|||||||
firstName: 'John',
|
firstName: 'John',
|
||||||
lastName: 'Doe',
|
lastName: 'Doe',
|
||||||
profileImage: 'john.jpg'
|
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();
|
expect(mockMessage.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -340,9 +295,7 @@ describe('Messages Routes', () => {
|
|||||||
id: 5,
|
id: 5,
|
||||||
senderId: 1,
|
senderId: 1,
|
||||||
receiverId: 2,
|
receiverId: 2,
|
||||||
subject: 'New Message',
|
content: 'Hello Jane!'
|
||||||
content: 'Hello Jane!',
|
|
||||||
parentMessageId: null
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockMessageWithSender = {
|
const mockMessageWithSender = {
|
||||||
@@ -364,9 +317,7 @@ describe('Messages Routes', () => {
|
|||||||
it('should create a new message', async () => {
|
it('should create a new message', async () => {
|
||||||
const messageData = {
|
const messageData = {
|
||||||
receiverId: 2,
|
receiverId: 2,
|
||||||
subject: 'New Message',
|
content: 'Hello Jane!'
|
||||||
content: 'Hello Jane!',
|
|
||||||
parentMessageId: null
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
@@ -378,31 +329,8 @@ describe('Messages Routes', () => {
|
|||||||
expect(mockMessageCreate).toHaveBeenCalledWith({
|
expect(mockMessageCreate).toHaveBeenCalledWith({
|
||||||
senderId: 1,
|
senderId: 1,
|
||||||
receiverId: 2,
|
receiverId: 2,
|
||||||
subject: 'New Message',
|
|
||||||
content: 'Hello Jane!',
|
content: 'Hello Jane!',
|
||||||
parentMessageId: null
|
imagePath: 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
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -413,7 +341,6 @@ describe('Messages Routes', () => {
|
|||||||
.post('/messages')
|
.post('/messages')
|
||||||
.send({
|
.send({
|
||||||
receiverId: 999,
|
receiverId: 999,
|
||||||
subject: 'Test',
|
|
||||||
content: 'Test message'
|
content: 'Test message'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -426,7 +353,6 @@ describe('Messages Routes', () => {
|
|||||||
.post('/messages')
|
.post('/messages')
|
||||||
.send({
|
.send({
|
||||||
receiverId: 1, // Same as sender ID
|
receiverId: 1, // Same as sender ID
|
||||||
subject: 'Self Message',
|
|
||||||
content: 'Hello self!'
|
content: 'Hello self!'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -441,7 +367,6 @@ describe('Messages Routes', () => {
|
|||||||
.post('/messages')
|
.post('/messages')
|
||||||
.send({
|
.send({
|
||||||
receiverId: 2,
|
receiverId: 2,
|
||||||
subject: 'Test',
|
|
||||||
content: 'Test message'
|
content: 'Test message'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -596,62 +521,5 @@ describe('Messages Routes', () => {
|
|||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body).toEqual([]);
|
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',
|
endDateTime: '2024-01-15T18:00:00.000Z',
|
||||||
deliveryMethod: 'pickup',
|
deliveryMethod: 'pickup',
|
||||||
deliveryAddress: null,
|
deliveryAddress: null,
|
||||||
notes: 'Test rental',
|
|
||||||
stripePaymentMethodId: 'pm_test123',
|
stripePaymentMethodId: 'pm_test123',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ describe('ConditionCheckService', () => {
|
|||||||
submittedBy: 'renter-789',
|
submittedBy: 'renter-789',
|
||||||
photos: mockPhotos,
|
photos: mockPhotos,
|
||||||
notes: 'Item received in good condition',
|
notes: 'Item received in good condition',
|
||||||
metadata: expect.any(Object)
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -449,7 +449,6 @@ describe('EmailService', () => {
|
|||||||
totalAmount: 150.00,
|
totalAmount: 150.00,
|
||||||
payoutAmount: 135.00,
|
payoutAmount: 135.00,
|
||||||
deliveryMethod: 'pickup',
|
deliveryMethod: 'pickup',
|
||||||
notes: 'Please have it ready by 9am',
|
|
||||||
item: { name: 'Power Drill' }
|
item: { name: 'Power Drill' }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -522,7 +521,6 @@ describe('EmailService', () => {
|
|||||||
totalAmount: 0,
|
totalAmount: 0,
|
||||||
payoutAmount: 0,
|
payoutAmount: 0,
|
||||||
deliveryMethod: 'pickup',
|
deliveryMethod: 'pickup',
|
||||||
notes: null,
|
|
||||||
item: { name: 'Free Item' }
|
item: { name: 'Free Item' }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -531,39 +529,6 @@ describe('EmailService', () => {
|
|||||||
expect(result.success).toBe(true);
|
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 () => {
|
it('should generate correct approval URL', async () => {
|
||||||
const mockOwner = {
|
const mockOwner = {
|
||||||
email: 'owner@example.com',
|
email: 'owner@example.com',
|
||||||
@@ -589,7 +554,6 @@ describe('EmailService', () => {
|
|||||||
totalAmount: 100,
|
totalAmount: 100,
|
||||||
payoutAmount: 90,
|
payoutAmount: 90,
|
||||||
deliveryMethod: 'pickup',
|
deliveryMethod: 'pickup',
|
||||||
notes: 'Test notes',
|
|
||||||
item: { name: 'Test Item' }
|
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 Profile from './pages/Profile';
|
||||||
import PublicProfile from './pages/PublicProfile';
|
import PublicProfile from './pages/PublicProfile';
|
||||||
import Messages from './pages/Messages';
|
import Messages from './pages/Messages';
|
||||||
import MessageDetail from './pages/MessageDetail';
|
|
||||||
import ForumPosts from './pages/ForumPosts';
|
import ForumPosts from './pages/ForumPosts';
|
||||||
import ForumPostDetail from './pages/ForumPostDetail';
|
import ForumPostDetail from './pages/ForumPostDetail';
|
||||||
import CreateForumPost from './pages/CreateForumPost';
|
import CreateForumPost from './pages/CreateForumPost';
|
||||||
@@ -150,14 +149,6 @@ const AppContent: React.FC = () => {
|
|||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="/messages/:id"
|
|
||||||
element={
|
|
||||||
<PrivateRoute>
|
|
||||||
<MessageDetail />
|
|
||||||
</PrivateRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route path="/forum" element={<ForumPosts />} />
|
<Route path="/forum" element={<ForumPosts />} />
|
||||||
<Route path="/forum/:id" element={<ForumPostDetail />} />
|
<Route path="/forum/:id" element={<ForumPostDetail />} />
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -322,7 +322,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient, onMes
|
|||||||
// Build FormData for message (with or without image)
|
// Build FormData for message (with or without image)
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('receiverId', recipient.id);
|
formData.append('receiverId', recipient.id);
|
||||||
formData.append('subject', `Message from ${currentUser?.firstName}`);
|
|
||||||
formData.append('content', messageContent || ' '); // Send space if only image
|
formData.append('content', messageContent || ' '); // Send space if only image
|
||||||
if (imageToSend) {
|
if (imageToSend) {
|
||||||
formData.append('image', imageToSend);
|
formData.append('image', imageToSend);
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ interface MessageModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MessageModal: React.FC<MessageModalProps> = ({ show, onClose, recipient, onSuccess }) => {
|
const MessageModal: React.FC<MessageModalProps> = ({ show, onClose, recipient, onSuccess }) => {
|
||||||
const [subject, setSubject] = useState('');
|
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -23,12 +22,10 @@ const MessageModal: React.FC<MessageModalProps> = ({ show, onClose, recipient, o
|
|||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('receiverId', recipient.id);
|
formData.append('receiverId', recipient.id);
|
||||||
formData.append('subject', subject);
|
|
||||||
formData.append('content', content);
|
formData.append('content', content);
|
||||||
|
|
||||||
await messageAPI.sendMessage(formData);
|
await messageAPI.sendMessage(formData);
|
||||||
|
|
||||||
setSubject('');
|
|
||||||
setContent('');
|
setContent('');
|
||||||
onClose();
|
onClose();
|
||||||
if (onSuccess) {
|
if (onSuccess) {
|
||||||
@@ -59,19 +56,6 @@ const MessageModal: React.FC<MessageModalProps> = ({ show, onClose, recipient, o
|
|||||||
</div>
|
</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">
|
<div className="mb-3">
|
||||||
<label htmlFor="content" className="form-label">Message</label>
|
<label htmlFor="content" className="form-label">Message</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -98,7 +82,7 @@ const MessageModal: React.FC<MessageModalProps> = ({ show, onClose, recipient, o
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
disabled={sending || !subject || !content}
|
disabled={sending || !content}
|
||||||
>
|
>
|
||||||
{sending ? (
|
{sending ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ const ReturnStatusModal: React.FC<ReturnStatusModalProps> = ({
|
|||||||
lost: false,
|
lost: false,
|
||||||
});
|
});
|
||||||
const [actualReturnDateTime, setActualReturnDateTime] = useState("");
|
const [actualReturnDateTime, setActualReturnDateTime] = useState("");
|
||||||
const [notes, setNotes] = useState("");
|
|
||||||
const [conditionNotes, setConditionNotes] = useState("");
|
const [conditionNotes, setConditionNotes] = useState("");
|
||||||
const [photos, setPhotos] = useState<File[]>([]);
|
const [photos, setPhotos] = useState<File[]>([]);
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
@@ -56,7 +55,6 @@ const ReturnStatusModal: React.FC<ReturnStatusModalProps> = ({
|
|||||||
lost: false,
|
lost: false,
|
||||||
});
|
});
|
||||||
setActualReturnDateTime("");
|
setActualReturnDateTime("");
|
||||||
setNotes("");
|
|
||||||
setConditionNotes("");
|
setConditionNotes("");
|
||||||
setPhotos([]);
|
setPhotos([]);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -71,13 +69,13 @@ const ReturnStatusModal: React.FC<ReturnStatusModalProps> = ({
|
|||||||
|
|
||||||
// Create blob URLs for photo previews and clean them up
|
// Create blob URLs for photo previews and clean them up
|
||||||
const photoBlobUrls = useMemo(() => {
|
const photoBlobUrls = useMemo(() => {
|
||||||
return photos.map(photo => URL.createObjectURL(photo));
|
return photos.map((photo) => URL.createObjectURL(photo));
|
||||||
}, [photos]);
|
}, [photos]);
|
||||||
|
|
||||||
// Cleanup blob URLs when photos change or component unmounts
|
// Cleanup blob URLs when photos change or component unmounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
photoBlobUrls.forEach(url => URL.revokeObjectURL(url));
|
photoBlobUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||||
};
|
};
|
||||||
}, [photoBlobUrls]);
|
}, [photoBlobUrls]);
|
||||||
|
|
||||||
@@ -202,6 +200,7 @@ const ReturnStatusModal: React.FC<ReturnStatusModalProps> = ({
|
|||||||
);
|
);
|
||||||
if (!hasSelection) {
|
if (!hasSelection) {
|
||||||
setError("Please select at least one return status option");
|
setError("Please select at least one return status option");
|
||||||
|
setProcessing(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +315,6 @@ const ReturnStatusModal: React.FC<ReturnStatusModalProps> = ({
|
|||||||
const data: any = {
|
const data: any = {
|
||||||
status: primaryStatus,
|
status: primaryStatus,
|
||||||
statusOptions, // Send all selected options
|
statusOptions, // Send all selected options
|
||||||
notes: notes.trim() || undefined,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (statusOptions.returned_late) {
|
if (statusOptions.returned_late) {
|
||||||
@@ -346,7 +344,6 @@ const ReturnStatusModal: React.FC<ReturnStatusModalProps> = ({
|
|||||||
lost: false,
|
lost: false,
|
||||||
});
|
});
|
||||||
setActualReturnDateTime("");
|
setActualReturnDateTime("");
|
||||||
setNotes("");
|
|
||||||
setConditionNotes("");
|
setConditionNotes("");
|
||||||
setPhotos([]);
|
setPhotos([]);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -950,7 +947,7 @@ const ReturnStatusModal: React.FC<ReturnStatusModalProps> = ({
|
|||||||
>
|
>
|
||||||
<span className="visually-hidden">Loading...</span>
|
<span className="visually-hidden">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
Processing...
|
Submitting...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Submit"
|
"Submit"
|
||||||
|
|||||||
@@ -69,6 +69,18 @@ const ReviewItemModal: React.FC<ReviewItemModalProps> = ({
|
|||||||
|
|
||||||
if (!show) return null;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="modal d-block"
|
className="modal d-block"
|
||||||
@@ -212,13 +224,6 @@ const ReviewItemModal: React.FC<ReviewItemModalProps> = ({
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SuccessModal
|
|
||||||
show={showSuccessModal}
|
|
||||||
onClose={handleSuccessModalClose}
|
|
||||||
title="Thank you for your review!"
|
|
||||||
message={successMessage}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -69,6 +69,18 @@ const ReviewRenterModal: React.FC<ReviewRenterModalProps> = ({
|
|||||||
|
|
||||||
if (!show) return null;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="modal d-block"
|
className="modal d-block"
|
||||||
@@ -212,13 +224,6 @@ const ReviewRenterModal: React.FC<ReviewRenterModalProps> = ({
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SuccessModal
|
|
||||||
show={showSuccessModal}
|
|
||||||
onClose={handleSuccessModalClose}
|
|
||||||
title="Thank you for your review!"
|
|
||||||
message={successMessage}
|
|
||||||
/>
|
|
||||||
</div>
|
</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}
|
<strong>Total:</strong> ${rental.totalAmount}
|
||||||
</p>
|
</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.status === "cancelled" &&
|
||||||
rental.refundAmount !== undefined && (
|
rental.refundAmount !== undefined && (
|
||||||
<div className="alert alert-info mt-2 mb-2 p-2 small">
|
<div className="alert alert-info mt-2 mb-2 p-2 small">
|
||||||
@@ -620,9 +628,7 @@ const Owning: React.FC = () => {
|
|||||||
onClick={() => toggleAvailability(item)}
|
onClick={() => toggleAvailability(item)}
|
||||||
className="btn btn-sm btn-outline-info"
|
className="btn btn-sm btn-outline-info"
|
||||||
>
|
>
|
||||||
{item.isAvailable
|
{item.isAvailable ? "Mark Unavailable" : "Mark Available"}
|
||||||
? "Mark Unavailable"
|
|
||||||
: "Mark Available"}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(item.id)}
|
onClick={() => handleDelete(item.id)}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const RentItem: React.FC = () => {
|
|||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
deliveryMethod: "pickup" as "pickup" | "delivery",
|
deliveryMethod: "pickup" as "pickup" | "delivery",
|
||||||
deliveryAddress: "",
|
deliveryAddress: "",
|
||||||
|
intendedUse: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const [manualSelection, setManualSelection] = useState({
|
const [manualSelection, setManualSelection] = useState({
|
||||||
@@ -143,6 +144,7 @@ const RentItem: React.FC = () => {
|
|||||||
endDateTime,
|
endDateTime,
|
||||||
deliveryMethod: formData.deliveryMethod,
|
deliveryMethod: formData.deliveryMethod,
|
||||||
deliveryAddress: formData.deliveryAddress,
|
deliveryAddress: formData.deliveryAddress,
|
||||||
|
intendedUse: formData.intendedUse || undefined,
|
||||||
totalAmount: totalCost,
|
totalAmount: totalCost,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -261,6 +263,26 @@ const RentItem: React.FC = () => {
|
|||||||
</p>
|
</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.startDate ||
|
||||||
!manualSelection.endDate ||
|
!manualSelection.endDate ||
|
||||||
!getRentalData() ? (
|
!getRentalData() ? (
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ export const rentalAPI = {
|
|||||||
// Return status marking
|
// Return status marking
|
||||||
markReturn: (
|
markReturn: (
|
||||||
id: string,
|
id: string,
|
||||||
data: { status: string; actualReturnDateTime?: string; notes?: string }
|
data: { status: string; actualReturnDateTime?: string }
|
||||||
) => api.post(`/rentals/${id}/mark-return`, data),
|
) => api.post(`/rentals/${id}/mark-return`, data),
|
||||||
reportDamage: (id: string, data: any) =>
|
reportDamage: (id: string, data: any) =>
|
||||||
api.post(`/rentals/${id}/report-damage`, data),
|
api.post(`/rentals/${id}/report-damage`, data),
|
||||||
|
|||||||
@@ -39,14 +39,11 @@ export interface Message {
|
|||||||
id: string;
|
id: string;
|
||||||
senderId: string;
|
senderId: string;
|
||||||
receiverId: string;
|
receiverId: string;
|
||||||
subject: string;
|
|
||||||
content: string;
|
content: string;
|
||||||
isRead: boolean;
|
isRead: boolean;
|
||||||
parentMessageId?: string;
|
|
||||||
imagePath?: string;
|
imagePath?: string;
|
||||||
sender?: User;
|
sender?: User;
|
||||||
receiver?: User;
|
receiver?: User;
|
||||||
replies?: Message[];
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -152,7 +149,7 @@ export interface Rental {
|
|||||||
stripeTransferId?: string;
|
stripeTransferId?: string;
|
||||||
deliveryMethod: "pickup" | "delivery";
|
deliveryMethod: "pickup" | "delivery";
|
||||||
deliveryAddress?: string;
|
deliveryAddress?: string;
|
||||||
notes?: string;
|
intendedUse?: string;
|
||||||
rating?: number;
|
rating?: number;
|
||||||
review?: string;
|
review?: string;
|
||||||
declineReason?: string;
|
declineReason?: string;
|
||||||
@@ -194,7 +191,6 @@ export interface ConditionCheck {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
submittedBy: string;
|
submittedBy: string;
|
||||||
submittedAt: string;
|
submittedAt: string;
|
||||||
metadata: any;
|
|
||||||
submittedByUser?: User;
|
submittedByUser?: User;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user