diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..c019fa8 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,15 @@ +FROM node:18-alpine + +WORKDIR /app + +COPY package*.json ./ + +RUN npm ci --only=production + +COPY . . + +RUN mkdir -p uploads/profiles + +EXPOSE 5000 + +CMD ["node", "server.js"] \ No newline at end of file diff --git a/backend/models/ItemRequest.js b/backend/models/ItemRequest.js new file mode 100644 index 0000000..9c4bf58 --- /dev/null +++ b/backend/models/ItemRequest.js @@ -0,0 +1,76 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../config/database'); + +const ItemRequest = sequelize.define('ItemRequest', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + title: { + type: DataTypes.STRING, + allowNull: false + }, + description: { + type: DataTypes.TEXT, + allowNull: false + }, + address1: { + type: DataTypes.STRING + }, + address2: { + type: DataTypes.STRING + }, + city: { + type: DataTypes.STRING + }, + state: { + type: DataTypes.STRING + }, + zipCode: { + type: DataTypes.STRING + }, + country: { + type: DataTypes.STRING + }, + latitude: { + type: DataTypes.DECIMAL(10, 8) + }, + longitude: { + type: DataTypes.DECIMAL(11, 8) + }, + maxPricePerHour: { + type: DataTypes.DECIMAL(10, 2) + }, + maxPricePerDay: { + type: DataTypes.DECIMAL(10, 2) + }, + preferredStartDate: { + type: DataTypes.DATE + }, + preferredEndDate: { + type: DataTypes.DATE + }, + isFlexibleDates: { + type: DataTypes.BOOLEAN, + defaultValue: true + }, + status: { + type: DataTypes.ENUM('open', 'fulfilled', 'closed'), + defaultValue: 'open' + }, + requesterId: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'Users', + key: 'id' + } + }, + responseCount: { + type: DataTypes.INTEGER, + defaultValue: 0 + } +}); + +module.exports = ItemRequest; \ No newline at end of file diff --git a/backend/models/ItemRequestResponse.js b/backend/models/ItemRequestResponse.js new file mode 100644 index 0000000..1e77114 --- /dev/null +++ b/backend/models/ItemRequestResponse.js @@ -0,0 +1,59 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../config/database'); + +const ItemRequestResponse = sequelize.define('ItemRequestResponse', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + itemRequestId: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'ItemRequests', + key: 'id' + } + }, + responderId: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'Users', + key: 'id' + } + }, + message: { + type: DataTypes.TEXT, + allowNull: false + }, + offerPricePerHour: { + type: DataTypes.DECIMAL(10, 2) + }, + offerPricePerDay: { + type: DataTypes.DECIMAL(10, 2) + }, + availableStartDate: { + type: DataTypes.DATE + }, + availableEndDate: { + type: DataTypes.DATE + }, + existingItemId: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: 'Items', + key: 'id' + } + }, + status: { + type: DataTypes.ENUM('pending', 'accepted', 'declined', 'expired'), + defaultValue: 'pending' + }, + contactInfo: { + type: DataTypes.STRING + } +}); + +module.exports = ItemRequestResponse; \ No newline at end of file diff --git a/backend/models/index.js b/backend/models/index.js index 704739b..46af393 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -3,6 +3,8 @@ const User = require('./User'); const Item = require('./Item'); const Rental = require('./Rental'); const Message = require('./Message'); +const ItemRequest = require('./ItemRequest'); +const ItemRequestResponse = require('./ItemRequestResponse'); User.hasMany(Item, { as: 'ownedItems', foreignKey: 'ownerId' }); Item.belongsTo(User, { as: 'owner', foreignKey: 'ownerId' }); @@ -22,10 +24,21 @@ Message.belongsTo(User, { as: 'receiver', foreignKey: 'receiverId' }); Message.hasMany(Message, { as: 'replies', foreignKey: 'parentMessageId' }); Message.belongsTo(Message, { as: 'parentMessage', foreignKey: 'parentMessageId' }); +User.hasMany(ItemRequest, { as: 'itemRequests', foreignKey: 'requesterId' }); +ItemRequest.belongsTo(User, { as: 'requester', foreignKey: 'requesterId' }); + +User.hasMany(ItemRequestResponse, { as: 'itemRequestResponses', foreignKey: 'responderId' }); +ItemRequest.hasMany(ItemRequestResponse, { as: 'responses', foreignKey: 'itemRequestId' }); +ItemRequestResponse.belongsTo(User, { as: 'responder', foreignKey: 'responderId' }); +ItemRequestResponse.belongsTo(ItemRequest, { as: 'itemRequest', foreignKey: 'itemRequestId' }); +ItemRequestResponse.belongsTo(Item, { as: 'existingItem', foreignKey: 'existingItemId' }); + module.exports = { sequelize, User, Item, Rental, - Message + Message, + ItemRequest, + ItemRequestResponse }; \ No newline at end of file diff --git a/backend/routes/itemRequests.js b/backend/routes/itemRequests.js new file mode 100644 index 0000000..4826d5b --- /dev/null +++ b/backend/routes/itemRequests.js @@ -0,0 +1,286 @@ +const express = require('express'); +const { Op } = require('sequelize'); +const { ItemRequest, ItemRequestResponse, User, Item } = require('../models'); +const { authenticateToken } = require('../middleware/auth'); +const router = express.Router(); + +router.get('/', async (req, res) => { + try { + const { + search, + status = 'open', + page = 1, + limit = 20 + } = req.query; + + const where = { status }; + + if (search) { + where[Op.or] = [ + { title: { [Op.iLike]: `%${search}%` } }, + { description: { [Op.iLike]: `%${search}%` } } + ]; + } + + const offset = (page - 1) * limit; + + const { count, rows } = await ItemRequest.findAndCountAll({ + where, + include: [ + { + model: User, + as: 'requester', + attributes: ['id', 'username', 'firstName', 'lastName'] + } + ], + limit: parseInt(limit), + offset: parseInt(offset), + order: [['createdAt', 'DESC']] + }); + + res.json({ + requests: rows, + totalPages: Math.ceil(count / limit), + currentPage: parseInt(page), + totalRequests: count + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.get('/my-requests', authenticateToken, async (req, res) => { + try { + const requests = await ItemRequest.findAll({ + where: { requesterId: req.user.id }, + include: [ + { + model: User, + as: 'requester', + attributes: ['id', 'username', 'firstName', 'lastName'] + }, + { + model: ItemRequestResponse, + as: 'responses', + include: [ + { + model: User, + as: 'responder', + attributes: ['id', 'username', 'firstName', 'lastName'] + }, + { + model: Item, + as: 'existingItem' + } + ] + } + ], + order: [['createdAt', 'DESC']] + }); + + res.json(requests); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.get('/:id', async (req, res) => { + try { + const request = await ItemRequest.findByPk(req.params.id, { + include: [ + { + model: User, + as: 'requester', + attributes: ['id', 'username', 'firstName', 'lastName'] + }, + { + model: ItemRequestResponse, + as: 'responses', + include: [ + { + model: User, + as: 'responder', + attributes: ['id', 'username', 'firstName', 'lastName'] + }, + { + model: Item, + as: 'existingItem' + } + ] + } + ] + }); + + if (!request) { + return res.status(404).json({ error: 'Item request not found' }); + } + + res.json(request); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.post('/', authenticateToken, async (req, res) => { + try { + const request = await ItemRequest.create({ + ...req.body, + requesterId: req.user.id + }); + + const requestWithRequester = await ItemRequest.findByPk(request.id, { + include: [ + { + model: User, + as: 'requester', + attributes: ['id', 'username', 'firstName', 'lastName'] + } + ] + }); + + res.status(201).json(requestWithRequester); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.put('/:id', authenticateToken, async (req, res) => { + try { + const request = await ItemRequest.findByPk(req.params.id); + + if (!request) { + return res.status(404).json({ error: 'Item request not found' }); + } + + if (request.requesterId !== req.user.id) { + return res.status(403).json({ error: 'Unauthorized' }); + } + + await request.update(req.body); + + const updatedRequest = await ItemRequest.findByPk(request.id, { + include: [ + { + model: User, + as: 'requester', + attributes: ['id', 'username', 'firstName', 'lastName'] + } + ] + }); + + res.json(updatedRequest); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.delete('/:id', authenticateToken, async (req, res) => { + try { + const request = await ItemRequest.findByPk(req.params.id); + + if (!request) { + return res.status(404).json({ error: 'Item request not found' }); + } + + if (request.requesterId !== req.user.id) { + return res.status(403).json({ error: 'Unauthorized' }); + } + + await request.destroy(); + res.status(204).send(); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.post('/:id/responses', authenticateToken, async (req, res) => { + try { + const request = await ItemRequest.findByPk(req.params.id); + + if (!request) { + return res.status(404).json({ error: 'Item request not found' }); + } + + if (request.requesterId === req.user.id) { + return res.status(400).json({ error: 'Cannot respond to your own request' }); + } + + if (request.status !== 'open') { + return res.status(400).json({ error: 'Cannot respond to closed request' }); + } + + const response = await ItemRequestResponse.create({ + ...req.body, + itemRequestId: req.params.id, + responderId: req.user.id + }); + + await request.increment('responseCount'); + + const responseWithDetails = await ItemRequestResponse.findByPk(response.id, { + include: [ + { + model: User, + as: 'responder', + attributes: ['id', 'username', 'firstName', 'lastName'] + }, + { + model: Item, + as: 'existingItem' + } + ] + }); + + res.status(201).json(responseWithDetails); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.put('/responses/:responseId/status', authenticateToken, async (req, res) => { + try { + const { status } = req.body; + const response = await ItemRequestResponse.findByPk(req.params.responseId, { + include: [ + { + model: ItemRequest, + as: 'itemRequest' + } + ] + }); + + if (!response) { + return res.status(404).json({ error: 'Response not found' }); + } + + if (response.itemRequest.requesterId !== req.user.id) { + return res.status(403).json({ error: 'Only the requester can update response status' }); + } + + await response.update({ status }); + + if (status === 'accepted') { + await response.itemRequest.update({ status: 'fulfilled' }); + } + + const updatedResponse = await ItemRequestResponse.findByPk(response.id, { + include: [ + { + model: User, + as: 'responder', + attributes: ['id', 'username', 'firstName', 'lastName'] + }, + { + model: Item, + as: 'existingItem' + } + ] + }); + + res.json(updatedResponse); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index a9051d2..f1c0618 100644 --- a/backend/server.js +++ b/backend/server.js @@ -18,6 +18,7 @@ const itemRoutes = require("./routes/items"); const rentalRoutes = require("./routes/rentals"); const messageRoutes = require("./routes/messages"); const betaRoutes = require("./routes/beta"); +const itemRequestRoutes = require("./routes/itemRequests"); const app = express(); @@ -37,6 +38,7 @@ app.use("/api/users", userRoutes); app.use("/api/items", itemRoutes); app.use("/api/rentals", rentalRoutes); app.use("/api/messages", messageRoutes); +app.use("/api/item-requests", itemRequestRoutes); app.get("/", (req, res) => { res.json({ message: "CommunityRentals.App API is running!" }); diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..e8110ec --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,34 @@ +# Build stage +FROM node:18-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ + +RUN npm ci + +COPY . . + +RUN npm run build + +# Production stage +FROM nginx:alpine + +COPY --from=builder /app/build /usr/share/nginx/html + +RUN echo 'server { \ + listen 80; \ + location / { \ + root /usr/share/nginx/html; \ + index index.html index.htm; \ + try_files $uri $uri/ /index.html; \ + } \ + error_page 500 502 503 504 /50x.html; \ + location = /50x.html { \ + root /usr/share/nginx/html; \ + } \ +}' > /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 002a682..305e847 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,10 @@ import Profile from './pages/Profile'; import PublicProfile from './pages/PublicProfile'; import Messages from './pages/Messages'; import MessageDetail from './pages/MessageDetail'; +import ItemRequests from './pages/ItemRequests'; +import ItemRequestDetail from './pages/ItemRequestDetail'; +import CreateItemRequest from './pages/CreateItemRequest'; +import MyRequests from './pages/MyRequests'; import PrivateRoute from './components/PrivateRoute'; import './App.css'; @@ -99,6 +103,24 @@ function App() { } + /> + } /> + } /> + + + + } + /> + + + + } /> diff --git a/frontend/src/components/ItemRequestCard.tsx b/frontend/src/components/ItemRequestCard.tsx new file mode 100644 index 0000000..3d448e5 --- /dev/null +++ b/frontend/src/components/ItemRequestCard.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { ItemRequest } from '../types'; + +interface ItemRequestCardProps { + request: ItemRequest; + showActions?: boolean; +} + +const ItemRequestCard: React.FC = ({ request, showActions = true }) => { + const formatDate = (dateString?: string) => { + if (!dateString) return 'Flexible'; + const date = new Date(dateString); + return date.toLocaleDateString(); + }; + + const getLocationString = () => { + const parts = []; + if (request.city) parts.push(request.city); + if (request.state) parts.push(request.state); + return parts.join(', ') || 'Location not specified'; + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'open': + return 'success'; + case 'fulfilled': + return 'primary'; + case 'closed': + return 'secondary'; + default: + return 'secondary'; + } + }; + + return ( +
+
+
+
{request.title}
+ + {request.status.charAt(0).toUpperCase() + request.status.slice(1)} + +
+ +

+ {request.description.length > 100 + ? `${request.description.substring(0, 100)}...` + : request.description + } +

+ +
+ + + {getLocationString()} + +
+ +
+ + + Requested by {request.requester?.firstName || 'Unknown'} + +
+ + {(request.maxPricePerDay || request.maxPricePerHour) && ( +
+ + + Budget: + {request.maxPricePerDay && ` $${request.maxPricePerDay}/day`} + {request.maxPricePerHour && ` $${request.maxPricePerHour}/hour`} + +
+ )} + +
+ + + Dates: {formatDate(request.preferredStartDate)} - {formatDate(request.preferredEndDate)} + {request.isFlexibleDates && ' (Flexible)'} + +
+ +
+ + {request.responseCount || 0} response{request.responseCount !== 1 ? 's' : ''} + + + {new Date(request.createdAt).toLocaleDateString()} + +
+ + {showActions && ( +
+ + View Details + +
+ )} +
+
+ ); +}; + +export default ItemRequestCard; \ No newline at end of file diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index c4f323f..7806549 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -88,6 +88,11 @@ const Navbar: React.FC = () => { My Listings +
  • + + My Requests + +
  • Messages diff --git a/frontend/src/components/RequestResponseModal.tsx b/frontend/src/components/RequestResponseModal.tsx new file mode 100644 index 0000000..3e7084f --- /dev/null +++ b/frontend/src/components/RequestResponseModal.tsx @@ -0,0 +1,257 @@ +import React, { useState, useEffect } from 'react'; +import { useAuth } from '../contexts/AuthContext'; +import { itemRequestAPI, itemAPI } from '../services/api'; +import { ItemRequest, Item } from '../types'; + +interface RequestResponseModalProps { + show: boolean; + onHide: () => void; + request: ItemRequest | null; + onResponseSubmitted: () => void; +} + +const RequestResponseModal: React.FC = ({ + show, + onHide, + request, + onResponseSubmitted +}) => { + const { user } = useAuth(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [userItems, setUserItems] = useState([]); + + const [formData, setFormData] = useState({ + message: '', + offerPricePerHour: '', + offerPricePerDay: '', + availableStartDate: '', + availableEndDate: '', + existingItemId: '', + contactInfo: '' + }); + + useEffect(() => { + if (show && user) { + fetchUserItems(); + resetForm(); + } + }, [show, user]); + + const fetchUserItems = async () => { + try { + const response = await itemAPI.getItems({ owner: user?.id }); + setUserItems(response.data.items || []); + } catch (err) { + console.error('Failed to fetch user items:', err); + } + }; + + const resetForm = () => { + setFormData({ + message: '', + offerPricePerHour: '', + offerPricePerDay: '', + availableStartDate: '', + availableEndDate: '', + existingItemId: '', + contactInfo: '' + }); + setError(null); + }; + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!request || !user) return; + + setLoading(true); + setError(null); + + try { + const responseData = { + ...formData, + offerPricePerHour: formData.offerPricePerHour ? parseFloat(formData.offerPricePerHour) : null, + offerPricePerDay: formData.offerPricePerDay ? parseFloat(formData.offerPricePerDay) : null, + existingItemId: formData.existingItemId || null, + availableStartDate: formData.availableStartDate || null, + availableEndDate: formData.availableEndDate || null + }; + + await itemRequestAPI.respondToRequest(request.id, responseData); + onResponseSubmitted(); + onHide(); + } catch (err: any) { + setError(err.response?.data?.error || 'Failed to submit response'); + } finally { + setLoading(false); + } + }; + + if (!request) return null; + + return ( +
    +
    +
    +
    +
    Respond to Request
    + +
    + +
    +
    +
    {request.title}
    +

    {request.description}

    +
    + + {error && ( +
    + {error} +
    + )} + +
    +
    + +