diff --git a/backend/models/ForumComment.js b/backend/models/ForumComment.js new file mode 100644 index 0000000..b5a978a --- /dev/null +++ b/backend/models/ForumComment.js @@ -0,0 +1,44 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../config/database'); + +const ForumComment = sequelize.define('ForumComment', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + postId: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'ForumPosts', + key: 'id' + } + }, + authorId: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'Users', + key: 'id' + } + }, + content: { + type: DataTypes.TEXT, + allowNull: false + }, + parentCommentId: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: 'ForumComments', + key: 'id' + } + }, + isDeleted: { + type: DataTypes.BOOLEAN, + defaultValue: false + } +}); + +module.exports = ForumComment; diff --git a/backend/models/ForumPost.js b/backend/models/ForumPost.js new file mode 100644 index 0000000..8f43905 --- /dev/null +++ b/backend/models/ForumPost.js @@ -0,0 +1,49 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../config/database'); + +const ForumPost = sequelize.define('ForumPost', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + title: { + type: DataTypes.STRING, + allowNull: false + }, + content: { + type: DataTypes.TEXT, + allowNull: false + }, + authorId: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'Users', + key: 'id' + } + }, + category: { + type: DataTypes.ENUM('item_request', 'technical_support', 'community_resources', 'general_discussion'), + allowNull: false, + defaultValue: 'general_discussion' + }, + status: { + type: DataTypes.ENUM('open', 'solved', 'closed'), + defaultValue: 'open' + }, + viewCount: { + type: DataTypes.INTEGER, + defaultValue: 0 + }, + commentCount: { + type: DataTypes.INTEGER, + defaultValue: 0 + }, + isPinned: { + type: DataTypes.BOOLEAN, + defaultValue: false + } +}); + +module.exports = ForumPost; diff --git a/backend/models/ItemRequest.js b/backend/models/ItemRequest.js deleted file mode 100644 index 9c4bf58..0000000 --- a/backend/models/ItemRequest.js +++ /dev/null @@ -1,76 +0,0 @@ -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 deleted file mode 100644 index 7a17bd3..0000000 --- a/backend/models/ItemRequestResponse.js +++ /dev/null @@ -1,65 +0,0 @@ -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) - }, - offerPricePerWeek: { - type: DataTypes.DECIMAL(10, 2) - }, - offerPricePerMonth: { - 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/PostTag.js b/backend/models/PostTag.js new file mode 100644 index 0000000..f27f719 --- /dev/null +++ b/backend/models/PostTag.js @@ -0,0 +1,24 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../config/database'); + +const PostTag = sequelize.define('PostTag', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + postId: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'ForumPosts', + key: 'id' + } + }, + tagName: { + type: DataTypes.STRING, + allowNull: false + } +}); + +module.exports = PostTag; diff --git a/backend/models/index.js b/backend/models/index.js index feb2bd5..1b8c965 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -3,8 +3,9 @@ const User = require("./User"); const Item = require("./Item"); const Rental = require("./Rental"); const Message = require("./Message"); -const ItemRequest = require("./ItemRequest"); -const ItemRequestResponse = require("./ItemRequestResponse"); +const ForumPost = require("./ForumPost"); +const ForumComment = require("./ForumComment"); +const PostTag = require("./PostTag"); const UserAddress = require("./UserAddress"); const ConditionCheck = require("./ConditionCheck"); const AlphaInvitation = require("./AlphaInvitation"); @@ -31,29 +32,22 @@ Message.belongsTo(Message, { foreignKey: "parentMessageId", }); -User.hasMany(ItemRequest, { as: "itemRequests", foreignKey: "requesterId" }); -ItemRequest.belongsTo(User, { as: "requester", foreignKey: "requesterId" }); +// Forum associations +User.hasMany(ForumPost, { as: "forumPosts", foreignKey: "authorId" }); +ForumPost.belongsTo(User, { as: "author", foreignKey: "authorId" }); -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", -}); +User.hasMany(ForumComment, { as: "forumComments", foreignKey: "authorId" }); +ForumComment.belongsTo(User, { as: "author", foreignKey: "authorId" }); + +ForumPost.hasMany(ForumComment, { as: "comments", foreignKey: "postId" }); +ForumComment.belongsTo(ForumPost, { as: "post", foreignKey: "postId" }); + +// Self-referential association for nested comments +ForumComment.hasMany(ForumComment, { as: "replies", foreignKey: "parentCommentId" }); +ForumComment.belongsTo(ForumComment, { as: "parentComment", foreignKey: "parentCommentId" }); + +ForumPost.hasMany(PostTag, { as: "tags", foreignKey: "postId" }); +PostTag.belongsTo(ForumPost, { as: "post", foreignKey: "postId" }); User.hasMany(UserAddress, { as: "addresses", foreignKey: "userId" }); UserAddress.belongsTo(User, { as: "user", foreignKey: "userId" }); @@ -93,8 +87,9 @@ module.exports = { Item, Rental, Message, - ItemRequest, - ItemRequestResponse, + ForumPost, + ForumComment, + PostTag, UserAddress, ConditionCheck, AlphaInvitation, diff --git a/backend/routes/forum.js b/backend/routes/forum.js new file mode 100644 index 0000000..7775287 --- /dev/null +++ b/backend/routes/forum.js @@ -0,0 +1,641 @@ +const express = require('express'); +const { Op } = require('sequelize'); +const { ForumPost, ForumComment, PostTag, User } = require('../models'); +const { authenticateToken } = require('../middleware/auth'); +const logger = require('../utils/logger'); +const router = express.Router(); + +// Helper function to build nested comment tree +const buildCommentTree = (comments) => { + const commentMap = {}; + const rootComments = []; + + // Create a map of all comments + comments.forEach(comment => { + commentMap[comment.id] = { ...comment.toJSON(), replies: [] }; + }); + + // Build the tree structure + comments.forEach(comment => { + if (comment.parentCommentId && commentMap[comment.parentCommentId]) { + commentMap[comment.parentCommentId].replies.push(commentMap[comment.id]); + } else if (!comment.parentCommentId) { + rootComments.push(commentMap[comment.id]); + } + }); + + return rootComments; +}; + +// GET /api/forum/posts - Browse all posts +router.get('/posts', async (req, res) => { + try { + const { + search, + category, + tag, + status, + page = 1, + limit = 20, + sort = 'recent' + } = req.query; + + const where = {}; + + if (category) { + where.category = category; + } + + if (status) { + where.status = status; + } + + if (search) { + where[Op.or] = [ + { title: { [Op.iLike]: `%${search}%` } }, + { content: { [Op.iLike]: `%${search}%` } } + ]; + } + + const offset = (page - 1) * limit; + + // Determine sort order + let order; + switch (sort) { + case 'comments': + order = [['commentCount', 'DESC'], ['createdAt', 'DESC']]; + break; + case 'views': + order = [['viewCount', 'DESC'], ['createdAt', 'DESC']]; + break; + case 'recent': + default: + order = [['isPinned', 'DESC'], ['updatedAt', 'DESC']]; + break; + } + + const include = [ + { + model: User, + as: 'author', + attributes: ['id', 'username', 'firstName', 'lastName'] + }, + { + model: PostTag, + as: 'tags', + attributes: ['tagName'] + } + ]; + + // Filter by tag if provided + if (tag) { + include[1].where = { tagName: tag }; + include[1].required = true; + } + + const { count, rows } = await ForumPost.findAndCountAll({ + where, + include, + limit: parseInt(limit), + offset: parseInt(offset), + order, + distinct: true + }); + + const reqLogger = logger.withRequestId(req.id); + reqLogger.info("Forum posts fetched", { + search, + category, + tag, + status, + postsCount: count, + page: parseInt(page), + limit: parseInt(limit) + }); + + res.json({ + posts: rows, + totalPages: Math.ceil(count / limit), + currentPage: parseInt(page), + totalPosts: count + }); + } catch (error) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Forum posts fetch failed", { + error: error.message, + stack: error.stack, + query: req.query + }); + res.status(500).json({ error: error.message }); + } +}); + +// GET /api/forum/posts/:id - Get single post with all comments +router.get('/posts/:id', async (req, res) => { + try { + const post = await ForumPost.findByPk(req.params.id, { + include: [ + { + model: User, + as: 'author', + attributes: ['id', 'username', 'firstName', 'lastName'] + }, + { + model: PostTag, + as: 'tags', + attributes: ['tagName'] + }, + { + model: ForumComment, + as: 'comments', + where: { isDeleted: false }, + required: false, + include: [ + { + model: User, + as: 'author', + attributes: ['id', 'username', 'firstName', 'lastName'] + } + ], + order: [['createdAt', 'ASC']] + } + ] + }); + + if (!post) { + return res.status(404).json({ error: 'Post not found' }); + } + + // Increment view count + await post.increment('viewCount'); + + // Build nested comment tree + const postData = post.toJSON(); + postData.comments = buildCommentTree(post.comments); + postData.viewCount += 1; // Reflect the increment + + const reqLogger = logger.withRequestId(req.id); + reqLogger.info("Forum post fetched", { + postId: req.params.id, + authorId: post.authorId + }); + + res.json(postData); + } catch (error) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Forum post fetch failed", { + error: error.message, + stack: error.stack, + postId: req.params.id + }); + res.status(500).json({ error: error.message }); + } +}); + +// POST /api/forum/posts - Create new post +router.post('/posts', authenticateToken, async (req, res) => { + try { + const { title, content, category, tags } = req.body; + + const post = await ForumPost.create({ + title, + content, + category, + authorId: req.user.id + }); + + // Create tags if provided + if (tags && Array.isArray(tags) && tags.length > 0) { + const tagPromises = tags.map(tagName => + PostTag.create({ + postId: post.id, + tagName: tagName.toLowerCase().trim() + }) + ); + await Promise.all(tagPromises); + } + + const postWithDetails = await ForumPost.findByPk(post.id, { + include: [ + { + model: User, + as: 'author', + attributes: ['id', 'username', 'firstName', 'lastName'] + }, + { + model: PostTag, + as: 'tags', + attributes: ['tagName'] + } + ] + }); + + const reqLogger = logger.withRequestId(req.id); + reqLogger.info("Forum post created", { + postId: post.id, + authorId: req.user.id, + category, + title + }); + + res.status(201).json(postWithDetails); + } catch (error) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Forum post creation failed", { + error: error.message, + stack: error.stack, + authorId: req.user.id, + postData: logger.sanitize(req.body) + }); + res.status(500).json({ error: error.message }); + } +}); + +// PUT /api/forum/posts/:id - Update post +router.put('/posts/:id', authenticateToken, async (req, res) => { + try { + const post = await ForumPost.findByPk(req.params.id); + + if (!post) { + return res.status(404).json({ error: 'Post not found' }); + } + + if (post.authorId !== req.user.id) { + return res.status(403).json({ error: 'Unauthorized' }); + } + + const { title, content, category, tags } = req.body; + + await post.update({ title, content, category }); + + // Update tags if provided + if (tags !== undefined) { + // Delete existing tags + await PostTag.destroy({ where: { postId: post.id } }); + + // Create new tags + if (Array.isArray(tags) && tags.length > 0) { + const tagPromises = tags.map(tagName => + PostTag.create({ + postId: post.id, + tagName: tagName.toLowerCase().trim() + }) + ); + await Promise.all(tagPromises); + } + } + + const updatedPost = await ForumPost.findByPk(post.id, { + include: [ + { + model: User, + as: 'author', + attributes: ['id', 'username', 'firstName', 'lastName'] + }, + { + model: PostTag, + as: 'tags', + attributes: ['tagName'] + } + ] + }); + + const reqLogger = logger.withRequestId(req.id); + reqLogger.info("Forum post updated", { + postId: req.params.id, + authorId: req.user.id + }); + + res.json(updatedPost); + } catch (error) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Forum post update failed", { + error: error.message, + stack: error.stack, + postId: req.params.id, + authorId: req.user.id + }); + res.status(500).json({ error: error.message }); + } +}); + +// DELETE /api/forum/posts/:id - Delete post +router.delete('/posts/:id', authenticateToken, async (req, res) => { + try { + const post = await ForumPost.findByPk(req.params.id); + + if (!post) { + return res.status(404).json({ error: 'Post not found' }); + } + + if (post.authorId !== req.user.id) { + return res.status(403).json({ error: 'Unauthorized' }); + } + + // Delete associated tags and comments + await PostTag.destroy({ where: { postId: post.id } }); + await ForumComment.destroy({ where: { postId: post.id } }); + await post.destroy(); + + const reqLogger = logger.withRequestId(req.id); + reqLogger.info("Forum post deleted", { + postId: req.params.id, + authorId: req.user.id + }); + + res.status(204).send(); + } catch (error) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Forum post deletion failed", { + error: error.message, + stack: error.stack, + postId: req.params.id, + authorId: req.user.id + }); + res.status(500).json({ error: error.message }); + } +}); + +// PATCH /api/forum/posts/:id/status - Update post status +router.patch('/posts/:id/status', authenticateToken, async (req, res) => { + try { + const { status } = req.body; + const post = await ForumPost.findByPk(req.params.id); + + if (!post) { + return res.status(404).json({ error: 'Post not found' }); + } + + if (post.authorId !== req.user.id) { + return res.status(403).json({ error: 'Only the author can update post status' }); + } + + if (!['open', 'solved', 'closed'].includes(status)) { + return res.status(400).json({ error: 'Invalid status value' }); + } + + await post.update({ status }); + + const updatedPost = await ForumPost.findByPk(post.id, { + include: [ + { + model: User, + as: 'author', + attributes: ['id', 'username', 'firstName', 'lastName'] + }, + { + model: PostTag, + as: 'tags', + attributes: ['tagName'] + } + ] + }); + + const reqLogger = logger.withRequestId(req.id); + reqLogger.info("Forum post status updated", { + postId: req.params.id, + newStatus: status, + authorId: req.user.id + }); + + res.json(updatedPost); + } catch (error) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Forum post status update failed", { + error: error.message, + stack: error.stack, + postId: req.params.id, + authorId: req.user.id + }); + res.status(500).json({ error: error.message }); + } +}); + +// POST /api/forum/posts/:id/comments - Add comment/reply +router.post('/posts/:id/comments', authenticateToken, async (req, res) => { + try { + const { content, parentCommentId } = req.body; + const post = await ForumPost.findByPk(req.params.id); + + if (!post) { + return res.status(404).json({ error: 'Post not found' }); + } + + // Validate parent comment if provided + if (parentCommentId) { + const parentComment = await ForumComment.findByPk(parentCommentId); + if (!parentComment || parentComment.postId !== post.id) { + return res.status(400).json({ error: 'Invalid parent comment' }); + } + } + + const comment = await ForumComment.create({ + postId: req.params.id, + authorId: req.user.id, + content, + parentCommentId: parentCommentId || null + }); + + // Increment comment count + await post.increment('commentCount'); + + const commentWithDetails = await ForumComment.findByPk(comment.id, { + include: [ + { + model: User, + as: 'author', + attributes: ['id', 'username', 'firstName', 'lastName'] + } + ] + }); + + const reqLogger = logger.withRequestId(req.id); + reqLogger.info("Forum comment created", { + postId: req.params.id, + commentId: comment.id, + authorId: req.user.id, + isReply: !!parentCommentId + }); + + res.status(201).json(commentWithDetails); + } catch (error) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Forum comment creation failed", { + error: error.message, + stack: error.stack, + postId: req.params.id, + authorId: req.user.id + }); + res.status(500).json({ error: error.message }); + } +}); + +// PUT /api/forum/comments/:id - Edit comment +router.put('/comments/:id', authenticateToken, async (req, res) => { + try { + const { content } = req.body; + const comment = await ForumComment.findByPk(req.params.id); + + if (!comment) { + return res.status(404).json({ error: 'Comment not found' }); + } + + if (comment.authorId !== req.user.id) { + return res.status(403).json({ error: 'Unauthorized' }); + } + + if (comment.isDeleted) { + return res.status(400).json({ error: 'Cannot edit deleted comment' }); + } + + await comment.update({ content }); + + const updatedComment = await ForumComment.findByPk(comment.id, { + include: [ + { + model: User, + as: 'author', + attributes: ['id', 'username', 'firstName', 'lastName'] + } + ] + }); + + const reqLogger = logger.withRequestId(req.id); + reqLogger.info("Forum comment updated", { + commentId: req.params.id, + authorId: req.user.id + }); + + res.json(updatedComment); + } catch (error) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Forum comment update failed", { + error: error.message, + stack: error.stack, + commentId: req.params.id, + authorId: req.user.id + }); + res.status(500).json({ error: error.message }); + } +}); + +// DELETE /api/forum/comments/:id - Soft delete comment +router.delete('/comments/:id', authenticateToken, async (req, res) => { + try { + const comment = await ForumComment.findByPk(req.params.id); + + if (!comment) { + return res.status(404).json({ error: 'Comment not found' }); + } + + if (comment.authorId !== req.user.id) { + return res.status(403).json({ error: 'Unauthorized' }); + } + + // Soft delete + await comment.update({ isDeleted: true, content: '[deleted]' }); + + // Decrement comment count + const post = await ForumPost.findByPk(comment.postId); + if (post && post.commentCount > 0) { + await post.decrement('commentCount'); + } + + const reqLogger = logger.withRequestId(req.id); + reqLogger.info("Forum comment deleted", { + commentId: req.params.id, + authorId: req.user.id, + postId: comment.postId + }); + + res.status(204).send(); + } catch (error) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Forum comment deletion failed", { + error: error.message, + stack: error.stack, + commentId: req.params.id, + authorId: req.user.id + }); + res.status(500).json({ error: error.message }); + } +}); + +// GET /api/forum/my-posts - Get user's posts +router.get('/my-posts', authenticateToken, async (req, res) => { + try { + const posts = await ForumPost.findAll({ + where: { authorId: req.user.id }, + include: [ + { + model: User, + as: 'author', + attributes: ['id', 'username', 'firstName', 'lastName'] + }, + { + model: PostTag, + as: 'tags', + attributes: ['tagName'] + } + ], + order: [['createdAt', 'DESC']] + }); + + const reqLogger = logger.withRequestId(req.id); + reqLogger.info("User forum posts fetched", { + userId: req.user.id, + postsCount: posts.length + }); + + res.json(posts); + } catch (error) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("User forum posts fetch failed", { + error: error.message, + stack: error.stack, + userId: req.user.id + }); + res.status(500).json({ error: error.message }); + } +}); + +// GET /api/forum/tags - Get all unique tags for autocomplete +router.get('/tags', async (req, res) => { + try { + const { search } = req.query; + + const where = {}; + if (search) { + where.tagName = { [Op.iLike]: `%${search}%` }; + } + + const tags = await PostTag.findAll({ + where, + attributes: [ + 'tagName', + [require('sequelize').fn('COUNT', require('sequelize').col('tagName')), 'count'] + ], + group: ['tagName'], + order: [[require('sequelize').fn('COUNT', require('sequelize').col('tagName')), 'DESC']], + limit: 50 + }); + + const reqLogger = logger.withRequestId(req.id); + reqLogger.info("Tags fetched", { + search, + tagsCount: tags.length + }); + + res.json(tags); + } catch (error) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Tags fetch failed", { + error: error.message, + stack: error.stack, + query: req.query + }); + res.status(500).json({ error: error.message }); + } +}); + +module.exports = router; diff --git a/backend/routes/itemRequests.js b/backend/routes/itemRequests.js deleted file mode 100644 index d5af823..0000000 --- a/backend/routes/itemRequests.js +++ /dev/null @@ -1,396 +0,0 @@ -const express = require('express'); -const { Op } = require('sequelize'); -const { ItemRequest, ItemRequestResponse, User, Item } = require('../models'); -const { authenticateToken } = require('../middleware/auth'); -const logger = require('../utils/logger'); -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']] - }); - - const reqLogger = logger.withRequestId(req.id); - reqLogger.info("Item requests fetched", { - search, - status, - requestsCount: count, - page: parseInt(page), - limit: parseInt(limit) - }); - - res.json({ - requests: rows, - totalPages: Math.ceil(count / limit), - currentPage: parseInt(page), - totalRequests: count - }); - } catch (error) { - const reqLogger = logger.withRequestId(req.id); - reqLogger.error("Item requests fetch failed", { - error: error.message, - stack: error.stack, - query: req.query - }); - 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']] - }); - - const reqLogger = logger.withRequestId(req.id); - reqLogger.info("User item requests fetched", { - userId: req.user.id, - requestsCount: requests.length - }); - - res.json(requests); - } catch (error) { - const reqLogger = logger.withRequestId(req.id); - reqLogger.error("User item requests fetch failed", { - error: error.message, - stack: error.stack, - userId: req.user.id - }); - 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' }); - } - - const reqLogger = logger.withRequestId(req.id); - reqLogger.info("Item request fetched", { - requestId: req.params.id, - requesterId: request.requesterId - }); - - res.json(request); - } catch (error) { - const reqLogger = logger.withRequestId(req.id); - reqLogger.error("Item request fetch failed", { - error: error.message, - stack: error.stack, - requestId: req.params.id - }); - 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'] - } - ] - }); - - const reqLogger = logger.withRequestId(req.id); - reqLogger.info("Item request created", { - requestId: request.id, - requesterId: req.user.id, - title: req.body.title - }); - - res.status(201).json(requestWithRequester); - } catch (error) { - const reqLogger = logger.withRequestId(req.id); - reqLogger.error("Item request creation failed", { - error: error.message, - stack: error.stack, - requesterId: req.user.id, - requestData: logger.sanitize(req.body) - }); - 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'] - } - ] - }); - - const reqLogger = logger.withRequestId(req.id); - reqLogger.info("Item request updated", { - requestId: req.params.id, - requesterId: req.user.id - }); - - res.json(updatedRequest); - } catch (error) { - const reqLogger = logger.withRequestId(req.id); - reqLogger.error("Item request update failed", { - error: error.message, - stack: error.stack, - requestId: req.params.id, - requesterId: req.user.id - }); - 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(); - - const reqLogger = logger.withRequestId(req.id); - reqLogger.info("Item request deleted", { - requestId: req.params.id, - requesterId: req.user.id - }); - - res.status(204).send(); - } catch (error) { - const reqLogger = logger.withRequestId(req.id); - reqLogger.error("Item request deletion failed", { - error: error.message, - stack: error.stack, - requestId: req.params.id, - requesterId: req.user.id - }); - 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' - } - ] - }); - - const reqLogger = logger.withRequestId(req.id); - reqLogger.info("Item request response created", { - requestId: req.params.id, - responseId: response.id, - responderId: req.user.id - }); - - res.status(201).json(responseWithDetails); - } catch (error) { - const reqLogger = logger.withRequestId(req.id); - reqLogger.error("Item request response creation failed", { - error: error.message, - stack: error.stack, - requestId: req.params.id, - responderId: req.user.id - }); - 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' - } - ] - }); - - const reqLogger = logger.withRequestId(req.id); - reqLogger.info("Item request response status updated", { - responseId: req.params.responseId, - newStatus: status, - requesterId: req.user.id, - requestFulfilled: status === 'accepted' - }); - - res.json(updatedResponse); - } catch (error) { - const reqLogger = logger.withRequestId(req.id); - reqLogger.error("Item request response status update failed", { - error: error.message, - stack: error.stack, - responseId: req.params.responseId, - requesterId: req.user.id - }); - 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 ee5dfd3..38cd6d9 100644 --- a/backend/server.js +++ b/backend/server.js @@ -23,7 +23,7 @@ const userRoutes = require("./routes/users"); const itemRoutes = require("./routes/items"); const rentalRoutes = require("./routes/rentals"); const messageRoutes = require("./routes/messages"); -const itemRequestRoutes = require("./routes/itemRequests"); +const forumRoutes = require("./routes/forum"); const stripeRoutes = require("./routes/stripe"); const mapsRoutes = require("./routes/maps"); const conditionCheckRoutes = require("./routes/conditionChecks"); @@ -148,7 +148,7 @@ app.use("/api/users", requireAlphaAccess, userRoutes); app.use("/api/items", requireAlphaAccess, itemRoutes); app.use("/api/rentals", requireAlphaAccess, rentalRoutes); app.use("/api/messages", requireAlphaAccess, messageRoutes); -app.use("/api/item-requests", requireAlphaAccess, itemRequestRoutes); +app.use("/api/forum", requireAlphaAccess, forumRoutes); app.use("/api/stripe", requireAlphaAccess, stripeRoutes); app.use("/api/maps", requireAlphaAccess, mapsRoutes); app.use("/api/condition-checks", requireAlphaAccess, conditionCheckRoutes); diff --git a/backend/tests/unit/routes/itemRequests.test.js b/backend/tests/unit/routes/itemRequests.test.js deleted file mode 100644 index cfe57e1..0000000 --- a/backend/tests/unit/routes/itemRequests.test.js +++ /dev/null @@ -1,823 +0,0 @@ -const request = require('supertest'); -const express = require('express'); -const itemRequestsRouter = require('../../../routes/itemRequests'); - -// Mock all dependencies -jest.mock('../../../models', () => ({ - ItemRequest: { - findAndCountAll: jest.fn(), - findAll: jest.fn(), - findByPk: jest.fn(), - create: jest.fn(), - }, - ItemRequestResponse: { - findByPk: jest.fn(), - create: jest.fn(), - }, - User: jest.fn(), - Item: jest.fn(), -})); - -jest.mock('../../../middleware/auth', () => ({ - authenticateToken: jest.fn((req, res, next) => { - req.user = { id: 1 }; - next(); - }), -})); - -jest.mock('sequelize', () => ({ - Op: { - or: Symbol('or'), - iLike: Symbol('iLike'), - }, -})); - -const { ItemRequest, ItemRequestResponse, User, Item } = require('../../../models'); - -// Create express app with the router -const app = express(); -app.use(express.json()); -app.use('/item-requests', itemRequestsRouter); - -// Mock models -const mockItemRequestFindAndCountAll = ItemRequest.findAndCountAll; -const mockItemRequestFindAll = ItemRequest.findAll; -const mockItemRequestFindByPk = ItemRequest.findByPk; -const mockItemRequestCreate = ItemRequest.create; -const mockItemRequestResponseFindByPk = ItemRequestResponse.findByPk; -const mockItemRequestResponseCreate = ItemRequestResponse.create; - -describe('ItemRequests Routes', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('GET /', () => { - it('should get item requests with default pagination and status', async () => { - const mockRequestsData = { - count: 25, - rows: [ - { - id: 1, - title: 'Need a Camera', - description: 'Looking for a DSLR camera for weekend photography', - status: 'open', - requesterId: 2, - createdAt: '2024-01-15T10:00:00.000Z', - requester: { - id: 2, - username: 'jane_doe', - firstName: 'Jane', - lastName: 'Doe' - } - }, - { - id: 2, - title: 'Power Drill Needed', - description: 'Need a drill for home improvement project', - status: 'open', - requesterId: 3, - createdAt: '2024-01-14T10:00:00.000Z', - requester: { - id: 3, - username: 'bob_smith', - firstName: 'Bob', - lastName: 'Smith' - } - } - ] - }; - - mockItemRequestFindAndCountAll.mockResolvedValue(mockRequestsData); - - const response = await request(app) - .get('/item-requests'); - - expect(response.status).toBe(200); - expect(response.body).toEqual({ - requests: mockRequestsData.rows, - totalPages: 2, - currentPage: 1, - totalRequests: 25 - }); - expect(mockItemRequestFindAndCountAll).toHaveBeenCalledWith({ - where: { status: 'open' }, - include: [ - { - model: User, - as: 'requester', - attributes: ['id', 'username', 'firstName', 'lastName'] - } - ], - limit: 20, - offset: 0, - order: [['createdAt', 'DESC']] - }); - }); - - it('should filter requests with search query', async () => { - const mockSearchResults = { - count: 5, - rows: [ - { - id: 1, - title: 'Need a Camera', - description: 'Looking for a DSLR camera', - status: 'open' - } - ] - }; - - mockItemRequestFindAndCountAll.mockResolvedValue(mockSearchResults); - - const response = await request(app) - .get('/item-requests?search=camera&page=1&limit=10'); - - const { Op } = require('sequelize'); - expect(mockItemRequestFindAndCountAll).toHaveBeenCalledWith({ - where: { - status: 'open', - [Op.or]: [ - { title: { [Op.iLike]: '%camera%' } }, - { description: { [Op.iLike]: '%camera%' } } - ] - }, - include: expect.any(Array), - limit: 10, - offset: 0, - order: [['createdAt', 'DESC']] - }); - }); - - it('should handle custom pagination', async () => { - const mockData = { count: 50, rows: [] }; - mockItemRequestFindAndCountAll.mockResolvedValue(mockData); - - const response = await request(app) - .get('/item-requests?page=3&limit=5'); - - expect(mockItemRequestFindAndCountAll).toHaveBeenCalledWith({ - where: { status: 'open' }, - include: expect.any(Array), - limit: 5, - offset: 10, // (3-1) * 5 - order: [['createdAt', 'DESC']] - }); - }); - - it('should filter by custom status', async () => { - const mockData = { count: 10, rows: [] }; - mockItemRequestFindAndCountAll.mockResolvedValue(mockData); - - await request(app) - .get('/item-requests?status=fulfilled'); - - expect(mockItemRequestFindAndCountAll).toHaveBeenCalledWith({ - where: { status: 'fulfilled' }, - include: expect.any(Array), - limit: 20, - offset: 0, - order: [['createdAt', 'DESC']] - }); - }); - - it('should handle database errors', async () => { - mockItemRequestFindAndCountAll.mockRejectedValue(new Error('Database error')); - - const response = await request(app) - .get('/item-requests'); - - expect(response.status).toBe(500); - expect(response.body).toEqual({ error: 'Database error' }); - }); - }); - - describe('GET /my-requests', () => { - it('should get user\'s own requests with responses', async () => { - const mockRequests = [ - { - id: 1, - title: 'My Camera Request', - description: 'Need a camera', - status: 'open', - requesterId: 1, - requester: { - id: 1, - username: 'john_doe', - firstName: 'John', - lastName: 'Doe' - }, - responses: [ - { - id: 1, - message: 'I have a Canon DSLR available', - responder: { - id: 2, - username: 'jane_doe', - firstName: 'Jane', - lastName: 'Doe' - }, - existingItem: { - id: 5, - name: 'Canon EOS 5D', - description: 'Professional DSLR camera' - } - } - ] - } - ]; - - mockItemRequestFindAll.mockResolvedValue(mockRequests); - - const response = await request(app) - .get('/item-requests/my-requests'); - - expect(response.status).toBe(200); - expect(response.body).toEqual(mockRequests); - expect(mockItemRequestFindAll).toHaveBeenCalledWith({ - where: { requesterId: 1 }, - 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']] - }); - }); - - it('should handle database errors', async () => { - mockItemRequestFindAll.mockRejectedValue(new Error('Database error')); - - const response = await request(app) - .get('/item-requests/my-requests'); - - expect(response.status).toBe(500); - expect(response.body).toEqual({ error: 'Database error' }); - }); - }); - - describe('GET /:id', () => { - it('should get specific request with responses', async () => { - const mockRequest = { - id: 1, - title: 'Camera Request', - description: 'Need a DSLR camera', - status: 'open', - requesterId: 2, - requester: { - id: 2, - username: 'jane_doe', - firstName: 'Jane', - lastName: 'Doe' - }, - responses: [ - { - id: 1, - message: 'I have a Canon DSLR', - responder: { - id: 1, - username: 'john_doe', - firstName: 'John', - lastName: 'Doe' - }, - existingItem: null - } - ] - }; - - mockItemRequestFindByPk.mockResolvedValue(mockRequest); - - const response = await request(app) - .get('/item-requests/1'); - - expect(response.status).toBe(200); - expect(response.body).toEqual(mockRequest); - expect(mockItemRequestFindByPk).toHaveBeenCalledWith('1', { - 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' - } - ] - } - ] - }); - }); - - it('should return 404 for non-existent request', async () => { - mockItemRequestFindByPk.mockResolvedValue(null); - - const response = await request(app) - .get('/item-requests/999'); - - expect(response.status).toBe(404); - expect(response.body).toEqual({ error: 'Item request not found' }); - }); - - it('should handle database errors', async () => { - mockItemRequestFindByPk.mockRejectedValue(new Error('Database error')); - - const response = await request(app) - .get('/item-requests/1'); - - expect(response.status).toBe(500); - expect(response.body).toEqual({ error: 'Database error' }); - }); - }); - - describe('POST /', () => { - it('should create a new item request', async () => { - const requestData = { - title: 'Need a Drill', - description: 'Looking for a power drill for weekend project', - category: 'tools', - budget: 50, - location: 'New York' - }; - - const mockCreatedRequest = { - id: 3, - ...requestData, - requesterId: 1, - status: 'open' - }; - - const mockRequestWithRequester = { - ...mockCreatedRequest, - requester: { - id: 1, - username: 'john_doe', - firstName: 'John', - lastName: 'Doe' - } - }; - - mockItemRequestCreate.mockResolvedValue(mockCreatedRequest); - mockItemRequestFindByPk.mockResolvedValue(mockRequestWithRequester); - - const response = await request(app) - .post('/item-requests') - .send(requestData); - - expect(response.status).toBe(201); - expect(response.body).toEqual(mockRequestWithRequester); - expect(mockItemRequestCreate).toHaveBeenCalledWith({ - ...requestData, - requesterId: 1 - }); - }); - - it('should handle database errors during creation', async () => { - mockItemRequestCreate.mockRejectedValue(new Error('Database error')); - - const response = await request(app) - .post('/item-requests') - .send({ - title: 'Test Request', - description: 'Test description' - }); - - expect(response.status).toBe(500); - expect(response.body).toEqual({ error: 'Database error' }); - }); - }); - - describe('PUT /:id', () => { - const mockRequest = { - id: 1, - title: 'Original Title', - requesterId: 1, - update: jest.fn() - }; - - beforeEach(() => { - mockItemRequestFindByPk.mockResolvedValue(mockRequest); - }); - - it('should update item request for owner', async () => { - const updateData = { - title: 'Updated Title', - description: 'Updated description' - }; - - const mockUpdatedRequest = { - ...mockRequest, - ...updateData, - requester: { - id: 1, - username: 'john_doe', - firstName: 'John', - lastName: 'Doe' - } - }; - - mockRequest.update.mockResolvedValue(); - mockItemRequestFindByPk - .mockResolvedValueOnce(mockRequest) - .mockResolvedValueOnce(mockUpdatedRequest); - - const response = await request(app) - .put('/item-requests/1') - .send(updateData); - - expect(response.status).toBe(200); - expect(response.body).toEqual({ - id: 1, - title: 'Updated Title', - description: 'Updated description', - requesterId: 1, - requester: { - id: 1, - username: 'john_doe', - firstName: 'John', - lastName: 'Doe' - } - }); - expect(mockRequest.update).toHaveBeenCalledWith(updateData); - }); - - it('should return 404 for non-existent request', async () => { - mockItemRequestFindByPk.mockResolvedValue(null); - - const response = await request(app) - .put('/item-requests/999') - .send({ title: 'Updated' }); - - expect(response.status).toBe(404); - expect(response.body).toEqual({ error: 'Item request not found' }); - }); - - it('should return 403 for unauthorized user', async () => { - const unauthorizedRequest = { ...mockRequest, requesterId: 2 }; - mockItemRequestFindByPk.mockResolvedValue(unauthorizedRequest); - - const response = await request(app) - .put('/item-requests/1') - .send({ title: 'Updated' }); - - expect(response.status).toBe(403); - expect(response.body).toEqual({ error: 'Unauthorized' }); - }); - - it('should handle database errors', async () => { - mockItemRequestFindByPk.mockRejectedValue(new Error('Database error')); - - const response = await request(app) - .put('/item-requests/1') - .send({ title: 'Updated' }); - - expect(response.status).toBe(500); - expect(response.body).toEqual({ error: 'Database error' }); - }); - }); - - describe('DELETE /:id', () => { - const mockRequest = { - id: 1, - requesterId: 1, - destroy: jest.fn() - }; - - beforeEach(() => { - mockItemRequestFindByPk.mockResolvedValue(mockRequest); - }); - - it('should delete item request for owner', async () => { - mockRequest.destroy.mockResolvedValue(); - - const response = await request(app) - .delete('/item-requests/1'); - - expect(response.status).toBe(204); - expect(mockRequest.destroy).toHaveBeenCalled(); - }); - - it('should return 404 for non-existent request', async () => { - mockItemRequestFindByPk.mockResolvedValue(null); - - const response = await request(app) - .delete('/item-requests/999'); - - expect(response.status).toBe(404); - expect(response.body).toEqual({ error: 'Item request not found' }); - }); - - it('should return 403 for unauthorized user', async () => { - const unauthorizedRequest = { ...mockRequest, requesterId: 2 }; - mockItemRequestFindByPk.mockResolvedValue(unauthorizedRequest); - - const response = await request(app) - .delete('/item-requests/1'); - - expect(response.status).toBe(403); - expect(response.body).toEqual({ error: 'Unauthorized' }); - }); - - it('should handle database errors', async () => { - mockItemRequestFindByPk.mockRejectedValue(new Error('Database error')); - - const response = await request(app) - .delete('/item-requests/1'); - - expect(response.status).toBe(500); - expect(response.body).toEqual({ error: 'Database error' }); - }); - }); - - describe('POST /:id/responses', () => { - const mockRequest = { - id: 1, - requesterId: 2, - status: 'open', - increment: jest.fn() - }; - - const mockResponseData = { - message: 'I have a drill you can borrow', - price: 25, - existingItemId: 5 - }; - - const mockCreatedResponse = { - id: 1, - ...mockResponseData, - itemRequestId: 1, - responderId: 1 - }; - - const mockResponseWithDetails = { - ...mockCreatedResponse, - responder: { - id: 1, - username: 'john_doe', - firstName: 'John', - lastName: 'Doe' - }, - existingItem: { - id: 5, - name: 'Power Drill', - description: 'Cordless power drill' - } - }; - - beforeEach(() => { - mockItemRequestFindByPk.mockResolvedValue(mockRequest); - mockItemRequestResponseCreate.mockResolvedValue(mockCreatedResponse); - mockItemRequestResponseFindByPk.mockResolvedValue(mockResponseWithDetails); - }); - - it('should create a response to item request', async () => { - mockRequest.increment.mockResolvedValue(); - - const response = await request(app) - .post('/item-requests/1/responses') - .send(mockResponseData); - - expect(response.status).toBe(201); - expect(response.body).toEqual(mockResponseWithDetails); - expect(mockItemRequestResponseCreate).toHaveBeenCalledWith({ - ...mockResponseData, - itemRequestId: '1', - responderId: 1 - }); - expect(mockRequest.increment).toHaveBeenCalledWith('responseCount'); - }); - - it('should return 404 for non-existent request', async () => { - mockItemRequestFindByPk.mockResolvedValue(null); - - const response = await request(app) - .post('/item-requests/999/responses') - .send(mockResponseData); - - expect(response.status).toBe(404); - expect(response.body).toEqual({ error: 'Item request not found' }); - }); - - it('should prevent responding to own request', async () => { - const ownRequest = { ...mockRequest, requesterId: 1 }; - mockItemRequestFindByPk.mockResolvedValue(ownRequest); - - const response = await request(app) - .post('/item-requests/1/responses') - .send(mockResponseData); - - expect(response.status).toBe(400); - expect(response.body).toEqual({ error: 'Cannot respond to your own request' }); - }); - - it('should prevent responding to closed request', async () => { - const closedRequest = { ...mockRequest, status: 'fulfilled' }; - mockItemRequestFindByPk.mockResolvedValue(closedRequest); - - const response = await request(app) - .post('/item-requests/1/responses') - .send(mockResponseData); - - expect(response.status).toBe(400); - expect(response.body).toEqual({ error: 'Cannot respond to closed request' }); - }); - - it('should handle database errors', async () => { - mockItemRequestResponseCreate.mockRejectedValue(new Error('Database error')); - - const response = await request(app) - .post('/item-requests/1/responses') - .send(mockResponseData); - - expect(response.status).toBe(500); - expect(response.body).toEqual({ error: 'Database error' }); - }); - }); - - describe('PUT /responses/:responseId/status', () => { - const mockResponse = { - id: 1, - status: 'pending', - itemRequest: { - id: 1, - requesterId: 1 - }, - update: jest.fn() - }; - - beforeEach(() => { - mockItemRequestResponseFindByPk.mockResolvedValue(mockResponse); - }); - - it('should update response status to accepted and fulfill request', async () => { - const updatedResponse = { - ...mockResponse, - status: 'accepted', - responder: { - id: 2, - username: 'jane_doe', - firstName: 'Jane', - lastName: 'Doe' - }, - existingItem: null - }; - - mockResponse.update.mockResolvedValue(); - mockResponse.itemRequest.update = jest.fn().mockResolvedValue(); - mockItemRequestResponseFindByPk - .mockResolvedValueOnce(mockResponse) - .mockResolvedValueOnce(updatedResponse); - - const response = await request(app) - .put('/item-requests/responses/1/status') - .send({ status: 'accepted' }); - - expect(response.status).toBe(200); - expect(response.body).toEqual({ - id: 1, - status: 'accepted', - itemRequest: { - id: 1, - requesterId: 1 - }, - responder: { - id: 2, - username: 'jane_doe', - firstName: 'Jane', - lastName: 'Doe' - }, - existingItem: null - }); - expect(mockResponse.update).toHaveBeenCalledWith({ status: 'accepted' }); - expect(mockResponse.itemRequest.update).toHaveBeenCalledWith({ status: 'fulfilled' }); - }); - - it('should update response status without fulfilling request', async () => { - const updatedResponse = { ...mockResponse, status: 'declined' }; - mockResponse.update.mockResolvedValue(); - mockItemRequestResponseFindByPk - .mockResolvedValueOnce(mockResponse) - .mockResolvedValueOnce(updatedResponse); - - const response = await request(app) - .put('/item-requests/responses/1/status') - .send({ status: 'declined' }); - - expect(response.status).toBe(200); - expect(mockResponse.update).toHaveBeenCalledWith({ status: 'declined' }); - expect(mockResponse.itemRequest.update).not.toHaveBeenCalled(); - }); - - it('should return 404 for non-existent response', async () => { - mockItemRequestResponseFindByPk.mockResolvedValue(null); - - const response = await request(app) - .put('/item-requests/responses/999/status') - .send({ status: 'accepted' }); - - expect(response.status).toBe(404); - expect(response.body).toEqual({ error: 'Response not found' }); - }); - - it('should return 403 for unauthorized user', async () => { - const unauthorizedResponse = { - ...mockResponse, - itemRequest: { ...mockResponse.itemRequest, requesterId: 2 } - }; - mockItemRequestResponseFindByPk.mockResolvedValue(unauthorizedResponse); - - const response = await request(app) - .put('/item-requests/responses/1/status') - .send({ status: 'accepted' }); - - expect(response.status).toBe(403); - expect(response.body).toEqual({ error: 'Only the requester can update response status' }); - }); - - it('should handle database errors', async () => { - mockItemRequestResponseFindByPk.mockRejectedValue(new Error('Database error')); - - const response = await request(app) - .put('/item-requests/responses/1/status') - .send({ status: 'accepted' }); - - expect(response.status).toBe(500); - expect(response.body).toEqual({ error: 'Database error' }); - }); - }); - - describe('Edge cases', () => { - it('should handle empty search results', async () => { - mockItemRequestFindAndCountAll.mockResolvedValue({ count: 0, rows: [] }); - - const response = await request(app) - .get('/item-requests?search=nonexistent'); - - expect(response.status).toBe(200); - expect(response.body.requests).toEqual([]); - expect(response.body.totalRequests).toBe(0); - }); - - it('should handle zero page calculation', async () => { - mockItemRequestFindAndCountAll.mockResolvedValue({ count: 0, rows: [] }); - - const response = await request(app) - .get('/item-requests'); - - expect(response.body.totalPages).toBe(0); - }); - - it('should handle request without optional fields', async () => { - const minimalRequest = { - title: 'Basic Request', - description: 'Simple description' - }; - - const mockCreated = { id: 1, ...minimalRequest, requesterId: 1 }; - const mockWithRequester = { - ...mockCreated, - requester: { id: 1, username: 'test' } - }; - - mockItemRequestCreate.mockResolvedValue(mockCreated); - mockItemRequestFindByPk.mockResolvedValue(mockWithRequester); - - const response = await request(app) - .post('/item-requests') - .send(minimalRequest); - - expect(response.status).toBe(201); - expect(mockItemRequestCreate).toHaveBeenCalledWith({ - ...minimalRequest, - requesterId: 1 - }); - }); - }); -}); \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7cf77d2..96c3660 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,10 +22,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 ForumPosts from './pages/ForumPosts'; +import ForumPostDetail from './pages/ForumPostDetail'; +import CreateForumPost from './pages/CreateForumPost'; +import MyPosts from './pages/MyPosts'; import EarningsDashboard from './pages/EarningsDashboard'; import FAQ from './pages/FAQ'; import PrivateRoute from './components/PrivateRoute'; @@ -158,21 +158,21 @@ const AppContent: React.FC = () => { } /> - } /> - } /> + } /> + } /> - + } /> - + } /> diff --git a/frontend/src/components/CategoryBadge.tsx b/frontend/src/components/CategoryBadge.tsx new file mode 100644 index 0000000..ff24425 --- /dev/null +++ b/frontend/src/components/CategoryBadge.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +interface CategoryBadgeProps { + category: "item_request" | "technical_support" | "community_resources" | "general_discussion"; +} + +const CategoryBadge: React.FC = ({ category }) => { + const getCategoryConfig = (cat: string) => { + switch (cat) { + case 'item_request': + return { label: 'Item Request', color: 'primary', icon: 'bi-box-seam' }; + case 'technical_support': + return { label: 'Technical Support', color: 'warning', icon: 'bi-tools' }; + case 'community_resources': + return { label: 'Community Resources', color: 'info', icon: 'bi-people' }; + case 'general_discussion': + return { label: 'General Discussion', color: 'secondary', icon: 'bi-chat-dots' }; + default: + return { label: 'General', color: 'secondary', icon: 'bi-chat' }; + } + }; + + const config = getCategoryConfig(category); + + return ( + + + {config.label} + + ); +}; + +export default CategoryBadge; diff --git a/frontend/src/components/CommentForm.tsx b/frontend/src/components/CommentForm.tsx new file mode 100644 index 0000000..47531ba --- /dev/null +++ b/frontend/src/components/CommentForm.tsx @@ -0,0 +1,86 @@ +import React, { useState } from 'react'; + +interface CommentFormProps { + onSubmit: (content: string) => Promise; + onCancel?: () => void; + placeholder?: string; + buttonText?: string; + isReply?: boolean; +} + +const CommentForm: React.FC = ({ + onSubmit, + onCancel, + placeholder = 'Write your comment...', + buttonText = 'Post Comment', + isReply = false, +}) => { + const [content, setContent] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!content.trim()) { + setError('Comment cannot be empty'); + return; + } + + setIsSubmitting(true); + setError(''); + + try { + await onSubmit(content); + setContent(''); + } catch (err: any) { + setError(err.message || 'Failed to post comment'); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+