Initial commit - Rentall App

- Full-stack rental marketplace application
- React frontend with TypeScript
- Node.js/Express backend with JWT authentication
- Features: item listings, rental requests, calendar availability, user profiles
This commit is contained in:
jackiettran
2025-07-15 21:21:09 -04:00
commit c09384e3ea
53 changed files with 24425 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
const { Sequelize } = require('sequelize');
const sequelize = new Sequelize(
process.env.DB_NAME,
process.env.DB_USER,
process.env.DB_PASSWORD,
{
host: process.env.DB_HOST,
port: process.env.DB_PORT,
dialect: 'postgres',
logging: false,
pool: {
max: 5,
min: 0,
acquire: 30000,
idle: 10000
}
}
);
module.exports = sequelize;

View File

@@ -0,0 +1,27 @@
const jwt = require('jsonwebtoken');
const { User } = require('../models'); // Import from models/index.js to get models with associations
const authenticateToken = async (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await User.findByPk(decoded.userId);
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
req.user = user;
next();
} catch (error) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
};
module.exports = { authenticateToken };

110
backend/models/Item.js Normal file
View File

@@ -0,0 +1,110 @@
const { DataTypes } = require('sequelize');
const sequelize = require('../config/database');
const Item = sequelize.define('Item', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
name: {
type: DataTypes.STRING,
allowNull: false
},
description: {
type: DataTypes.TEXT,
allowNull: false
},
tags: {
type: DataTypes.ARRAY(DataTypes.STRING),
defaultValue: []
},
pickUpAvailable: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
localDeliveryAvailable: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
localDeliveryRadius: {
type: DataTypes.INTEGER,
validate: {
min: 1,
max: 100
}
},
shippingAvailable: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
inPlaceUseAvailable: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
pricePerHour: {
type: DataTypes.DECIMAL(10, 2)
},
pricePerDay: {
type: DataTypes.DECIMAL(10, 2)
},
replacementCost: {
type: DataTypes.DECIMAL(10, 2),
allowNull: false
},
location: {
type: DataTypes.STRING,
allowNull: false
},
latitude: {
type: DataTypes.DECIMAL(10, 8)
},
longitude: {
type: DataTypes.DECIMAL(11, 8)
},
images: {
type: DataTypes.ARRAY(DataTypes.STRING),
defaultValue: []
},
availability: {
type: DataTypes.BOOLEAN,
defaultValue: true
},
specifications: {
type: DataTypes.JSONB,
defaultValue: {}
},
rules: {
type: DataTypes.TEXT
},
minimumRentalDays: {
type: DataTypes.INTEGER,
defaultValue: 1
},
maximumRentalDays: {
type: DataTypes.INTEGER
},
needsTraining: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
unavailablePeriods: {
type: DataTypes.JSONB,
defaultValue: []
},
ownerId: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'Users',
key: 'id'
}
}
});
module.exports = Item;

76
backend/models/Rental.js Normal file
View File

@@ -0,0 +1,76 @@
const { DataTypes } = require('sequelize');
const sequelize = require('../config/database');
const Rental = sequelize.define('Rental', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
itemId: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'Items',
key: 'id'
}
},
renterId: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'Users',
key: 'id'
}
},
ownerId: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'Users',
key: 'id'
}
},
startDate: {
type: DataTypes.DATE,
allowNull: false
},
endDate: {
type: DataTypes.DATE,
allowNull: false
},
totalAmount: {
type: DataTypes.DECIMAL(10, 2),
allowNull: false
},
status: {
type: DataTypes.ENUM('pending', 'confirmed', 'active', 'completed', 'cancelled'),
defaultValue: 'pending'
},
paymentStatus: {
type: DataTypes.ENUM('pending', 'paid', 'refunded'),
defaultValue: 'pending'
},
deliveryMethod: {
type: DataTypes.ENUM('pickup', 'delivery'),
defaultValue: 'pickup'
},
deliveryAddress: {
type: DataTypes.TEXT
},
notes: {
type: DataTypes.TEXT
},
rating: {
type: DataTypes.INTEGER,
validate: {
min: 1,
max: 5
}
},
review: {
type: DataTypes.TEXT
}
});
module.exports = Rental;

66
backend/models/User.js Normal file
View File

@@ -0,0 +1,66 @@
const { DataTypes } = require('sequelize');
const sequelize = require('../config/database');
const bcrypt = require('bcryptjs');
const User = sequelize.define('User', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
username: {
type: DataTypes.STRING,
unique: true,
allowNull: false
},
email: {
type: DataTypes.STRING,
unique: true,
allowNull: false,
validate: {
isEmail: true
}
},
password: {
type: DataTypes.STRING,
allowNull: false
},
firstName: {
type: DataTypes.STRING,
allowNull: false
},
lastName: {
type: DataTypes.STRING,
allowNull: false
},
phone: {
type: DataTypes.STRING
},
address: {
type: DataTypes.TEXT
},
profileImage: {
type: DataTypes.STRING
},
isVerified: {
type: DataTypes.BOOLEAN,
defaultValue: false
}
}, {
hooks: {
beforeCreate: async (user) => {
user.password = await bcrypt.hash(user.password, 10);
},
beforeUpdate: async (user) => {
if (user.changed('password')) {
user.password = await bcrypt.hash(user.password, 10);
}
}
}
});
User.prototype.comparePassword = async function(password) {
return bcrypt.compare(password, this.password);
};
module.exports = User;

22
backend/models/index.js Normal file
View File

@@ -0,0 +1,22 @@
const sequelize = require('../config/database');
const User = require('./User');
const Item = require('./Item');
const Rental = require('./Rental');
User.hasMany(Item, { as: 'ownedItems', foreignKey: 'ownerId' });
Item.belongsTo(User, { as: 'owner', foreignKey: 'ownerId' });
User.hasMany(Rental, { as: 'rentalsAsRenter', foreignKey: 'renterId' });
User.hasMany(Rental, { as: 'rentalsAsOwner', foreignKey: 'ownerId' });
Item.hasMany(Rental, { as: 'rentals', foreignKey: 'itemId' });
Rental.belongsTo(Item, { as: 'item', foreignKey: 'itemId' });
Rental.belongsTo(User, { as: 'renter', foreignKey: 'renterId' });
Rental.belongsTo(User, { as: 'owner', foreignKey: 'ownerId' });
module.exports = {
sequelize,
User,
Item,
Rental
};

2364
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
backend/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "backend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"bcryptjs": "^3.0.2",
"body-parser": "^2.2.0",
"cors": "^2.8.5",
"dotenv": "^17.2.0",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.2",
"pg": "^8.16.3",
"sequelize": "^6.37.7",
"sequelize-cli": "^6.6.3"
},
"devDependencies": {
"nodemon": "^3.1.10"
}
}

77
backend/routes/auth.js Normal file
View File

@@ -0,0 +1,77 @@
const express = require('express');
const jwt = require('jsonwebtoken');
const { User } = require('../models'); // Import from models/index.js to get models with associations
const router = express.Router();
router.post('/register', async (req, res) => {
try {
const { username, email, password, firstName, lastName, phone } = req.body;
const existingUser = await User.findOne({
where: {
[require('sequelize').Op.or]: [{ email }, { username }]
}
});
if (existingUser) {
return res.status(400).json({ error: 'User already exists' });
}
const user = await User.create({
username,
email,
password,
firstName,
lastName,
phone
});
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, {
expiresIn: '7d'
});
res.status(201).json({
user: {
id: user.id,
username: user.username,
email: user.email,
firstName: user.firstName,
lastName: user.lastName
},
token
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
const user = await User.findOne({ where: { email } });
if (!user || !(await user.comparePassword(password))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, {
expiresIn: '7d'
});
res.json({
user: {
id: user.id,
username: user.username,
email: user.email,
firstName: user.firstName,
lastName: user.lastName
},
token
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;

164
backend/routes/items.js Normal file
View File

@@ -0,0 +1,164 @@
const express = require('express');
const { Op } = require('sequelize');
const { Item, User, Rental } = require('../models'); // Import from models/index.js to get models with associations
const { authenticateToken } = require('../middleware/auth');
const router = express.Router();
router.get('/', async (req, res) => {
try {
const {
tags,
isPortable,
minPrice,
maxPrice,
location,
search,
page = 1,
limit = 20
} = req.query;
const where = {};
if (tags) {
const tagsArray = Array.isArray(tags) ? tags : [tags];
where.tags = { [Op.overlap]: tagsArray };
}
if (isPortable !== undefined) where.isPortable = isPortable === 'true';
if (minPrice || maxPrice) {
where.pricePerDay = {};
if (minPrice) where.pricePerDay[Op.gte] = minPrice;
if (maxPrice) where.pricePerDay[Op.lte] = maxPrice;
}
if (location) where.location = { [Op.iLike]: `%${location}%` };
if (search) {
where[Op.or] = [
{ name: { [Op.iLike]: `%${search}%` } },
{ description: { [Op.iLike]: `%${search}%` } }
];
}
const offset = (page - 1) * limit;
const { count, rows } = await Item.findAndCountAll({
where,
include: [{ model: User, as: 'owner', attributes: ['id', 'username', 'firstName', 'lastName'] }],
limit: parseInt(limit),
offset: parseInt(offset),
order: [['createdAt', 'DESC']]
});
res.json({
items: rows,
totalPages: Math.ceil(count / limit),
currentPage: parseInt(page),
totalItems: count
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.get('/recommendations', authenticateToken, async (req, res) => {
try {
const userRentals = await Rental.findAll({
where: { renterId: req.user.id },
include: [{ model: Item, as: 'item' }]
});
const rentedTags = userRentals.reduce((tags, rental) => {
return [...tags, ...(rental.item.tags || [])];
}, []);
const uniqueTags = [...new Set(rentedTags)];
const recommendations = await Item.findAll({
where: {
tags: { [Op.overlap]: uniqueTags },
availability: true
},
limit: 10,
order: [['createdAt', 'DESC']]
});
res.json(recommendations);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.get('/:id', async (req, res) => {
try {
const item = await Item.findByPk(req.params.id, {
include: [{ model: User, as: 'owner', attributes: ['id', 'username', 'firstName', 'lastName'] }]
});
if (!item) {
return res.status(404).json({ error: 'Item not found' });
}
res.json(item);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.post('/', authenticateToken, async (req, res) => {
try {
const item = await Item.create({
...req.body,
ownerId: req.user.id
});
const itemWithOwner = await Item.findByPk(item.id, {
include: [{ model: User, as: 'owner', attributes: ['id', 'username', 'firstName', 'lastName'] }]
});
res.status(201).json(itemWithOwner);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.put('/:id', authenticateToken, async (req, res) => {
try {
const item = await Item.findByPk(req.params.id);
if (!item) {
return res.status(404).json({ error: 'Item not found' });
}
if (item.ownerId !== req.user.id) {
return res.status(403).json({ error: 'Unauthorized' });
}
await item.update(req.body);
const updatedItem = await Item.findByPk(item.id, {
include: [{ model: User, as: 'owner', attributes: ['id', 'username', 'firstName', 'lastName'] }]
});
res.json(updatedItem);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.delete('/:id', authenticateToken, async (req, res) => {
try {
const item = await Item.findByPk(req.params.id);
if (!item) {
return res.status(404).json({ error: 'Item not found' });
}
if (item.ownerId !== req.user.id) {
return res.status(403).json({ error: 'Unauthorized' });
}
await item.destroy();
res.status(204).send();
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;

156
backend/routes/rentals.js Normal file
View File

@@ -0,0 +1,156 @@
const express = require('express');
const { Op } = require('sequelize');
const { Rental, Item, User } = require('../models'); // Import from models/index.js to get models with associations
const { authenticateToken } = require('../middleware/auth');
const router = express.Router();
router.get('/my-rentals', authenticateToken, async (req, res) => {
try {
const rentals = await Rental.findAll({
where: { renterId: req.user.id },
include: [
{ model: Item, as: 'item' },
{ model: User, as: 'owner', attributes: ['id', 'username', 'firstName', 'lastName'] }
],
order: [['createdAt', 'DESC']]
});
res.json(rentals);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.get('/my-listings', authenticateToken, async (req, res) => {
try {
const rentals = await Rental.findAll({
where: { ownerId: req.user.id },
include: [
{ model: Item, as: 'item' },
{ model: User, as: 'renter', attributes: ['id', 'username', 'firstName', 'lastName'] }
],
order: [['createdAt', 'DESC']]
});
res.json(rentals);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.post('/', authenticateToken, async (req, res) => {
try {
const { itemId, startDate, endDate, deliveryMethod, deliveryAddress, notes } = req.body;
const item = await Item.findByPk(itemId);
if (!item) {
return res.status(404).json({ error: 'Item not found' });
}
if (!item.availability) {
return res.status(400).json({ error: 'Item is not available' });
}
const overlappingRental = await Rental.findOne({
where: {
itemId,
status: { [Op.in]: ['confirmed', 'active'] },
[Op.or]: [
{
startDate: { [Op.between]: [startDate, endDate] }
},
{
endDate: { [Op.between]: [startDate, endDate] }
}
]
}
});
if (overlappingRental) {
return res.status(400).json({ error: 'Item is already booked for these dates' });
}
const rentalDays = Math.ceil((new Date(endDate) - new Date(startDate)) / (1000 * 60 * 60 * 24));
const totalAmount = rentalDays * (item.pricePerDay || 0);
const rental = await Rental.create({
itemId,
renterId: req.user.id,
ownerId: item.ownerId,
startDate,
endDate,
totalAmount,
deliveryMethod,
deliveryAddress,
notes
});
const rentalWithDetails = await Rental.findByPk(rental.id, {
include: [
{ model: Item, as: 'item' },
{ model: User, as: 'owner', attributes: ['id', 'username', 'firstName', 'lastName'] },
{ model: User, as: 'renter', attributes: ['id', 'username', 'firstName', 'lastName'] }
]
});
res.status(201).json(rentalWithDetails);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.put('/:id/status', authenticateToken, async (req, res) => {
try {
const { status } = req.body;
const rental = await Rental.findByPk(req.params.id);
if (!rental) {
return res.status(404).json({ error: 'Rental not found' });
}
if (rental.ownerId !== req.user.id && rental.renterId !== req.user.id) {
return res.status(403).json({ error: 'Unauthorized' });
}
await rental.update({ status });
const updatedRental = await Rental.findByPk(rental.id, {
include: [
{ model: Item, as: 'item' },
{ model: User, as: 'owner', attributes: ['id', 'username', 'firstName', 'lastName'] },
{ model: User, as: 'renter', attributes: ['id', 'username', 'firstName', 'lastName'] }
]
});
res.json(updatedRental);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.post('/:id/review', authenticateToken, async (req, res) => {
try {
const { rating, review } = req.body;
const rental = await Rental.findByPk(req.params.id);
if (!rental) {
return res.status(404).json({ error: 'Rental not found' });
}
if (rental.renterId !== req.user.id) {
return res.status(403).json({ error: 'Only renters can leave reviews' });
}
if (rental.status !== 'completed') {
return res.status(400).json({ error: 'Can only review completed rentals' });
}
await rental.update({ rating, review });
res.json(rental);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;

38
backend/routes/users.js Normal file
View File

@@ -0,0 +1,38 @@
const express = require('express');
const { User } = require('../models'); // Import from models/index.js to get models with associations
const { authenticateToken } = require('../middleware/auth');
const router = express.Router();
router.get('/profile', authenticateToken, async (req, res) => {
try {
const user = await User.findByPk(req.user.id, {
attributes: { exclude: ['password'] }
});
res.json(user);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.put('/profile', authenticateToken, async (req, res) => {
try {
const { firstName, lastName, phone, address } = req.body;
await req.user.update({
firstName,
lastName,
phone,
address
});
const updatedUser = await User.findByPk(req.user.id, {
attributes: { exclude: ['password'] }
});
res.json(updatedUser);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;

36
backend/server.js Normal file
View File

@@ -0,0 +1,36 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const bodyParser = require('body-parser');
const { sequelize } = require('./models'); // Import from models/index.js to ensure associations are loaded
const authRoutes = require('./routes/auth');
const userRoutes = require('./routes/users');
const itemRoutes = require('./routes/items');
const rentalRoutes = require('./routes/rentals');
const app = express();
app.use(cors());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use('/api/items', itemRoutes);
app.use('/api/rentals', rentalRoutes);
app.get('/', (req, res) => {
res.json({ message: 'Rentall API is running!' });
});
const PORT = process.env.PORT || 5000;
sequelize.sync({ alter: true }).then(() => {
console.log('Database synced');
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
}).catch(err => {
console.error('Unable to sync database:', err);
});