diff --git a/server/controllers/answersController.js b/server/controllers/answersController.js index b064481..2f5a30a 100644 --- a/server/controllers/answersController.js +++ b/server/controllers/answersController.js @@ -1,114 +1,38 @@ const Answer = require('../models/Answer'); -const Notification = require('../models/Notification'); - -// Helper function to create notifications -const createNotification = async (recipientId, senderId, type, content, questionId, answerId) => { - try { - // Don't create notification if user is voting on their own content - if (recipientId.toString() === senderId.toString()) { - return; - } - - await Notification.create({ - recipient: recipientId, - sender: senderId, - type: type, - questionId: questionId, - answerId: answerId - }).then(notification => { - console.log('📩 Notification created:', notification); - }); - } catch (error) { - console.error('❌ Error creating notification:', error); - } -}; +const { voteOnContent, sendVoteResponse } = require('../services/voting'); // Upvote Answer exports.upvoteAnswer = async (req, res) => { try { - console.log('⬆️ Upvote called:', req.params.answerId, 'User:', req.user._id); - const answer = await Answer.findById(req.params.answerId).populate('author', 'username'); - if (!answer) return res.status(404).json({ message: 'Answer not found' }); + const result = await voteOnContent({ + model: Answer, + contentId: req.params.answerId, + voter: req.user, + voteType: 'upvote', + contentType: 'answer' + }); - const userId = req.user._id.toString(); - - // Check if user already upvoted - const existingUpvote = answer.votes.upvotes.find(vote => vote.user.toString() === userId); - const existingDownvote = answer.votes.downvotes.find(vote => vote.user.toString() === userId); - - if (existingUpvote) { - // Remove upvote (toggle off) - answer.votes.upvotes = answer.votes.upvotes.filter(vote => vote.user.toString() !== userId); - console.log('⬆️ Upvote removed'); - } else { - // Add upvote and remove downvote if exists - answer.votes.upvotes.push({ user: req.user._id }); - answer.votes.downvotes = answer.votes.downvotes.filter(vote => vote.user.toString() !== userId); - console.log('⬆️ Upvote added'); - - // Create notification for answer author - await createNotification( - answer.author._id, - req.user._id, - 'upvote', - `${req.user.username} upvoted your answer`, - answer.question, - answer._id - ); - } - - await answer.save(); - await answer.populate('author', 'username avatar reputation'); - - console.log('✅ Upvote successful. New vote count:', answer.voteCount); - res.status(200).json(answer); + sendVoteResponse(res, { ...result, contentType: 'answer' }); } catch (error) { console.error('❌ Upvote error:', error); - res.status(500).json({ message: 'Server error', error: error.message }); + res.status(error.statusCode || 500).json({ message: error.statusCode ? error.message : 'Server error', error: error.message }); } }; // Downvote Answer exports.downvoteAnswer = async (req, res) => { try { - console.log('⬇️ Downvote called:', req.params.answerId, 'User:', req.user._id); - const answer = await Answer.findById(req.params.answerId).populate('author', 'username'); - if (!answer) return res.status(404).json({ message: 'Answer not found' }); + const result = await voteOnContent({ + model: Answer, + contentId: req.params.answerId, + voter: req.user, + voteType: 'downvote', + contentType: 'answer' + }); - const userId = req.user._id.toString(); - - // Check if user already downvoted - const existingDownvote = answer.votes.downvotes.find(vote => vote.user.toString() === userId); - const existingUpvote = answer.votes.upvotes.find(vote => vote.user.toString() === userId); - - if (existingDownvote) { - // Remove downvote (toggle off) - answer.votes.downvotes = answer.votes.downvotes.filter(vote => vote.user.toString() !== userId); - console.log('⬇️ Downvote removed'); - } else { - // Add downvote and remove upvote if exists - answer.votes.downvotes.push({ user: req.user._id }); - answer.votes.upvotes = answer.votes.upvotes.filter(vote => vote.user.toString() !== userId); - console.log('⬇️ Downvote added'); - - // Create notification for answer author - await createNotification( - answer.author._id, - req.user._id, - 'downvote', - `${req.user.username} downvoted your answer`, - answer.question, - answer._id - ); - } - - await answer.save(); - await answer.populate('author', 'username avatar reputation'); - - console.log('✅ Downvote successful. New vote count:', answer.voteCount); - res.status(200).json(answer); + sendVoteResponse(res, { ...result, contentType: 'answer' }); } catch (error) { console.error('❌ Downvote error:', error); - res.status(500).json({ message: 'Server error', error: error.message }); + res.status(error.statusCode || 500).json({ message: error.statusCode ? error.message : 'Server error', error: error.message }); } -}; \ No newline at end of file +}; diff --git a/server/routes/questions.js b/server/routes/questions.js index bc86326..9d64260 100644 --- a/server/routes/questions.js +++ b/server/routes/questions.js @@ -2,9 +2,9 @@ const express = require('express') const { body, validationResult } = require('express-validator') const Question = require('../models/Question') const Answer = require('../models/Answer') -const User = require('../models/User') -const { authenticateToken, requireAdmin } = require('../middleware/auth') +const { authenticateToken } = require('../middleware/auth') const { acceptAnswer } = require('../services/answerAcceptance') +const { voteOnContent, sendVoteResponse } = require('../services/voting') const router = express.Router() @@ -377,51 +377,18 @@ router.delete('/:id', authenticateToken, async (req, res) => { // @access Private router.post('/:id/upvote', authenticateToken, async (req, res) => { try { - console.log('⬆️ Question upvote called:', req.params.id, 'User:', req.user._id); - const question = await Question.findById(req.params.id).populate('author', 'username'); - - if (!question || question.isDeleted) { - return res.status(404).json({ message: 'Question not found' }); - } + const result = await voteOnContent({ + model: Question, + contentId: req.params.id, + voter: req.user, + voteType: 'upvote', + contentType: 'question' + }) - const userId = req.user._id.toString(); - const existingUpvote = question.votes.upvotes.find(vote => vote.user.toString() === userId); - const existingDownvote = question.votes.downvotes.find(vote => vote.user.toString() === userId); - - if (existingUpvote) { - // Remove upvote (toggle off) - question.votes.upvotes = question.votes.upvotes.filter(vote => vote.user.toString() !== userId); - console.log('⬆️ Question upvote removed'); - } else { - // Add upvote and remove downvote if exists - question.votes.upvotes.push({ user: req.user._id }); - question.votes.downvotes = question.votes.downvotes.filter(vote => vote.user.toString() !== userId); - console.log('⬆️ Question upvote added'); - - // Create notification for question author (if not self) - if (question.author._id.toString() !== req.user._id.toString()) { - try { - const notification = await require('../models/Notification').create({ - recipient: question.author._id, - sender: req.user._id, - type: 'upvote', - questionId: question._id - }); - console.log('📩 Notification created:', notification); - } catch (error) { - console.error('❌ Error creating question upvote notification:', error); - } - } - } - - await question.save(); - await question.populate('author', 'username reputation avatar'); - - console.log('✅ Question upvote successful. New vote count:', question.voteCount); - res.status(200).json(question); + sendVoteResponse(res, { ...result, contentType: 'question' }) } catch (error) { console.error('❌ Question upvote error:', error); - res.status(500).json({ message: 'Server error', error: error.message }); + res.status(error.statusCode || 500).json({ message: error.statusCode ? error.message : 'Server error', error: error.message }); } }); @@ -430,51 +397,18 @@ router.post('/:id/upvote', authenticateToken, async (req, res) => { // @access Private router.post('/:id/downvote', authenticateToken, async (req, res) => { try { - console.log('⬇️ Question downvote called:', req.params.id, 'User:', req.user._id); - const question = await Question.findById(req.params.id).populate('author', 'username'); - - if (!question || question.isDeleted) { - return res.status(404).json({ message: 'Question not found' }); - } + const result = await voteOnContent({ + model: Question, + contentId: req.params.id, + voter: req.user, + voteType: 'downvote', + contentType: 'question' + }) - const userId = req.user._id.toString(); - const existingDownvote = question.votes.downvotes.find(vote => vote.user.toString() === userId); - const existingUpvote = question.votes.upvotes.find(vote => vote.user.toString() === userId); - - if (existingDownvote) { - // Remove downvote (toggle off) - question.votes.downvotes = question.votes.downvotes.filter(vote => vote.user.toString() !== userId); - console.log('⬇️ Question downvote removed'); - } else { - // Add downvote and remove upvote if exists - question.votes.downvotes.push({ user: req.user._id }); - question.votes.upvotes = question.votes.upvotes.filter(vote => vote.user.toString() !== userId); - console.log('⬇️ Question downvote added'); - - // Create notification for question author (if not self) - if (question.author._id.toString() !== req.user._id.toString()) { - try { - const notification = await require('../models/Notification').create({ - recipient: question.author._id, - sender: req.user._id, - type: 'downvote', - questionId: question._id - }); - console.log('📩 Notification created:', notification); - } catch (error) { - console.error('❌ Error creating question downvote notification:', error); - } - } - } - - await question.save(); - await question.populate('author', 'username reputation avatar'); - - console.log('✅ Question downvote successful. New vote count:', question.voteCount); - res.status(200).json(question); + sendVoteResponse(res, { ...result, contentType: 'question' }) } catch (error) { console.error('❌ Question downvote error:', error); - res.status(500).json({ message: 'Server error', error: error.message }); + res.status(error.statusCode || 500).json({ message: error.statusCode ? error.message : 'Server error', error: error.message }); } }); @@ -496,33 +430,20 @@ router.post('/:id/vote', [ }) } - const question = await Question.findById(req.params.id) - - if (!question || question.isDeleted) { - return res.status(404).json({ message: 'Question not found' }) - } - - // Check if user can vote - if (!req.user.canVote()) { - return res.status(403).json({ message: 'Insufficient reputation to vote' }) - } - const { voteType } = req.body - await question.addVote(req.user._id, voteType) - - // Update author reputation - const reputationChange = voteType === 'upvote' ? 10 : -2 - await question.author.updateReputation(reputationChange) - - res.json({ - message: 'Vote recorded successfully', - voteCount: question.voteCount, - totalVotes: question.totalVotes + const result = await voteOnContent({ + model: Question, + contentId: req.params.id, + voter: req.user, + voteType, + contentType: 'question' }) + + sendVoteResponse(res, { ...result, contentType: 'question' }) } catch (error) { console.error('Vote error:', error) - res.status(500).json({ message: 'Server error' }) + res.status(error.statusCode || 500).json({ message: error.statusCode ? error.message : 'Server error' }) } }) diff --git a/server/services/voting.js b/server/services/voting.js new file mode 100644 index 0000000..eb70160 --- /dev/null +++ b/server/services/voting.js @@ -0,0 +1,148 @@ +const Notification = require('../models/Notification') + +const REPUTATION_BY_VOTE = { + upvote: 10, + downvote: -2 +} + +class VoteError extends Error { + constructor(message, statusCode) { + super(message) + this.statusCode = statusCode + } +} + +const getVoteStatus = (content, userId) => { + const voterId = userId.toString() + const upvoted = content.votes.upvotes.some(vote => vote.user.toString() === voterId) + const downvoted = content.votes.downvotes.some(vote => vote.user.toString() === voterId) + + if (upvoted) return 'upvote' + if (downvoted) return 'downvote' + return null +} + +const getReputationValue = voteType => REPUTATION_BY_VOTE[voteType] || 0 + +const applyVoteChange = (content, voterId, voteType) => { + const previousVoteStatus = getVoteStatus(content, voterId) + const nextVoteStatus = previousVoteStatus === voteType ? null : voteType + const voterIdString = voterId.toString() + + content.votes.upvotes = content.votes.upvotes.filter(vote => vote.user.toString() !== voterIdString) + content.votes.downvotes = content.votes.downvotes.filter(vote => vote.user.toString() !== voterIdString) + + if (nextVoteStatus === 'upvote') { + content.votes.upvotes.push({ user: voterId }) + } else if (nextVoteStatus === 'downvote') { + content.votes.downvotes.push({ user: voterId }) + } + + return { + previousVoteStatus, + voteStatus: nextVoteStatus, + reputationDelta: getReputationValue(nextVoteStatus) - getReputationValue(previousVoteStatus) + } +} + +const createVoteNotification = async ({ + recipientId, + senderId, + voteType, + contentType, + questionId, + answerId +}) => { + if (!recipientId || recipientId.toString() === senderId.toString()) { + return + } + + try { + await Notification.create({ + recipient: recipientId, + sender: senderId, + type: voteType, + title: `${contentType === 'answer' ? 'Answer' : 'Question'} ${voteType}`, + content: `Your ${contentType} was ${voteType === 'upvote' ? 'upvoted' : 'downvoted'}`, + questionId, + answerId + }) + } catch (error) { + console.error('Vote notification error:', error) + } +} + +const voteOnContent = async ({ + model, + contentId, + voter, + voteType, + contentType +}) => { + if (!['upvote', 'downvote'].includes(voteType)) { + throw new VoteError('Vote type must be upvote or downvote', 400) + } + + const content = await model.findById(contentId).populate('author', 'username avatar reputation') + + if (!content || content.isDeleted) { + throw new VoteError(`${contentType === 'answer' ? 'Answer' : 'Question'} not found`, 404) + } + + if (!content.author || !content.author._id) { + throw new VoteError(`${contentType === 'answer' ? 'Answer' : 'Question'} author not found`, 500) + } + + if (!voter.canVote()) { + throw new VoteError('Insufficient reputation to vote', 403) + } + + if (content.author._id.toString() === voter._id.toString()) { + throw new VoteError('You cannot vote on your own content', 403) + } + + const { previousVoteStatus, voteStatus, reputationDelta } = applyVoteChange(content, voter._id, voteType) + + await content.save() + + if (reputationDelta !== 0 && typeof content.author.updateReputation === 'function') { + await content.author.updateReputation(reputationDelta) + } + + await content.populate('author', 'username avatar reputation') + + if (voteStatus === voteType && previousVoteStatus !== voteType) { + await createVoteNotification({ + recipientId: content.author._id, + senderId: voter._id, + voteType, + contentType, + questionId: contentType === 'question' ? content._id : content.question, + answerId: contentType === 'answer' ? content._id : undefined + }) + } + + return { + content, + voteStatus, + voteCount: content.voteCount, + totalVotes: content.totalVotes + } +} + +const sendVoteResponse = (res, { content, voteStatus, voteCount, totalVotes, contentType }) => { + res.status(200).json({ + message: 'Vote recorded successfully', + voteStatus, + voteCount, + totalVotes, + [contentType]: content + }) +} + +module.exports = { + VoteError, + applyVoteChange, + voteOnContent, + sendVoteResponse +} diff --git a/tests/voting-service.test.js b/tests/voting-service.test.js new file mode 100644 index 0000000..9fc7197 --- /dev/null +++ b/tests/voting-service.test.js @@ -0,0 +1,109 @@ +const mongoose = require('mongoose') +const Notification = require('../server/models/Notification') +const { applyVoteChange, voteOnContent } = require('../server/services/voting') + +const objectId = () => new mongoose.Types.ObjectId() + +describe('voting service', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + test('toggles votes and returns the correct reputation delta', () => { + const voterId = objectId() + const content = { + votes: { + upvotes: [], + downvotes: [{ user: voterId }] + } + } + + const result = applyVoteChange(content, voterId, 'upvote') + + expect(result).toEqual({ + previousVoteStatus: 'downvote', + voteStatus: 'upvote', + reputationDelta: 12 + }) + expect(content.votes.upvotes).toHaveLength(1) + expect(content.votes.downvotes).toHaveLength(0) + }) + + test('rejects voters without enough reputation before saving', async () => { + const authorId = objectId() + const content = { + _id: objectId(), + author: { _id: authorId }, + isDeleted: false, + votes: { upvotes: [], downvotes: [] }, + save: jest.fn() + } + const model = { + findById: jest.fn(() => ({ + populate: jest.fn().mockResolvedValue(content) + })) + } + const voter = { + _id: objectId(), + canVote: jest.fn(() => false) + } + + await expect(voteOnContent({ + model, + contentId: content._id, + voter, + voteType: 'upvote', + contentType: 'question' + })).rejects.toMatchObject({ + message: 'Insufficient reputation to vote', + statusCode: 403 + }) + + expect(content.save).not.toHaveBeenCalled() + }) + + test('updates reputation on the populated author document', async () => { + jest.spyOn(Notification, 'create').mockResolvedValue({}) + + const authorId = objectId() + const voterId = objectId() + const content = { + _id: objectId(), + author: { + _id: authorId, + updateReputation: jest.fn().mockResolvedValue(undefined) + }, + isDeleted: false, + votes: { upvotes: [], downvotes: [] }, + save: jest.fn().mockResolvedValue(undefined), + populate: jest.fn().mockResolvedValue(undefined), + get voteCount() { + return this.votes.upvotes.length - this.votes.downvotes.length + }, + get totalVotes() { + return this.votes.upvotes.length + this.votes.downvotes.length + } + } + const model = { + findById: jest.fn(() => ({ + populate: jest.fn().mockResolvedValue(content) + })) + } + const voter = { + _id: voterId, + canVote: jest.fn(() => true) + } + + const result = await voteOnContent({ + model, + contentId: content._id, + voter, + voteType: 'upvote', + contentType: 'question' + }) + + expect(content.author.updateReputation).toHaveBeenCalledWith(10) + expect(result.voteStatus).toBe('upvote') + expect(result.voteCount).toBe(1) + }) +})