From 158ae3e2c1d935a39dcd6241efb731aa91802add Mon Sep 17 00:00:00 2001 From: Jeet Date: Wed, 15 Oct 2025 14:34:35 +0530 Subject: [PATCH] feat: implement Question model, controller, and addUpvote functionality --- backend/controllers/questionController.js | 477 +++++----------------- backend/models/Question.js | 98 ++--- 2 files changed, 139 insertions(+), 436 deletions(-) diff --git a/backend/controllers/questionController.js b/backend/controllers/questionController.js index 4ed152d..bb63436 100644 --- a/backend/controllers/questionController.js +++ b/backend/controllers/questionController.js @@ -1,84 +1,25 @@ -/** - * QUESTION CONTROLLER - Handles all question-related operations - * - * HACKTOBERFEST TODO: - * This controller manages CRUD operations for interview questions. - * - * CONTRIBUTOR TASKS: - * Implement the following controller functions: - * - * 1. createQuestion - POST /api/questions - * 2. getAllQuestions - GET /api/questions (with filtering, sorting, pagination) - * 3. getQuestionById - GET /api/questions/:id - * 4. updateQuestion - PUT /api/questions/:id - * 5. deleteQuestion - DELETE /api/questions/:id - * 6. upvoteQuestion - POST /api/questions/:id/upvote - * 7. getQuestionUpvotes - GET /api/questions/:id/upvotes - * 8. searchQuestions - GET /api/questions/search - * 9. getCategories - GET /api/categories - * - * FILTERING & SORTING: - * - Filter by: company, topic, role, difficulty, date range - * - Sort by: latest, oldest, upvotes - * - Pagination: page, limit query params - * - * TIMESTAMPS: - * - All questions include createdAt and updatedAt - * - Return timestamps in ISO format - */ - import Question from '../models/Question.js'; /** - * TODO: IMPLEMENT CREATE QUESTION - * - * @route POST /api/questions - * @desc Create a new interview question - * @access Private (authenticated users only) - * - * Steps: - * 1. Extract questionText, company, topic, role, difficulty from req.body - * 2. Add submittedBy field from req.user.id (optional - for anonymous support) - * 3. Create question using Question.create() - * 4. Return 201 response with created question - * 5. Handle errors (validation, duplicate, etc.) - * - * REQUEST BODY: - * { - * questionText: "Explain event loop in Node.js", - * company: "Amazon", - * topic: "JavaScript", - * role: "SDE", - * difficulty: "Medium" - * } - * - * RESPONSE: - * { - * success: true, - * message: "Question created successfully", - * data: { question object with id, timestamps, etc. } - * } + * Create a new question */ - export const createQuestion = async (req, res, next) => { try { - // TODO: Implement create question logic - const { questionText, company, topic, role, difficulty } = req.body; - // Create question with submittedBy field (optional for anonymous) - // const question = await Question.create({ - // questionText, - // company, - // topic, - // role, - // difficulty, - // submittedBy: req.user ? req.user.id : null - // }); - - res.status(501).json({ - success: false, - message: 'Create question endpoint not implemented yet', + const question = await Question.create({ + questionText, + company, + topic, + role, + difficulty, + submittedBy: req.user ? req.user.id : null + }); + + res.status(201).json({ + success: true, + message: 'Question created successfully', + data: question }); } catch (error) { next(error); @@ -86,88 +27,46 @@ export const createQuestion = async (req, res, next) => { }; /** - * TODO: IMPLEMENT GET ALL QUESTIONS - * - * @route GET /api/questions - * @desc Get all questions with filtering, sorting, and pagination - * @access Public - * - * QUERY PARAMETERS: - * - company: Filter by company name - * - topic: Filter by topic - * - role: Filter by job role - * - difficulty: Filter by difficulty (Easy/Medium/Hard) - * - sort: Sort by 'latest', 'oldest', or 'upvotes' - * - fromDate: Filter questions from this date - * - toDate: Filter questions until this date - * - page: Page number (default: 1) - * - limit: Results per page (default: 10) - * - * Steps: - * 1. Build filter object based on query params - * 2. Build sort object based on sort param - * 3. Calculate skip value for pagination: (page - 1) * limit - * 4. Query database with filters, sort, skip, and limit - * 5. Get total count for pagination info - * 6. Return questions with pagination metadata - * - * EXAMPLE QUERY: - * GET /api/questions?company=Amazon&topic=Arrays&sort=latest&page=1&limit=10 - * - * RESPONSE: - * { - * success: true, - * count: 45, - * page: 1, - * pages: 5, - * data: [array of questions] - * } + * Get all questions with filtering, sorting, pagination */ - export const getAllQuestions = async (req, res, next) => { try { - // TODO: Implement get all questions with filters - - // Extract query parameters const { company, topic, role, difficulty, sort, fromDate, toDate, page = 1, limit = 10 } = req.query; - // Build filter object const filter = {}; - // if (company) filter.company = company; - // if (topic) filter.topic = topic; - // if (role) filter.role = role; - // if (difficulty) filter.difficulty = difficulty; - - // Date range filtering - // if (fromDate || toDate) { - // filter.createdAt = {}; - // if (fromDate) filter.createdAt.$gte = new Date(fromDate); - // if (toDate) filter.createdAt.$lte = new Date(toDate); - // } - - // Build sort object - // let sortOption = {}; - // if (sort === 'latest') sortOption = { createdAt: -1 }; - // else if (sort === 'oldest') sortOption = { createdAt: 1 }; - // else if (sort === 'upvotes') sortOption = { upvotes: -1 }; - // else sortOption = { createdAt: -1 }; // default - - // Pagination - // const skip = (page - 1) * limit; - - // Execute query - // const questions = await Question.find(filter) - // .sort(sortOption) - // .skip(skip) - // .limit(parseInt(limit)) - // .populate('submittedBy', 'name'); - - // Get total count - // const total = await Question.countDocuments(filter); - - res.status(501).json({ - success: false, - message: 'Get questions endpoint not implemented yet', + if (company) filter.company = company; + if (topic) filter.topic = topic; + if (role) filter.role = role; + if (difficulty) filter.difficulty = difficulty; + + if (fromDate || toDate) { + filter.createdAt = {}; + if (fromDate) filter.createdAt.$gte = new Date(fromDate); + if (toDate) filter.createdAt.$lte = new Date(toDate); + } + + let sortOption = {}; + if (sort === 'latest') sortOption = { createdAt: -1 }; + else if (sort === 'oldest') sortOption = { createdAt: 1 }; + else if (sort === 'upvotes') sortOption = { upvotes: -1 }; + else sortOption = { createdAt: -1 }; + + const skip = (page - 1) * limit; + + const questions = await Question.find(filter) + .sort(sortOption) + .skip(skip) + .limit(parseInt(limit)) + .populate('submittedBy', 'name email'); + + const total = await Question.countDocuments(filter); + + res.status(200).json({ + success: true, + count: total, + page: parseInt(page), + pages: Math.ceil(total / limit), + data: questions }); } catch (error) { next(error); @@ -175,184 +74,86 @@ export const getAllQuestions = async (req, res, next) => { }; /** - * TODO: IMPLEMENT GET QUESTION BY ID - * - * @route GET /api/questions/:id - * @desc Get a single question by ID - * @access Public - * - * Steps: - * 1. Extract id from req.params - * 2. Find question using Question.findById() - * 3. Optionally populate submittedBy field - * 4. If not found, return 404 error - * 5. Return question data + * Get question by ID */ - export const getQuestionById = async (req, res, next) => { try { - // TODO: Implement get question by ID - const { id } = req.params; + const question = await Question.findById(id).populate('submittedBy', 'name email'); - // const question = await Question.findById(id).populate('submittedBy', 'name email'); - - // if (!question) { - // return res.status(404).json({ - // success: false, - // message: 'Question not found' - // }); - // } + if (!question) { + return res.status(404).json({ success: false, message: 'Question not found' }); + } - res.status(501).json({ - success: false, - message: 'Get question by ID endpoint not implemented yet', - }); + res.status(200).json({ success: true, data: question }); } catch (error) { next(error); } }; /** - * TODO: IMPLEMENT UPDATE QUESTION - * - * @route PUT /api/questions/:id - * @desc Update a question (admin or question owner only) - * @access Private - * - * AUTHORIZATION: - * - Only admin or the user who submitted can update - * - Check: req.user.role === 'admin' || question.submittedBy === req.user.id - * - * Steps: - * 1. Find question by ID - * 2. Check authorization - * 3. Update allowed fields (questionText, topic, difficulty) - * 4. Save updated question - * 5. Return updated question - * - * ALLOWED UPDATES: questionText, topic, difficulty - * NOT ALLOWED: company, role, upvotes, submittedBy + * Update question */ - export const updateQuestion = async (req, res, next) => { try { - // TODO: Implement update question - const { id } = req.params; const { questionText, topic, difficulty } = req.body; - // Find question - // const question = await Question.findById(id); - - // Check if exists - // if (!question) return 404 + const question = await Question.findById(id); + if (!question) return res.status(404).json({ success: false, message: 'Question not found' }); - // Check authorization - // if (req.user.role !== 'admin' && question.submittedBy.toString() !== req.user.id) { - // return 403 error - // } + if (req.user.role !== 'admin' && question.submittedBy?.toString() !== req.user.id) { + return res.status(403).json({ success: false, message: 'Not authorized' }); + } - // Update fields - // if (questionText) question.questionText = questionText; - // if (topic) question.topic = topic; - // if (difficulty) question.difficulty = difficulty; + if (questionText) question.questionText = questionText; + if (topic) question.topic = topic; + if (difficulty) question.difficulty = difficulty; - // Save - // const updatedQuestion = await question.save(); - - res.status(501).json({ - success: false, - message: 'Update question endpoint not implemented yet', - }); + const updatedQuestion = await question.save(); + res.status(200).json({ success: true, data: updatedQuestion }); } catch (error) { next(error); } }; /** - * TODO: IMPLEMENT DELETE QUESTION - * - * @route DELETE /api/questions/:id - * @desc Delete a question (admin or owner only) - * @access Private - * - * AUTHORIZATION: - * - Only admin or the user who submitted can delete - * - * Steps: - * 1. Find question by ID - * 2. Check if exists - * 3. Check authorization - * 4. Delete using question.deleteOne() or Question.findByIdAndDelete() - * 5. Return success message + * Delete question */ - export const deleteQuestion = async (req, res, next) => { try { - // TODO: Implement delete question - const { id } = req.params; + const question = await Question.findById(id); + if (!question) return res.status(404).json({ success: false, message: 'Question not found' }); - // Find and check authorization - // const question = await Question.findById(id); - - // Delete - // await question.deleteOne(); - // OR - // await Question.findByIdAndDelete(id); + if (req.user.role !== 'admin' && question.submittedBy?.toString() !== req.user.id) { + return res.status(403).json({ success: false, message: 'Not authorized' }); + } - res.status(501).json({ - success: false, - message: 'Delete question endpoint not implemented yet', - }); + await question.deleteOne(); + res.status(200).json({ success: true, message: 'Question deleted successfully' }); } catch (error) { next(error); } }; /** - * TODO: IMPLEMENT UPVOTE QUESTION - * - * @route POST /api/questions/:id/upvote - * @desc Upvote or remove upvote from a question (toggle) - * @access Private (authenticated users only) - * - * Steps: - * 1. Find question by ID - * 2. Check if user already upvoted (check upvotedBy array) - * 3. If upvoted: remove upvote (toggle off) - * 4. If not upvoted: add upvote (toggle on) - * 5. Update upvotes count - * 6. Save question - * 7. Return new upvote count - * - * HINT: Use the addUpvote instance method from Question model - * - * RESPONSE: - * { - * success: true, - * message: "Question upvoted" or "Upvote removed", - * upvotes: 42 - * } + * Upvote question */ - export const upvoteQuestion = async (req, res, next) => { try { - // TODO: Implement upvote toggle - const { id } = req.params; const userId = req.user.id; - // Find question - // const question = await Question.findById(id); + const question = await Question.findById(id); + if (!question) return res.status(404).json({ success: false, message: 'Question not found' }); - // Use the addUpvote method from model - // await question.addUpvote(userId); + await question.addUpvote(userId); - res.status(501).json({ - success: false, - message: 'Upvote endpoint not implemented yet', + res.status(200).json({ + success: true, + message: 'Upvote toggled', + upvotes: question.upvotes }); } catch (error) { next(error); @@ -360,130 +161,54 @@ export const upvoteQuestion = async (req, res, next) => { }; /** - * TODO: IMPLEMENT GET QUESTION UPVOTES - * - * @route GET /api/questions/:id/upvotes - * @desc Get total upvotes for a question - * @access Public - * - * Steps: - * 1. Find question by ID - * 2. Return upvotes count - * - * RESPONSE: - * { - * success: true, - * upvotes: 42 - * } + * Get total upvotes */ - export const getQuestionUpvotes = async (req, res, next) => { try { - // TODO: Implement get upvotes - const { id } = req.params; + const question = await Question.findById(id); + if (!question) return res.status(404).json({ success: false, message: 'Question not found' }); - // const question = await Question.findById(id); - // if (!question) return 404 - - res.status(501).json({ - success: false, - message: 'Get upvotes endpoint not implemented yet', - }); + res.status(200).json({ success: true, upvotes: question.upvotes }); } catch (error) { next(error); } }; /** - * TODO: IMPLEMENT SEARCH QUESTIONS - * - * @route GET /api/questions/search?q=keyword - * @desc Search questions by keyword in questionText, company, or topic - * @access Public - * - * Steps: - * 1. Extract 'q' query parameter (search keyword) - * 2. Build regex search query for questionText, company, and topic - * 3. Use $or operator to search across multiple fields - * 4. Return matching questions - * - * EXAMPLE: - * const searchRegex = new RegExp(keyword, 'i'); // case-insensitive - * const questions = await Question.find({ - * $or: [ - * { questionText: searchRegex }, - * { company: searchRegex }, - * { topic: searchRegex } - * ] - * }); + * Search questions */ - export const searchQuestions = async (req, res, next) => { try { - // TODO: Implement search functionality - const { q } = req.query; + if (!q) return res.status(400).json({ success: false, message: 'Search query is required' }); - // if (!q) return 400 error - - // Build regex search - // const searchRegex = new RegExp(q, 'i'); + const regex = new RegExp(q, 'i'); - // Search across multiple fields - // const questions = await Question.find({ - // $or: [ - // { questionText: searchRegex }, - // { company: searchRegex }, - // { topic: searchRegex } - // ] - // }).sort({ createdAt: -1 }); + const questions = await Question.find({ + $or: [ + { questionText: regex }, + { company: regex }, + { topic: regex } + ] + }).sort({ createdAt: -1 }); - res.status(501).json({ - success: false, - message: 'Search endpoint not implemented yet', - }); + res.status(200).json({ success: true, data: questions }); } catch (error) { next(error); } }; /** - * TODO: IMPLEMENT GET CATEGORIES - * - * @route GET /api/categories - * @desc Get list of all unique topics and companies - * @access Public - * - * Steps: - * 1. Use Question.distinct() to get unique values - * 2. Get distinct topics: Question.distinct('topic') - * 3. Get distinct companies: Question.distinct('company') - * 4. Get distinct roles: Question.distinct('role') - * 5. Return all three arrays - * - * RESPONSE: - * { - * success: true, - * topics: ['Arrays', 'Graphs', 'System Design', ...], - * companies: ['Google', 'Amazon', 'Microsoft', ...], - * roles: ['SDE', 'Analyst', 'Frontend', ...] - * } + * Get categories (distinct topics, companies, roles) */ - export const getCategories = async (req, res, next) => { try { - // TODO: Implement get categories + const topics = await Question.distinct('topic'); + const companies = await Question.distinct('company'); + const roles = await Question.distinct('role'); - // Get distinct values - // const topics = await Question.distinct('topic'); - // const companies = await Question.distinct('company'); - // const roles = await Question.distinct('role'); - - res.status(501).json({ - success: false, - message: 'Get categories endpoint not implemented yet', - }); + res.status(200).json({ success: true, topics, companies, roles }); } catch (error) { next(error); } diff --git a/backend/models/Question.js b/backend/models/Question.js index 3fbea3c..0c83bb4 100644 --- a/backend/models/Question.js +++ b/backend/models/Question.js @@ -1,105 +1,83 @@ -/** - * QUESTION MODEL - Defines the schema for interview questions - * - * HACKTOBERFEST TODO: - * This model represents interview questions submitted by users. - * - * CONTRIBUTOR TASKS: - * 1. Define required fields: questionText, company, topic, role, difficulty - * 2. Define optional fields: submittedBy, upvotes, upvotedBy - * 3. Enable timestamps - * 4. Add indexes for optimization - * 5. Implement instance method 'addUpvote' to handle upvoting - * 6. Export the Question model - */ - import mongoose from 'mongoose'; /** - * TODO: DEFINE QUESTION SCHEMA - * - * Required Fields: - * - questionText: String, required, trim, minlength 10 - * - company: String, required, trim - * - topic: String, required, trim - * - role: String, required, trim - * - difficulty: String, required, enum ['Easy', 'Medium', 'Hard'] - * - * Optional Fields: - * - submittedBy: ObjectId, ref 'User' - * - upvotes: Number, default 0, min 0 - * - upvotedBy: [ObjectId], ref 'User', default [] - * - * Schema Options: - * - timestamps: true + * QUESTION SCHEMA */ const questionSchema = new mongoose.Schema( { questionText: { type: String, - // TODO: Add required, trim, and minlength validations + required: true, + trim: true, + minlength: 10 }, company: { type: String, - // TODO: Add required and trim + required: true, + trim: true }, topic: { type: String, - // TODO: Add required and trim + required: true, + trim: true }, role: { type: String, - // TODO: Add required and trim + required: true, + trim: true }, difficulty: { type: String, - // TODO: Add required and enum ['Easy', 'Medium', 'Hard'] + required: true, + enum: ['Easy', 'Medium', 'Hard'] }, submittedBy: { type: mongoose.Schema.Types.ObjectId, - ref: 'User', - // Optional: questions can be anonymous + ref: 'User' }, upvotes: { type: Number, default: 0, - // TODO: Add min 0 validation + min: 0 }, upvotedBy: { type: [mongoose.Schema.Types.ObjectId], ref: 'User', - default: [], - }, + default: [] + } }, { - // TODO: Enable timestamps + timestamps: true } ); /** - * TODO: ADD INDEXES FOR QUERY OPTIMIZATION - * - * Add indexes on frequently queried fields to improve performance - * Example: company, topic, role, difficulty, compound indexes + * INDEXES FOR OPTIMIZATION */ -// questionSchema.index({ /* TODO */ }); +questionSchema.index({ company: 1 }); +questionSchema.index({ topic: 1 }); +questionSchema.index({ difficulty: 1 }); +questionSchema.index({ questionText: 'text' }); +questionSchema.index({ company: 1, topic: 1, role: 1 }); /** - * TODO: ADD INSTANCE METHOD - addUpvote - * - * This method should toggle upvote by a given userId - * Steps: - * 1. Check if user already upvoted - * 2. If yes: remove upvote - * 3. If no: add upvote - * 4. Save and return updated question + * INSTANCE METHOD: addUpvote */ -// questionSchema.methods.addUpvote = async function(userId) { -// // TODO: Implement upvote toggle logic -// }; +questionSchema.methods.addUpvote = async function(userId) { + if (this.upvotedBy.includes(userId)) { + // Remove upvote + this.upvotedBy.pull(userId); + this.upvotes = this.upvotes - 1; + } else { + // Add upvote + this.upvotedBy.push(userId); + this.upvotes = this.upvotes + 1; + } + return await this.save(); +}; /** - * TODO: CREATE AND EXPORT QUESTION MODEL + * CREATE AND EXPORT QUESTION MODEL */ const Question = mongoose.model('Question', questionSchema); -export default Question; +export default Question; \ No newline at end of file