From 22206ce243e18a13b8965b5a7eca96c0ad4bffd8 Mon Sep 17 00:00:00 2001 From: "github-classroom[bot]" <66690702+github-classroom[bot]@users.noreply.github.com> Date: Sat, 1 Mar 2025 03:30:26 +0000 Subject: [PATCH 001/144] Setting up GitHub Classroom Feedback From 4e0f7a28a07332db7660f5a79579de5c7c77acb0 Mon Sep 17 00:00:00 2001 From: "github-classroom[bot]" <66690702+github-classroom[bot]@users.noreply.github.com> Date: Sat, 1 Mar 2025 03:30:29 +0000 Subject: [PATCH 002/144] add deadline --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 62aad77..69871ee 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +[![Review Assignment Due Date](https://classroom.github.com/assets/deadline-readme-button-22041afd0340ce965d47ae6ef1cefeee28c7c493a6346c4f15d667ab976d596c.svg)](https://classroom.github.com/a/fE-a_qEp) The individual and team project for this class are designed to mirror the experiences of a software engineer joining a new development team: you will be “onboarded” to our codebase, make several individual contributions, and then form a team to propose, develop and implement new features. The codebase that we’ll be developing on is a Fake Stack Overflow project (let’s call it HuskyFlow). You will get an opportunity to work with the starter code which provides basic skeleton for the app and then additional features will be proposed and implemented by you! All implementation will take place in the TypeScript programming language, using React for the user interface. ## Getting Started From 40f761ddd25e43bfe88aa94c8010f57b27d150bd Mon Sep 17 00:00:00 2001 From: jessicazha0 Date: Wed, 12 Mar 2025 19:18:39 -0400 Subject: [PATCH 003/144] contributions backend and hook --- .../main/userContributions/index.css | 0 .../main/userContributions/index.tsx | 0 client/src/hooks/useUserContributions.ts | 43 ++++++++ client/src/services/contributionsService.ts | 27 ++++++ server/controllers/contribution.controller.ts | 97 +++++++++++++++++++ 5 files changed, 167 insertions(+) create mode 100644 client/src/components/main/userContributions/index.css create mode 100644 client/src/components/main/userContributions/index.tsx create mode 100644 client/src/hooks/useUserContributions.ts create mode 100644 client/src/services/contributionsService.ts create mode 100644 server/controllers/contribution.controller.ts diff --git a/client/src/components/main/userContributions/index.css b/client/src/components/main/userContributions/index.css new file mode 100644 index 0000000..e69de29 diff --git a/client/src/components/main/userContributions/index.tsx b/client/src/components/main/userContributions/index.tsx new file mode 100644 index 0000000..e69de29 diff --git a/client/src/hooks/useUserContributions.ts b/client/src/hooks/useUserContributions.ts new file mode 100644 index 0000000..29053e0 --- /dev/null +++ b/client/src/hooks/useUserContributions.ts @@ -0,0 +1,43 @@ +import { useEffect, useState } from 'react'; +import getUserContributions from '../services/contributionsService'; + +/** + * Custom hook to fetch and manage user contributions (questions, answers, comments) over a date range. + * Supports fetching contributions for any user by providing a `userID`. + * + * @param userID - The ID of the user whose contributions are being fetched. + * @param startDate - The start date of the range (YYYY-MM-DD format). + * @param endDate - The end date of the range (YYYY-MM-DD format). + * @returns contributions - The fetched contributions data. + * @returns loading - Boolean indicating whether data is loading. + * @returns error - Any error message encountered during the fetch. + */ +const useUserContributions = (userID: string, startDate: string, endDate: string) => { + const [contributions, setContributions] = useState< + { date: string; questions: number; answers: number; comments: number }[] + >([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!userID || !startDate || !endDate) return; + + const fetchContributions = async () => { + setLoading(true); + try { + const data = await getUserContributions(userID, startDate, endDate); + setContributions(data); + } catch (err) { + setError('Error fetching user contributions'); + } finally { + setLoading(false); + } + }; + + fetchContributions(); + }, [userID, startDate, endDate]); + + return { contributions, loading, error }; +}; + +export default useUserContributions; diff --git a/client/src/services/contributionsService.ts b/client/src/services/contributionsService.ts new file mode 100644 index 0000000..60e4759 --- /dev/null +++ b/client/src/services/contributionsService.ts @@ -0,0 +1,27 @@ +import api from './config'; + +const USER_CONTRIBUTIONS_API_URL = `${process.env.REACT_APP_SERVER_URL}/getUserContributions`; + +/** + * Function to get user contributions (questions, answers, comments) within a specified date range. + * + * @param userID - The ID of the user. + * @param startDate - The start date of the range (YYYY-MM-DD format). + * @param endDate - The end date of the range (YYYY-MM-DD format). + * @throws Error if there is an issue fetching the user contributions. + */ +const getUserContributions = async ( + userID: string, + startDate: string, + endDate: string, +): Promise<{ date: string; questions: number; answers: number; comments: number }[]> => { + const res = await api.get( + `${USER_CONTRIBUTIONS_API_URL}?userID=${userID}&startDate=${startDate}&endDate=${endDate}`, + ); + if (res.status !== 200) { + throw new Error('Error when fetching user contributions'); + } + return res.data; +}; + +export default getUserContributions; diff --git a/server/controllers/contribution.controller.ts b/server/controllers/contribution.controller.ts new file mode 100644 index 0000000..f308780 --- /dev/null +++ b/server/controllers/contribution.controller.ts @@ -0,0 +1,97 @@ +import express, { Request, Response } from 'express'; +import QuestionModel from '../models/questions.model'; +import AnswerModel from '../models/answers.model'; +import CommentModel from '../models/comments.model'; + +const userContributionsController = () => { + const router = express.Router(); + + /** + * Retrieves a user's contributions (questions, answers, comments) within a specified date range. + * Aggregates contributions per day to allow frontend visualization. + * + * @param req The request object containing userID, startDate, and endDate as query parameters. + * @param res The HTTP response object used to send back the found contributions or an error message. + * + * @returns A Promise that resolves to void. + */ + const getUserContributions = async (req: Request, res: Response): Promise => { + const { userID, startDate, endDate } = req.query; + + if (!userID || !startDate || !endDate) { + res.status(400).send('Missing required query parameters: userID, startDate, endDate'); + return; + } + + try { + const start = new Date(startDate as string); + const end = new Date(endDate as string); + + const questions = await QuestionModel.aggregate([ + { $match: { askedBy: userID, askDateTime: { $gte: start, $lte: end } } }, + { + $group: { + _id: { $dateToString: { format: '%Y-%m-%d', date: '$askDateTime' } }, + count: { $sum: 1 }, + }, + }, + ]); + + const answers = await AnswerModel.aggregate([ + { $match: { ansBy: userID, ansDateTime: { $gte: start, $lte: end } } }, + { + $group: { + _id: { $dateToString: { format: '%Y-%m-%d', date: '$ansDateTime' } }, + count: { $sum: 1 }, + }, + }, + ]); + + const comments = await CommentModel.aggregate([ + { $match: { commentBy: userID, commentDateTime: { $gte: start, $lte: end } } }, + { + $group: { + _id: { $dateToString: { format: '%Y-%m-%d', date: '$commentDateTime' } }, + count: { $sum: 1 }, + }, + }, + ]); + + const contributionsMap: Record< + string, + { questions: number; answers: number; comments: number } + > = {}; + + const processContributions = ( + data: { _id: string; count: number }[], + type: 'questions' | 'answers' | 'comments', + ) => { + data.forEach(({ _id, count }) => { + if (!contributionsMap[_id]) { + contributionsMap[_id] = { questions: 0, answers: 0, comments: 0 }; + } + contributionsMap[_id][type] += count; + }); + }; + + processContributions(questions, 'questions'); + processContributions(answers, 'answers'); + processContributions(comments, 'comments'); + + const contributionsArray = Object.entries(contributionsMap).map(([date, contributions]) => ({ + date, + ...contributions, + })); + + res.json(contributionsArray); + } catch (err: unknown) { + res.status(500).send(`Error when retrieving user contributions: ${(err as Error).message}`); + } + }; + + router.get('/getUserContributions', getUserContributions); + + return router; +}; + +export default userContributionsController; From 7427d65787e6ba3504e6d73153d7d0a0ae69d1f3 Mon Sep 17 00:00:00 2001 From: Khushi Khan <76977629+khushikhan0@users.noreply.github.com> Date: Fri, 14 Mar 2025 01:15:51 -0400 Subject: [PATCH 004/144] contributions visual --- .../main/baseComponents/dropdown/index.css | 42 ++++++++++++++++ .../main/baseComponents/dropdown/index.tsx | 48 +++++++++++++++++++ .../main/userContributions/index.css | 0 .../main/userContributions/index.tsx | 0 .../src/components/profileSettings/index.tsx | 3 ++ .../userContributionsComponent/index.css | 20 ++++++++ .../userContributionsComponent/index.tsx | 48 +++++++++++++++++++ 7 files changed, 161 insertions(+) create mode 100644 client/src/components/main/baseComponents/dropdown/index.css create mode 100644 client/src/components/main/baseComponents/dropdown/index.tsx delete mode 100644 client/src/components/main/userContributions/index.css delete mode 100644 client/src/components/main/userContributions/index.tsx create mode 100644 client/src/components/profileSettings/userContributionsComponent/index.css create mode 100644 client/src/components/profileSettings/userContributionsComponent/index.tsx diff --git a/client/src/components/main/baseComponents/dropdown/index.css b/client/src/components/main/baseComponents/dropdown/index.css new file mode 100644 index 0000000..351e6ae --- /dev/null +++ b/client/src/components/main/baseComponents/dropdown/index.css @@ -0,0 +1,42 @@ +.dropdown { + position: relative; + display: inline-block; +} + +.dropdown-button { + background-color: darkgray; + color: black; + padding: 10px 15px; + border: none; + border-radius: 5px; + cursor: pointer; + font-size: 16px; +} + +.dropdown-content { + display: none; + position: absolute; + background-color: white; + /* min-width: 150px; + box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.2); + border-radius: 5px; + overflow: hidden; + z-index: 10; */ +} + +.dropdown-content a { + color: black; + padding: 10px 15px; + /* text-decoration: none; + display: block; + font-size: 14px; */ +} + +.dropdown-content a:hover { + background-color: lightgray; +} + +.dropdown-content.show { + display: block; + outline: black; +} diff --git a/client/src/components/main/baseComponents/dropdown/index.tsx b/client/src/components/main/baseComponents/dropdown/index.tsx new file mode 100644 index 0000000..468e5b6 --- /dev/null +++ b/client/src/components/main/baseComponents/dropdown/index.tsx @@ -0,0 +1,48 @@ +import React, { ReactNode, useState } from 'react'; +import './index.css'; + +const Dropdown: React.FC = () => { + const [isOpen, setIsOpen] = useState(false); + const [selectedMonth, setSelectedMonth] = useState('January'); + + const toggleDropdown = () => { + setIsOpen(!isOpen); + }; + + const months: string[] = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + + const handleMonthClick = (month: string) => { + setSelectedMonth(month); + setIsOpen(false); + }; + + return ( +
+ +
+ {months.map((month, index) => ( + + ))} +
+
+ ); +}; + +export default Dropdown; diff --git a/client/src/components/main/userContributions/index.css b/client/src/components/main/userContributions/index.css deleted file mode 100644 index e69de29..0000000 diff --git a/client/src/components/main/userContributions/index.tsx b/client/src/components/main/userContributions/index.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/client/src/components/profileSettings/index.tsx b/client/src/components/profileSettings/index.tsx index 1eae4ed..79bb4ba 100644 --- a/client/src/components/profileSettings/index.tsx +++ b/client/src/components/profileSettings/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import './index.css'; import useProfileSettings from '../../hooks/useProfileSettings'; +import UserContributionsComponent from './userContributionsComponent'; const ProfileSettings: React.FC = () => { const { @@ -125,6 +126,8 @@ const ProfileSettings: React.FC = () => { )} + + {/* ---- Danger Zone (Delete User) ---- */} {canEditProfile && ( <> diff --git a/client/src/components/profileSettings/userContributionsComponent/index.css b/client/src/components/profileSettings/userContributionsComponent/index.css new file mode 100644 index 0000000..40e16c2 --- /dev/null +++ b/client/src/components/profileSettings/userContributionsComponent/index.css @@ -0,0 +1,20 @@ +h3.headertext { + text-align: center; +} + +.grid-layout { + display: grid; + grid-template-columns: repeat(7, 30px); + grid-template-rows: 30px; + gap: 5px; + justify-content: center; +} + +.grid-item { + width: 30px; + height: 30px; + border-radius: 10px; + background-color: #D9D9D9; + box-sizing: border-box; + display: flex; +} \ No newline at end of file diff --git a/client/src/components/profileSettings/userContributionsComponent/index.tsx b/client/src/components/profileSettings/userContributionsComponent/index.tsx new file mode 100644 index 0000000..2423f1d --- /dev/null +++ b/client/src/components/profileSettings/userContributionsComponent/index.tsx @@ -0,0 +1,48 @@ +import getUserContributions from '../../../services/contributionsService'; +import './index.css'; +import useUserContributions from '../../../hooks/useUserContributions'; +import Dropdown from '../../main/baseComponents/dropdown'; + +interface UserContributionProps { + userID: string; + startDate: string; + endDate: string; + err?: string; +} + +const UserContributionsComponent = ({ userID, startDate, endDate, err }: UserContributionProps) => { + const { contributions, loading, error } = useUserContributions(userID, startDate, endDate); + + const handleUpdateUserContributions = async (type: string) => { + try { + await getUserContributions(userID, startDate, endDate); + } catch (e) { + // Handle error + } + }; + + const daysOfTheWeek = ['Mon', '', 'Wed', '', 'Fri', '', '']; + + return ( +
+

+ {contributions.length} Contributions in +

+
+ {daysOfTheWeek.map((day, index) => ( +
+ {day === 'Mon' || day === 'Wed' || day === 'Fri' ? day : ''} +
+ ))} +
+ +
+ {Array.from({ length: 31 }).map((_, i) => ( +
+ ))} +
+
+ ); +}; + +export default UserContributionsComponent; From 98ec69720c2843487e800a1219bd7c211dd0fa97 Mon Sep 17 00:00:00 2001 From: jessicazha0 Date: Fri, 14 Mar 2025 10:27:35 -0400 Subject: [PATCH 005/144] tests and connect backend --- server/app.ts | 2 + server/controllers/contribution.controller.ts | 79 ++---------------- server/services/contribution.service.ts | 83 +++++++++++++++++++ .../contribution.contoller.spec.ts | 67 +++++++++++++++ .../services/contribution.service.spec.ts | 54 ++++++++++++ 5 files changed, 213 insertions(+), 72 deletions(-) create mode 100644 server/services/contribution.service.ts create mode 100644 server/tests/controllers/contribution.contoller.spec.ts create mode 100644 server/tests/services/contribution.service.spec.ts diff --git a/server/app.ts b/server/app.ts index 573efba..0416f81 100644 --- a/server/app.ts +++ b/server/app.ts @@ -18,6 +18,7 @@ import userController from './controllers/user.controller'; import messageController from './controllers/message.controller'; import chatController from './controllers/chat.controller'; import gameController from './controllers/game.controller'; +import userContributionsController from './controllers/contribution.controller'; dotenv.config(); @@ -82,6 +83,7 @@ app.use('/messaging', messageController(socket)); app.use('/user', userController(socket)); app.use('/chat', chatController(socket)); app.use('/games', gameController(socket)); +app.use('/contributions', userContributionsController()); // Export the app instance export { app, server, startServer }; diff --git a/server/controllers/contribution.controller.ts b/server/controllers/contribution.controller.ts index f308780..8f9e8eb 100644 --- a/server/controllers/contribution.controller.ts +++ b/server/controllers/contribution.controller.ts @@ -1,20 +1,9 @@ import express, { Request, Response } from 'express'; -import QuestionModel from '../models/questions.model'; -import AnswerModel from '../models/answers.model'; -import CommentModel from '../models/comments.model'; +import getUserContributionsByDateRange from '../services/contribution.service'; const userContributionsController = () => { const router = express.Router(); - /** - * Retrieves a user's contributions (questions, answers, comments) within a specified date range. - * Aggregates contributions per day to allow frontend visualization. - * - * @param req The request object containing userID, startDate, and endDate as query parameters. - * @param res The HTTP response object used to send back the found contributions or an error message. - * - * @returns A Promise that resolves to void. - */ const getUserContributions = async (req: Request, res: Response): Promise => { const { userID, startDate, endDate } = req.query; @@ -24,66 +13,12 @@ const userContributionsController = () => { } try { - const start = new Date(startDate as string); - const end = new Date(endDate as string); - - const questions = await QuestionModel.aggregate([ - { $match: { askedBy: userID, askDateTime: { $gte: start, $lte: end } } }, - { - $group: { - _id: { $dateToString: { format: '%Y-%m-%d', date: '$askDateTime' } }, - count: { $sum: 1 }, - }, - }, - ]); - - const answers = await AnswerModel.aggregate([ - { $match: { ansBy: userID, ansDateTime: { $gte: start, $lte: end } } }, - { - $group: { - _id: { $dateToString: { format: '%Y-%m-%d', date: '$ansDateTime' } }, - count: { $sum: 1 }, - }, - }, - ]); - - const comments = await CommentModel.aggregate([ - { $match: { commentBy: userID, commentDateTime: { $gte: start, $lte: end } } }, - { - $group: { - _id: { $dateToString: { format: '%Y-%m-%d', date: '$commentDateTime' } }, - count: { $sum: 1 }, - }, - }, - ]); - - const contributionsMap: Record< - string, - { questions: number; answers: number; comments: number } - > = {}; - - const processContributions = ( - data: { _id: string; count: number }[], - type: 'questions' | 'answers' | 'comments', - ) => { - data.forEach(({ _id, count }) => { - if (!contributionsMap[_id]) { - contributionsMap[_id] = { questions: 0, answers: 0, comments: 0 }; - } - contributionsMap[_id][type] += count; - }); - }; - - processContributions(questions, 'questions'); - processContributions(answers, 'answers'); - processContributions(comments, 'comments'); - - const contributionsArray = Object.entries(contributionsMap).map(([date, contributions]) => ({ - date, - ...contributions, - })); - - res.json(contributionsArray); + const contributions = await getUserContributionsByDateRange( + userID as string, + startDate as string, + endDate as string, + ); + res.json(contributions); } catch (err: unknown) { res.status(500).send(`Error when retrieving user contributions: ${(err as Error).message}`); } diff --git a/server/services/contribution.service.ts b/server/services/contribution.service.ts new file mode 100644 index 0000000..f52044e --- /dev/null +++ b/server/services/contribution.service.ts @@ -0,0 +1,83 @@ +import QuestionModel from '../models/questions.model'; +import AnswerModel from '../models/answers.model'; +import CommentModel from '../models/comments.model'; + +/** + * Retrieves a user's contributions (questions, answers, comments) within a specified date range. + * Aggregates contributions per day for visualization purposes. + * + * @param {string} userID - The unique ID of the user. + * @param {string} startDate - The start date for filtering contributions. + * @param {string} endDate - The end date for filtering contributions. + * @returns {Promise<{ date: string; questions: number; answers: number; comments: number }[]>} - A list of contributions grouped by date. + */ +const getUserContributionsByDateRange = async ( + userID: string, + startDate: string, + endDate: string, +): Promise<{ date: string; questions: number; answers: number; comments: number }[]> => { + try { + const start = new Date(startDate); + const end = new Date(endDate); + + const questions = await QuestionModel.aggregate([ + { $match: { askedBy: userID, askDateTime: { $gte: start, $lte: end } } }, + { + $group: { + _id: { $dateToString: { format: '%Y-%m-%d', date: '$askDateTime' } }, + count: { $sum: 1 }, + }, + }, + ]); + + const answers = await AnswerModel.aggregate([ + { $match: { ansBy: userID, ansDateTime: { $gte: start, $lte: end } } }, + { + $group: { + _id: { $dateToString: { format: '%Y-%m-%d', date: '$ansDateTime' } }, + count: { $sum: 1 }, + }, + }, + ]); + + const comments = await CommentModel.aggregate([ + { $match: { commentBy: userID, commentDateTime: { $gte: start, $lte: end } } }, + { + $group: { + _id: { $dateToString: { format: '%Y-%m-%d', date: '$commentDateTime' } }, + count: { $sum: 1 }, + }, + }, + ]); + + const contributionsMap: Record< + string, + { questions: number; answers: number; comments: number } + > = {}; + + const processContributions = ( + data: { _id: string; count: number }[], + type: 'questions' | 'answers' | 'comments', + ) => { + data.forEach(({ _id, count }) => { + if (!contributionsMap[_id]) { + contributionsMap[_id] = { questions: 0, answers: 0, comments: 0 }; + } + contributionsMap[_id][type] += count; + }); + }; + + processContributions(questions, 'questions'); + processContributions(answers, 'answers'); + processContributions(comments, 'comments'); + + return Object.entries(contributionsMap).map(([date, contributions]) => ({ + date, + ...contributions, + })); + } catch (error) { + throw new Error('Error fetching user contributions'); + } +}; + +export default getUserContributionsByDateRange; diff --git a/server/tests/controllers/contribution.contoller.spec.ts b/server/tests/controllers/contribution.contoller.spec.ts new file mode 100644 index 0000000..35025e6 --- /dev/null +++ b/server/tests/controllers/contribution.contoller.spec.ts @@ -0,0 +1,67 @@ +import mongoose from 'mongoose'; +import supertest from 'supertest'; +import { app } from '../../app'; +import * as userContributionsService from '../../services/contribution.service'; + +const getUserContributionsSpy = jest.spyOn(userContributionsService, 'default'); + +describe('GET /getUserContributions', () => { + it('should return user contributions within a given date range', async () => { + const mockUserID = new mongoose.Types.ObjectId().toString(); + const mockStartDate = '2024-01-01'; + const mockEndDate = '2024-12-31'; + + const mockContributions = [ + { date: '2024-03-10', questions: 2, answers: 1, comments: 3 }, + { date: '2024-03-11', questions: 1, answers: 2, comments: 0 }, + ]; + + getUserContributionsSpy.mockResolvedValueOnce(mockContributions); + + const response = await supertest(app).get( + `/contributions/getUserContributions?userID=${mockUserID}&startDate=${mockStartDate}&endDate=${mockEndDate}`, + ); + + expect(response.status).toBe(200); + expect(response.body).toEqual(mockContributions); + }); + + it('should return 400 if userID is missing', async () => { + const response = await supertest(app).get( + '/contributions/getUserContributions?startDate=2024-01-01&endDate=2024-12-31', + ); + expect(response.status).toBe(400); + expect(response.text).toBe('Missing required query parameters: userID, startDate, endDate'); + }); + + it('should return 400 if startDate is missing', async () => { + const response = await supertest(app).get( + '/contributions/getUserContributions?userID=dummyUserId&endDate=2024-12-31', + ); + expect(response.status).toBe(400); + expect(response.text).toBe('Missing required query parameters: userID, startDate, endDate'); + }); + + it('should return 400 if endDate is missing', async () => { + const response = await supertest(app).get( + '/contributions/getUserContributions?userID=dummyUserId&startDate=2024-01-01', + ); + expect(response.status).toBe(400); + expect(response.text).toBe('Missing required query parameters: userID, startDate, endDate'); + }); + + it('should return 500 if service method throws an error', async () => { + const mockUserID = new mongoose.Types.ObjectId().toString(); + const mockStartDate = '2024-01-01'; + const mockEndDate = '2024-12-31'; + + getUserContributionsSpy.mockRejectedValueOnce(new Error('Database error')); + + const response = await supertest(app).get( + `/contributions/getUserContributions?userID=${mockUserID}&startDate=${mockStartDate}&endDate=${mockEndDate}`, + ); + + expect(response.status).toBe(500); + expect(response.text).toContain('Error when retrieving user contributions'); + }); +}); diff --git a/server/tests/services/contribution.service.spec.ts b/server/tests/services/contribution.service.spec.ts new file mode 100644 index 0000000..7023358 --- /dev/null +++ b/server/tests/services/contribution.service.spec.ts @@ -0,0 +1,54 @@ +import mongoose from 'mongoose'; +import getUserContributionsByDateRange from '../../services/contribution.service'; +import QuestionModel from '../../models/questions.model'; +import AnswerModel from '../../models/answers.model'; +import CommentModel from '../../models/comments.model'; + +jest.mock('../../models/questions.model', () => ({ + aggregate: jest.fn(), +})); + +jest.mock('../../models/answers.model', () => ({ + aggregate: jest.fn(), +})); + +jest.mock('../../models/comments.model', () => ({ + aggregate: jest.fn(), +})); + +describe('getUserContributionsByDateRange', () => { + const mockUserID = new mongoose.Types.ObjectId().toString(); + const mockStartDate = '2024-01-01'; + const mockEndDate = '2024-12-31'; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return aggregated contributions data correctly', async () => { + (QuestionModel.aggregate as jest.Mock).mockResolvedValueOnce([{ _id: '2024-03-10', count: 2 }]); + (AnswerModel.aggregate as jest.Mock).mockResolvedValueOnce([{ _id: '2024-03-10', count: 1 }]); + (CommentModel.aggregate as jest.Mock).mockResolvedValueOnce([{ _id: '2024-03-10', count: 3 }]); + + const result = await getUserContributionsByDateRange(mockUserID, mockStartDate, mockEndDate); + + expect(result).toEqual([{ date: '2024-03-10', questions: 2, answers: 1, comments: 3 }]); + }); + + it('should return an empty array if no contributions exist', async () => { + (QuestionModel.aggregate as jest.Mock).mockResolvedValueOnce([]); + (AnswerModel.aggregate as jest.Mock).mockResolvedValueOnce([]); + (CommentModel.aggregate as jest.Mock).mockResolvedValueOnce([]); + + const result = await getUserContributionsByDateRange(mockUserID, mockStartDate, mockEndDate); + expect(result).toEqual([]); + }); + + it('should throw an error if database query fails', async () => { + (QuestionModel.aggregate as jest.Mock).mockRejectedValueOnce(new Error('Database error')); + + await expect( + getUserContributionsByDateRange(mockUserID, mockStartDate, mockEndDate), + ).rejects.toThrow('Error fetching user contributions'); + }); +}); From 4dbfcdc893f2927b7e13587f0922707a57924603 Mon Sep 17 00:00:00 2001 From: jessicazha0 Date: Fri, 14 Mar 2025 13:18:18 -0400 Subject: [PATCH 006/144] removed timeframe logic and linked backend --- .../src/components/profileSettings/index.tsx | 4 +-- .../userContributionsComponent/index.tsx | 23 ++++++------- client/src/hooks/useUserContributions.ts | 14 ++++---- client/src/services/contributionsService.ts | 17 ++++------ server/app.ts | 4 +-- server/controllers/contribution.controller.ts | 22 ++++++------- server/services/contribution.service.ts | 20 +++++------- .../contribution.contoller.spec.ts | 32 +++---------------- .../services/contribution.service.spec.ts | 16 ++++------ 9 files changed, 57 insertions(+), 95 deletions(-) diff --git a/client/src/components/profileSettings/index.tsx b/client/src/components/profileSettings/index.tsx index 79bb4ba..0ad8df9 100644 --- a/client/src/components/profileSettings/index.tsx +++ b/client/src/components/profileSettings/index.tsx @@ -99,6 +99,8 @@ const ProfileSettings: React.FC = () => { {userData.dateJoined ? new Date(userData.dateJoined).toLocaleDateString() : 'N/A'}

+ + {/* ---- Reset Password Section ---- */} {canEditProfile && ( <> @@ -126,8 +128,6 @@ const ProfileSettings: React.FC = () => { )} - - {/* ---- Danger Zone (Delete User) ---- */} {canEditProfile && ( <> diff --git a/client/src/components/profileSettings/userContributionsComponent/index.tsx b/client/src/components/profileSettings/userContributionsComponent/index.tsx index 2423f1d..100e6d8 100644 --- a/client/src/components/profileSettings/userContributionsComponent/index.tsx +++ b/client/src/components/profileSettings/userContributionsComponent/index.tsx @@ -1,28 +1,25 @@ -import getUserContributions from '../../../services/contributionsService'; import './index.css'; import useUserContributions from '../../../hooks/useUserContributions'; import Dropdown from '../../main/baseComponents/dropdown'; interface UserContributionProps { userID: string; - startDate: string; - endDate: string; err?: string; } -const UserContributionsComponent = ({ userID, startDate, endDate, err }: UserContributionProps) => { - const { contributions, loading, error } = useUserContributions(userID, startDate, endDate); - - const handleUpdateUserContributions = async (type: string) => { - try { - await getUserContributions(userID, startDate, endDate); - } catch (e) { - // Handle error - } - }; +const UserContributionsComponent = ({ userID, err }: UserContributionProps) => { + const { contributions, loading, error } = useUserContributions(userID); const daysOfTheWeek = ['Mon', '', 'Wed', '', 'Fri', '', '']; + if (loading) { + return

Loading contributions...

; + } + + if (error) { + return

Error loading contributions: {error}

; + } + return (

diff --git a/client/src/hooks/useUserContributions.ts b/client/src/hooks/useUserContributions.ts index 29053e0..8561d11 100644 --- a/client/src/hooks/useUserContributions.ts +++ b/client/src/hooks/useUserContributions.ts @@ -6,13 +6,11 @@ import getUserContributions from '../services/contributionsService'; * Supports fetching contributions for any user by providing a `userID`. * * @param userID - The ID of the user whose contributions are being fetched. - * @param startDate - The start date of the range (YYYY-MM-DD format). - * @param endDate - The end date of the range (YYYY-MM-DD format). * @returns contributions - The fetched contributions data. * @returns loading - Boolean indicating whether data is loading. * @returns error - Any error message encountered during the fetch. */ -const useUserContributions = (userID: string, startDate: string, endDate: string) => { +const useUserContributions = (userID: string) => { const [contributions, setContributions] = useState< { date: string; questions: number; answers: number; comments: number }[] >([]); @@ -20,12 +18,16 @@ const useUserContributions = (userID: string, startDate: string, endDate: string const [error, setError] = useState(null); useEffect(() => { - if (!userID || !startDate || !endDate) return; + if (!userID) { + setContributions([]); + return; + } const fetchContributions = async () => { setLoading(true); + setError(null); try { - const data = await getUserContributions(userID, startDate, endDate); + const data = await getUserContributions(userID); setContributions(data); } catch (err) { setError('Error fetching user contributions'); @@ -35,7 +37,7 @@ const useUserContributions = (userID: string, startDate: string, endDate: string }; fetchContributions(); - }, [userID, startDate, endDate]); + }, [userID]); return { contributions, loading, error }; }; diff --git a/client/src/services/contributionsService.ts b/client/src/services/contributionsService.ts index 60e4759..809567f 100644 --- a/client/src/services/contributionsService.ts +++ b/client/src/services/contributionsService.ts @@ -1,27 +1,22 @@ import api from './config'; -const USER_CONTRIBUTIONS_API_URL = `${process.env.REACT_APP_SERVER_URL}/getUserContributions`; +const USER_CONTRIBUTIONS_API_URL = `${process.env.REACT_APP_SERVER_URL}/contributions/getContributions`; /** * Function to get user contributions (questions, answers, comments) within a specified date range. * * @param userID - The ID of the user. - * @param startDate - The start date of the range (YYYY-MM-DD format). - * @param endDate - The end date of the range (YYYY-MM-DD format). * @throws Error if there is an issue fetching the user contributions. */ const getUserContributions = async ( userID: string, - startDate: string, - endDate: string, ): Promise<{ date: string; questions: number; answers: number; comments: number }[]> => { - const res = await api.get( - `${USER_CONTRIBUTIONS_API_URL}?userID=${userID}&startDate=${startDate}&endDate=${endDate}`, - ); - if (res.status !== 200) { - throw new Error('Error when fetching user contributions'); + try { + const res = await api.get(`${USER_CONTRIBUTIONS_API_URL}?userID=${userID}`); + return res.data; + } catch (error) { + throw new Error('Failed to fetch user contributions'); } - return res.data; }; export default getUserContributions; diff --git a/server/app.ts b/server/app.ts index 0416f81..46c8019 100644 --- a/server/app.ts +++ b/server/app.ts @@ -18,7 +18,7 @@ import userController from './controllers/user.controller'; import messageController from './controllers/message.controller'; import chatController from './controllers/chat.controller'; import gameController from './controllers/game.controller'; -import userContributionsController from './controllers/contribution.controller'; +import contributionsController from './controllers/contribution.controller'; dotenv.config(); @@ -83,7 +83,7 @@ app.use('/messaging', messageController(socket)); app.use('/user', userController(socket)); app.use('/chat', chatController(socket)); app.use('/games', gameController(socket)); -app.use('/contributions', userContributionsController()); +app.use('/contributions', contributionsController()); // Export the app instance export { app, server, startServer }; diff --git a/server/controllers/contribution.controller.ts b/server/controllers/contribution.controller.ts index 8f9e8eb..933ca76 100644 --- a/server/controllers/contribution.controller.ts +++ b/server/controllers/contribution.controller.ts @@ -1,32 +1,28 @@ import express, { Request, Response } from 'express'; -import getUserContributionsByDateRange from '../services/contribution.service'; +import getUserContributions from '../services/contribution.service'; -const userContributionsController = () => { +const contributionsController = () => { const router = express.Router(); - const getUserContributions = async (req: Request, res: Response): Promise => { - const { userID, startDate, endDate } = req.query; + const getContributions = async (req: Request, res: Response): Promise => { + const { userID } = req.query; - if (!userID || !startDate || !endDate) { - res.status(400).send('Missing required query parameters: userID, startDate, endDate'); + if (!userID) { + res.status(400).send('Invalid request'); return; } try { - const contributions = await getUserContributionsByDateRange( - userID as string, - startDate as string, - endDate as string, - ); + const contributions = await getUserContributions(userID as string); res.json(contributions); } catch (err: unknown) { res.status(500).send(`Error when retrieving user contributions: ${(err as Error).message}`); } }; - router.get('/getUserContributions', getUserContributions); + router.get('/getContributions', getContributions); return router; }; -export default userContributionsController; +export default contributionsController; diff --git a/server/services/contribution.service.ts b/server/services/contribution.service.ts index f52044e..3230c54 100644 --- a/server/services/contribution.service.ts +++ b/server/services/contribution.service.ts @@ -7,21 +7,14 @@ import CommentModel from '../models/comments.model'; * Aggregates contributions per day for visualization purposes. * * @param {string} userID - The unique ID of the user. - * @param {string} startDate - The start date for filtering contributions. - * @param {string} endDate - The end date for filtering contributions. * @returns {Promise<{ date: string; questions: number; answers: number; comments: number }[]>} - A list of contributions grouped by date. */ -const getUserContributionsByDateRange = async ( +const getUserContributions = async ( userID: string, - startDate: string, - endDate: string, ): Promise<{ date: string; questions: number; answers: number; comments: number }[]> => { try { - const start = new Date(startDate); - const end = new Date(endDate); - const questions = await QuestionModel.aggregate([ - { $match: { askedBy: userID, askDateTime: { $gte: start, $lte: end } } }, + { $match: { askedBy: userID } }, { $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$askDateTime' } }, @@ -30,8 +23,11 @@ const getUserContributionsByDateRange = async ( }, ]); + // eslint-disable-next-line + console.log('Questions found:', questions); + const answers = await AnswerModel.aggregate([ - { $match: { ansBy: userID, ansDateTime: { $gte: start, $lte: end } } }, + { $match: { ansBy: userID } }, { $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$ansDateTime' } }, @@ -41,7 +37,7 @@ const getUserContributionsByDateRange = async ( ]); const comments = await CommentModel.aggregate([ - { $match: { commentBy: userID, commentDateTime: { $gte: start, $lte: end } } }, + { $match: { commentBy: userID } }, { $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$commentDateTime' } }, @@ -80,4 +76,4 @@ const getUserContributionsByDateRange = async ( } }; -export default getUserContributionsByDateRange; +export default getUserContributions; diff --git a/server/tests/controllers/contribution.contoller.spec.ts b/server/tests/controllers/contribution.contoller.spec.ts index 35025e6..33b26d7 100644 --- a/server/tests/controllers/contribution.contoller.spec.ts +++ b/server/tests/controllers/contribution.contoller.spec.ts @@ -5,11 +5,9 @@ import * as userContributionsService from '../../services/contribution.service'; const getUserContributionsSpy = jest.spyOn(userContributionsService, 'default'); -describe('GET /getUserContributions', () => { +describe('GET /getContributions', () => { it('should return user contributions within a given date range', async () => { const mockUserID = new mongoose.Types.ObjectId().toString(); - const mockStartDate = '2024-01-01'; - const mockEndDate = '2024-12-31'; const mockContributions = [ { date: '2024-03-10', questions: 2, answers: 1, comments: 3 }, @@ -19,7 +17,7 @@ describe('GET /getUserContributions', () => { getUserContributionsSpy.mockResolvedValueOnce(mockContributions); const response = await supertest(app).get( - `/contributions/getUserContributions?userID=${mockUserID}&startDate=${mockStartDate}&endDate=${mockEndDate}`, + `/contributions/getContributions?userID=${mockUserID}`, ); expect(response.status).toBe(200); @@ -27,38 +25,18 @@ describe('GET /getUserContributions', () => { }); it('should return 400 if userID is missing', async () => { - const response = await supertest(app).get( - '/contributions/getUserContributions?startDate=2024-01-01&endDate=2024-12-31', - ); - expect(response.status).toBe(400); - expect(response.text).toBe('Missing required query parameters: userID, startDate, endDate'); - }); - - it('should return 400 if startDate is missing', async () => { - const response = await supertest(app).get( - '/contributions/getUserContributions?userID=dummyUserId&endDate=2024-12-31', - ); - expect(response.status).toBe(400); - expect(response.text).toBe('Missing required query parameters: userID, startDate, endDate'); - }); - - it('should return 400 if endDate is missing', async () => { - const response = await supertest(app).get( - '/contributions/getUserContributions?userID=dummyUserId&startDate=2024-01-01', - ); + const response = await supertest(app).get('/contributions/getContributions'); expect(response.status).toBe(400); - expect(response.text).toBe('Missing required query parameters: userID, startDate, endDate'); + expect(response.text).toBe('Invalid request'); }); it('should return 500 if service method throws an error', async () => { const mockUserID = new mongoose.Types.ObjectId().toString(); - const mockStartDate = '2024-01-01'; - const mockEndDate = '2024-12-31'; getUserContributionsSpy.mockRejectedValueOnce(new Error('Database error')); const response = await supertest(app).get( - `/contributions/getUserContributions?userID=${mockUserID}&startDate=${mockStartDate}&endDate=${mockEndDate}`, + `/contributions/getContributions?userID=${mockUserID}`, ); expect(response.status).toBe(500); diff --git a/server/tests/services/contribution.service.spec.ts b/server/tests/services/contribution.service.spec.ts index 7023358..1dff68f 100644 --- a/server/tests/services/contribution.service.spec.ts +++ b/server/tests/services/contribution.service.spec.ts @@ -1,5 +1,5 @@ import mongoose from 'mongoose'; -import getUserContributionsByDateRange from '../../services/contribution.service'; +import getUserContributions from '../../services/contribution.service'; import QuestionModel from '../../models/questions.model'; import AnswerModel from '../../models/answers.model'; import CommentModel from '../../models/comments.model'; @@ -16,10 +16,8 @@ jest.mock('../../models/comments.model', () => ({ aggregate: jest.fn(), })); -describe('getUserContributionsByDateRange', () => { +describe('getUserContributions', () => { const mockUserID = new mongoose.Types.ObjectId().toString(); - const mockStartDate = '2024-01-01'; - const mockEndDate = '2024-12-31'; afterEach(() => { jest.clearAllMocks(); @@ -30,7 +28,7 @@ describe('getUserContributionsByDateRange', () => { (AnswerModel.aggregate as jest.Mock).mockResolvedValueOnce([{ _id: '2024-03-10', count: 1 }]); (CommentModel.aggregate as jest.Mock).mockResolvedValueOnce([{ _id: '2024-03-10', count: 3 }]); - const result = await getUserContributionsByDateRange(mockUserID, mockStartDate, mockEndDate); + const result = await getUserContributions(mockUserID); expect(result).toEqual([{ date: '2024-03-10', questions: 2, answers: 1, comments: 3 }]); }); @@ -40,15 +38,15 @@ describe('getUserContributionsByDateRange', () => { (AnswerModel.aggregate as jest.Mock).mockResolvedValueOnce([]); (CommentModel.aggregate as jest.Mock).mockResolvedValueOnce([]); - const result = await getUserContributionsByDateRange(mockUserID, mockStartDate, mockEndDate); + const result = await getUserContributions(mockUserID); expect(result).toEqual([]); }); it('should throw an error if database query fails', async () => { (QuestionModel.aggregate as jest.Mock).mockRejectedValueOnce(new Error('Database error')); - await expect( - getUserContributionsByDateRange(mockUserID, mockStartDate, mockEndDate), - ).rejects.toThrow('Error fetching user contributions'); + await expect(getUserContributions(mockUserID)).rejects.toThrow( + 'Error fetching user contributions', + ); }); }); From 1c8ec227ead23faff13c019852a47d69352fb58b Mon Sep 17 00:00:00 2001 From: jessicazha0 Date: Fri, 14 Mar 2025 18:07:50 -0400 Subject: [PATCH 007/144] accurate months and colored grids by contributions --- .../main/baseComponents/dropdown/index.css | 31 ++++--- .../main/baseComponents/dropdown/index.tsx | 19 +++-- .../calendarGrid/index.css | 49 +++++++++++ .../calendarGrid/index.tsx | 84 +++++++++++++++++++ .../userContributionsComponent/index.tsx | 54 ++++++++---- .../yearSelector/index.css | 26 ++++++ .../yearSelector/index.tsx | 26 ++++++ 7 files changed, 255 insertions(+), 34 deletions(-) create mode 100644 client/src/components/profileSettings/userContributionsComponent/calendarGrid/index.css create mode 100644 client/src/components/profileSettings/userContributionsComponent/calendarGrid/index.tsx create mode 100644 client/src/components/profileSettings/userContributionsComponent/yearSelector/index.css create mode 100644 client/src/components/profileSettings/userContributionsComponent/yearSelector/index.tsx diff --git a/client/src/components/main/baseComponents/dropdown/index.css b/client/src/components/main/baseComponents/dropdown/index.css index 351e6ae..dc31bcf 100644 --- a/client/src/components/main/baseComponents/dropdown/index.css +++ b/client/src/components/main/baseComponents/dropdown/index.css @@ -4,39 +4,48 @@ } .dropdown-button { - background-color: darkgray; + background-color: lightgray; color: black; padding: 10px 15px; border: none; border-radius: 5px; cursor: pointer; font-size: 16px; + width: 135px; + text-align: center; } .dropdown-content { display: none; position: absolute; background-color: white; - /* min-width: 150px; + min-width: 100%; box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.2); border-radius: 5px; overflow: hidden; - z-index: 10; */ + z-index: 10; + left: 0; + padding: 0; + margin: 0; } -.dropdown-content a { - color: black; - padding: 10px 15px; - /* text-decoration: none; +.dropdown-item { display: block; - font-size: 14px; */ + width: 100%; + padding: 10px 15px; + text-align: left; + border: none; + background: none; + cursor: pointer; + font-size: 14px; + white-space: nowrap; + margin: 0; } -.dropdown-content a:hover { +.dropdown-item:hover { background-color: lightgray; } .dropdown-content.show { display: block; - outline: black; -} +} \ No newline at end of file diff --git a/client/src/components/main/baseComponents/dropdown/index.tsx b/client/src/components/main/baseComponents/dropdown/index.tsx index 468e5b6..99d4b63 100644 --- a/client/src/components/main/baseComponents/dropdown/index.tsx +++ b/client/src/components/main/baseComponents/dropdown/index.tsx @@ -1,9 +1,13 @@ -import React, { ReactNode, useState } from 'react'; +import React, { useState } from 'react'; import './index.css'; -const Dropdown: React.FC = () => { +interface DropdownProps { + onChange: (monthIndex: number) => void; +} + +const Dropdown: React.FC = ({ onChange }) => { const [isOpen, setIsOpen] = useState(false); - const [selectedMonth, setSelectedMonth] = useState('January'); + const [selectedMonth, setSelectedMonth] = useState(new Date().getMonth()); const toggleDropdown = () => { setIsOpen(!isOpen); @@ -24,19 +28,20 @@ const Dropdown: React.FC = () => { 'December', ]; - const handleMonthClick = (month: string) => { - setSelectedMonth(month); + const handleMonthClick = (monthIndex: number) => { + setSelectedMonth(monthIndex); setIsOpen(false); + onChange(monthIndex); }; return (
{months.map((month, index) => ( - ))} diff --git a/client/src/components/profileSettings/userContributionsComponent/calendarGrid/index.css b/client/src/components/profileSettings/userContributionsComponent/calendarGrid/index.css new file mode 100644 index 0000000..8cf3b48 --- /dev/null +++ b/client/src/components/profileSettings/userContributionsComponent/calendarGrid/index.css @@ -0,0 +1,49 @@ +.calendar-container { + display: block; +} + +.header-container { + display: grid; + grid-template-columns: repeat(7, 40px); + gap: 4px; + margin-bottom: 4px; + text-align: center; + align-items: center; + justify-items: center; + justify-content: center; +} + +.weekday-header { + width: 40px; + height: 40px; + text-align: center; + display: flex; + align-items: center; + justify-content: center; +} + +.week-row { + margin-bottom: 14px; +} + +.grid-layout { + display: grid; + grid-template-columns: repeat(7, 40px); + gap: 4px; + justify-content: center; + margin: 0 auto; +} + +.grid-item { + width: 40px; + height: 40px; + background-color: lightgray; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; +} + +.grid-item.empty { + background-color: white; +} diff --git a/client/src/components/profileSettings/userContributionsComponent/calendarGrid/index.tsx b/client/src/components/profileSettings/userContributionsComponent/calendarGrid/index.tsx new file mode 100644 index 0000000..faa593f --- /dev/null +++ b/client/src/components/profileSettings/userContributionsComponent/calendarGrid/index.tsx @@ -0,0 +1,84 @@ +import './index.css'; + +interface Contribution { + date: string; + questions: number; + answers: number; + comments: number; +} + +function contributionColor(count: number): string { + if (count >= 4) return '#2D1E64'; + if (count === 3) return '#592F83'; + if (count === 2) return '#B795D7'; + if (count === 1) return '#EADCFA'; + return '#dedede'; +} + +function CalendarGrid({ + year, + month, + contributions, +}: { + year: number; + month: number; + contributions: Contribution[]; +}) { + const daysOfTheWeek = ['Mon', '', 'Wed', '', 'Fri', '', '']; + + const firstDateOfMonth = new Date(year, month, 1); + const firstDayOfMonth = firstDateOfMonth.getDay(); + const offset = (firstDayOfMonth + 6) % 7; + + const daysInMonth = new Date(year, month + 1, 0).getDate(); + + const totalCells = 6 * 7; + const cells = Array.from({ length: totalCells }, (_, i) => { + const dayNumber = i - offset + 1; + return dayNumber >= 1 && dayNumber <= daysInMonth ? dayNumber : null; + }); + + const weeks: Array<(number | null)[]> = []; + for (let i = 0; i < totalCells; i += 7) { + weeks.push(cells.slice(i, i + 7)); + } + + const contributionCount = (day: number): number => { + const contribution = contributions.find(c => { + const d = new Date(c.date); + return d.getUTCFullYear() === year && d.getUTCMonth() === month && d.getUTCDate() === day; + }); + if (!contribution) return 0; + return contribution.questions + contribution.answers + contribution.comments; + }; + + return ( +
+
+ {daysOfTheWeek.map((day, index) => ( +
+ {day} +
+ ))} +
+ {weeks.map((week, wIndex) => ( +
+
+ {week.map((cell, index) => { + if (!cell) { + return
; + } + + const count = contributionCount(cell); + const color = contributionColor(count); + + return
; + })} +
+
+ ))} +
+ ); +} + +export default CalendarGrid; diff --git a/client/src/components/profileSettings/userContributionsComponent/index.tsx b/client/src/components/profileSettings/userContributionsComponent/index.tsx index 100e6d8..8a71fd7 100644 --- a/client/src/components/profileSettings/userContributionsComponent/index.tsx +++ b/client/src/components/profileSettings/userContributionsComponent/index.tsx @@ -1,6 +1,9 @@ import './index.css'; +import { useState } from 'react'; import useUserContributions from '../../../hooks/useUserContributions'; import Dropdown from '../../main/baseComponents/dropdown'; +import YearSelector from './yearSelector'; +import CalendarGrid from './calendarGrid'; interface UserContributionProps { userID: string; @@ -10,7 +13,8 @@ interface UserContributionProps { const UserContributionsComponent = ({ userID, err }: UserContributionProps) => { const { contributions, loading, error } = useUserContributions(userID); - const daysOfTheWeek = ['Mon', '', 'Wed', '', 'Fri', '', '']; + const [selectedYear, setSelectedYear] = useState(() => new Date().getFullYear()); + const [selectedMonth, setSelectedMonth] = useState(() => new Date().getMonth()); if (loading) { return

Loading contributions...

; @@ -20,24 +24,42 @@ const UserContributionsComponent = ({ userID, err }: UserContributionProps) => { return

Error loading contributions: {error}

; } + const yearsFromContributions = Array.from( + new Set(contributions.map(c => new Date(c.date).getFullYear())), + ); + + const currentYear = new Date().getFullYear(); + if (!yearsFromContributions.includes(currentYear)) { + yearsFromContributions.push(currentYear); + } + + yearsFromContributions.sort((a, b) => b - a); + + const filteredContributions = contributions.filter(contribution => { + const d = new Date(contribution.date); + return d.getUTCFullYear() === selectedYear && d.getUTCMonth() === selectedMonth; + }); + return ( -
-

- {contributions.length} Contributions in -

-
- {daysOfTheWeek.map((day, index) => ( -
- {day === 'Mon' || day === 'Wed' || day === 'Fri' ? day : ''} -
- ))} -
+
+
+

+ {filteredContributions.length} Contributions in{' '} + setSelectedMonth(monthIndex)} /> +

-
- {Array.from({ length: 31 }).map((_, i) => ( -
- ))} +
+ +
); }; diff --git a/client/src/components/profileSettings/userContributionsComponent/yearSelector/index.css b/client/src/components/profileSettings/userContributionsComponent/yearSelector/index.css new file mode 100644 index 0000000..0b5eb87 --- /dev/null +++ b/client/src/components/profileSettings/userContributionsComponent/yearSelector/index.css @@ -0,0 +1,26 @@ +.year-selector { + display: flex; + flex-direction: column; + margin-right: 1rem; +} + +.year-button { + background-color: transparent; + border: none; + padding: 8px 18px; + cursor: pointer; + color: #aaa; + border-radius: 4px; + transition: background-color 0.2s; + font-size: 16px; + margin: 4px; +} + +.year-button:hover { + background-color: lightgray; +} + +.year-button.selected { + background-color: #592f83; + color: #fff; +} diff --git a/client/src/components/profileSettings/userContributionsComponent/yearSelector/index.tsx b/client/src/components/profileSettings/userContributionsComponent/yearSelector/index.tsx new file mode 100644 index 0000000..d76d003 --- /dev/null +++ b/client/src/components/profileSettings/userContributionsComponent/yearSelector/index.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import './index.css'; + +type YearSelectorProps = { + years: number[]; + selectedYear: number; + onYearChange: (year: number) => void; +}; + +const YearSelector: React.FC = ({ years, selectedYear, onYearChange }) => ( +
+ {years.map(year => { + const isSelected = year === selectedYear; + return ( + + ); + })} +
+); + +export default YearSelector; From 817a2bd21f6c709a37ffed2cb5ceadb911984c65 Mon Sep 17 00:00:00 2001 From: jessicazha0 Date: Fri, 14 Mar 2025 19:20:01 -0400 Subject: [PATCH 008/144] tooltip description --- .../calendarGrid/index.css | 29 ++++++++ .../calendarGrid/index.tsx | 73 ++++++++++++++++--- 2 files changed, 91 insertions(+), 11 deletions(-) diff --git a/client/src/components/profileSettings/userContributionsComponent/calendarGrid/index.css b/client/src/components/profileSettings/userContributionsComponent/calendarGrid/index.css index 8cf3b48..fb8a626 100644 --- a/client/src/components/profileSettings/userContributionsComponent/calendarGrid/index.css +++ b/client/src/components/profileSettings/userContributionsComponent/calendarGrid/index.css @@ -42,8 +42,37 @@ align-items: center; justify-content: center; font-size: 14px; + position: relative; + overflow: visible; } .grid-item.empty { background-color: white; } + +.tooltip { + visibility: hidden; + opacity: 0; + position: absolute; + pointer-events: none; + + top: 105%; + left: 50%; + transform: translateX(-50%); + + background-color: rgba(45, 30, 100, 0.8); + color: #fff; + padding: 8px 10px; + border-radius: 5px; + white-space: nowrap; + font-size: 12px; + z-index: 999; + + transition: opacity 0.2s ease-in-out; +} + +.grid-item:hover .tooltip { + visibility: visible; + opacity: 1; + pointer-events: auto; +} \ No newline at end of file diff --git a/client/src/components/profileSettings/userContributionsComponent/calendarGrid/index.tsx b/client/src/components/profileSettings/userContributionsComponent/calendarGrid/index.tsx index faa593f..bc48420 100644 --- a/client/src/components/profileSettings/userContributionsComponent/calendarGrid/index.tsx +++ b/client/src/components/profileSettings/userContributionsComponent/calendarGrid/index.tsx @@ -15,6 +15,20 @@ function contributionColor(count: number): string { return '#dedede'; } +function ordinalSuffix(day: number): string { + if (day > 3 && day < 21) return 'th'; + switch (day % 10) { + case 1: + return 'st'; + case 2: + return 'nd'; + case 3: + return 'rd'; + default: + return 'th'; + } +} + function CalendarGrid({ year, month, @@ -25,6 +39,20 @@ function CalendarGrid({ contributions: Contribution[]; }) { const daysOfTheWeek = ['Mon', '', 'Wed', '', 'Fri', '', '']; + const months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; const firstDateOfMonth = new Date(year, month, 1); const firstDayOfMonth = firstDateOfMonth.getDay(); @@ -43,15 +71,6 @@ function CalendarGrid({ weeks.push(cells.slice(i, i + 7)); } - const contributionCount = (day: number): number => { - const contribution = contributions.find(c => { - const d = new Date(c.date); - return d.getUTCFullYear() === year && d.getUTCMonth() === month && d.getUTCDate() === day; - }); - if (!contribution) return 0; - return contribution.questions + contribution.answers + contribution.comments; - }; - return (
@@ -69,10 +88,42 @@ function CalendarGrid({ return
; } - const count = contributionCount(cell); + const dayContributions = contributions.find(contribution => { + const d = new Date(contribution.date); + return ( + d.getUTCFullYear() === year && + d.getUTCMonth() === month && + d.getUTCDate() === cell + ); + }); + + const questions = dayContributions?.questions ?? 0; + const comments = dayContributions?.comments ?? 0; + const answers = dayContributions?.answers ?? 0; + const count = questions + answers + comments; + const color = contributionColor(count); - return
; + return ( +
+
+
+ {count} {count === 1 ? 'contribution' : 'contributions'} on {months[month]}{' '} + {cell} + {ordinalSuffix(cell)} +
+
+ {questions} {questions === 1 ? 'question' : 'questions'} +
+
+ {comments} {comments === 1 ? 'comment' : 'comments'} +
+
+ {answers} {answers === 1 ? 'answer' : 'answers'} +
+
+
+ ); })}
From 4a435f8c35ac58cb36e62a8d7e3c6d4b927ca29f Mon Sep 17 00:00:00 2001 From: jessicazha0 Date: Fri, 14 Mar 2025 19:21:39 -0400 Subject: [PATCH 009/144] color key change --- .../userContributionsComponent/calendarGrid/index.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/src/components/profileSettings/userContributionsComponent/calendarGrid/index.tsx b/client/src/components/profileSettings/userContributionsComponent/calendarGrid/index.tsx index bc48420..27ba194 100644 --- a/client/src/components/profileSettings/userContributionsComponent/calendarGrid/index.tsx +++ b/client/src/components/profileSettings/userContributionsComponent/calendarGrid/index.tsx @@ -8,10 +8,10 @@ interface Contribution { } function contributionColor(count: number): string { - if (count >= 4) return '#2D1E64'; - if (count === 3) return '#592F83'; - if (count === 2) return '#B795D7'; - if (count === 1) return '#EADCFA'; + if (count >= 6) return '#2D1E64'; + if (count === 5) return '#592F83'; + if (count === 3 || count === 4) return '#B795D7'; + if (count === 1 || count === 2) return '#EADCFA'; return '#dedede'; } From 66f77a36942aa6e79ffcda37b3a31eb30e706b2b Mon Sep 17 00:00:00 2001 From: jessicazha0 Date: Fri, 14 Mar 2025 20:05:17 -0400 Subject: [PATCH 010/144] total contributions bug fix --- .../profileSettings/userContributionsComponent/index.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/client/src/components/profileSettings/userContributionsComponent/index.tsx b/client/src/components/profileSettings/userContributionsComponent/index.tsx index 8a71fd7..307e4fb 100644 --- a/client/src/components/profileSettings/userContributionsComponent/index.tsx +++ b/client/src/components/profileSettings/userContributionsComponent/index.tsx @@ -40,11 +40,17 @@ const UserContributionsComponent = ({ userID, err }: UserContributionProps) => { return d.getUTCFullYear() === selectedYear && d.getUTCMonth() === selectedMonth; }); + const totalContributions = filteredContributions.reduce( + (sum, contribution) => + sum + contribution.questions + contribution.comments + contribution.answers, + 0, + ); + return (

- {filteredContributions.length} Contributions in{' '} + {totalContributions} {totalContributions === 1 ? 'Contribution' : 'Contibutions'} in{' '} setSelectedMonth(monthIndex)} />

From 6ba6481b4c1e97c270e6e57b69974370b1b24dac Mon Sep 17 00:00:00 2001 From: jessicazha0 Date: Sat, 15 Mar 2025 01:02:41 -0400 Subject: [PATCH 011/144] communities backend and test --- server/app.ts | 2 + server/controllers/community.controller.ts | 349 +++++++++++ server/models/community.model.ts | 13 + server/models/schema/community.schema.ts | 52 ++ server/services/community.service.ts | 244 ++++++++ .../controllers/community.controller.spec.ts | 582 ++++++++++++++++++ .../tests/services/community.service.spec.ts | 355 +++++++++++ server/tests/utils/database.util.spec.ts | 76 ++- server/utils/database.util.ts | 53 +- shared/types/community.d.ts | 91 +++ shared/types/socket.d.ts | 17 + shared/types/types.d.ts | 1 + 12 files changed, 1823 insertions(+), 12 deletions(-) create mode 100644 server/controllers/community.controller.ts create mode 100644 server/models/community.model.ts create mode 100644 server/models/schema/community.schema.ts create mode 100644 server/services/community.service.ts create mode 100644 server/tests/controllers/community.controller.spec.ts create mode 100644 server/tests/services/community.service.spec.ts create mode 100644 shared/types/community.d.ts diff --git a/server/app.ts b/server/app.ts index 573efba..60f7ba8 100644 --- a/server/app.ts +++ b/server/app.ts @@ -18,6 +18,7 @@ import userController from './controllers/user.controller'; import messageController from './controllers/message.controller'; import chatController from './controllers/chat.controller'; import gameController from './controllers/game.controller'; +import communityController from './controllers/community.controller'; dotenv.config(); @@ -82,6 +83,7 @@ app.use('/messaging', messageController(socket)); app.use('/user', userController(socket)); app.use('/chat', chatController(socket)); app.use('/games', gameController(socket)); +app.use('/games', communityController(socket)); // Export the app instance export { app, server, startServer }; diff --git a/server/controllers/community.controller.ts b/server/controllers/community.controller.ts new file mode 100644 index 0000000..b23e214 --- /dev/null +++ b/server/controllers/community.controller.ts @@ -0,0 +1,349 @@ +import express, { Response } from 'express'; +import { ObjectId } from 'mongodb'; +import { + Community, + CommunityResponse, + CreateCommunityRequest, + CommunityIdRequest, + FakeSOSocket, + PopulatedDatabaseCommunity, +} from '../types/types'; +import { + saveCommunity, + getCommunity, + addMemberToCommunity, + addModeratorToCommunity, + addMemberRequest, + approveMemberRequest, + rejectMemberRequest, + removeMemberFromCommunity, + removeModeratorFromCommunity, +} from '../services/community.service'; +import { populateDocument } from '../utils/database.util'; + +const communityController = (socket: FakeSOSocket) => { + const router = express.Router(); + + /** + * Creates a new community. + * + * @param {CreateCommunityRequest} req - The request object containing the community data in its body. + * @param {Response} res - The response object used to send back the result. + * @returns {Promise} A promise that resolves once the community is created or an error is sent. + */ + const addCommunity = async (req: CreateCommunityRequest, res: Response): Promise => { + const community: Community = { + members: req.body.members || [], + moderators: req.body.moderators || [], + memberRequests: req.body.memberRequests || [], + owner: req.body.owner, + visibility: req.body.visibility, + }; + + if ( + !community.owner || + !community.visibility || + !['public', 'private'].includes(community.visibility) + ) { + res.status(400).send('Invalid community data'); + return; + } + + try { + const result: CommunityResponse = await saveCommunity(community); + if ('error' in result) { + throw new Error(result.error); + } + const populatedCommunity = await populateDocument(result._id.toString(), 'community'); + if ('error' in populatedCommunity) { + throw new Error(populatedCommunity.error); + } + socket.emit('communityUpdate', { + community: populatedCommunity as PopulatedDatabaseCommunity, + type: 'created', + }); + res.json(result); + } catch (err: unknown) { + const errorMsg = err instanceof Error ? err.message : 'Error when saving community'; + res.status(500).send(errorMsg); + } + }; + + /** + * Retrieves a community by its ID. + * + * @param {CommunityIdRequest} req - The request object with the communityId in its params. + * @param {Response} res - The response object used to send back the found community. + * @returns {Promise} A promise that resolves once the community is retrieved or an error is sent. + */ + const getCommunityById = async (req: CommunityIdRequest, res: Response): Promise => { + const { communityId } = req.params; + if (!ObjectId.isValid(communityId)) { + res.status(400).send('Invalid community ID format'); + return; + } + try { + const community: CommunityResponse = await getCommunity(communityId); + if ('error' in community) { + throw new Error(community.error); + } + res.json(community); + } catch (err: unknown) { + const errorMsg = err instanceof Error ? err.message : 'Error retrieving community'; + res.status(500).send(errorMsg); + } + }; + + /** + * Adds a member to a community. + * + * @param {CommunityIdRequest} req - The request object with the communityId in params and userId in the body. + * @param {Response} res - The response object used to send back the updated community. + * @returns {Promise} A promise that resolves once the member is added or an error is sent. + */ + const addMember = async (req: CommunityIdRequest, res: Response): Promise => { + const { communityId } = req.params; + const { userId } = req.body; + if (!userId) { + res.status(400).send('Missing userId'); + return; + } + try { + const result: CommunityResponse = await addMemberToCommunity(communityId, userId); + if ('error' in result) { + throw new Error(result.error); + } + const populatedCommunity = await populateDocument(result._id.toString(), 'community'); + if ('error' in populatedCommunity) { + throw new Error(populatedCommunity.error); + } + socket.emit('communityUpdate', { + community: populatedCommunity as PopulatedDatabaseCommunity, + type: 'memberAdded', + }); + res.json(result); + } catch (err: unknown) { + const errorMsg = err instanceof Error ? err.message : 'Error adding member to community'; + res.status(500).send(errorMsg); + } + }; + + /** + * Adds a moderator to a community. + * + * @param {CommunityIdRequest} req - The request object with the communityId in params and userId in the body. + * @param {Response} res - The response object used to send back the updated community. + * @returns {Promise} A promise that resolves once the moderator is added or an error is sent. + */ + const addModerator = async (req: CommunityIdRequest, res: Response): Promise => { + const { communityId } = req.params; + const { userId } = req.body; + if (!userId) { + res.status(400).send('Missing userId'); + return; + } + try { + const result: CommunityResponse = await addModeratorToCommunity(communityId, userId); + if ('error' in result) { + throw new Error(result.error); + } + const populatedCommunity = await populateDocument(result._id.toString(), 'community'); + if ('error' in populatedCommunity) { + throw new Error(populatedCommunity.error); + } + socket.emit('communityUpdate', { + community: populatedCommunity as PopulatedDatabaseCommunity, + type: 'moderatorAdded', + }); + res.json(result); + } catch (err: unknown) { + const errorMsg = err instanceof Error ? err.message : 'Error adding moderator to community'; + res.status(500).send(errorMsg); + } + }; + + /** + * Adds a member request for a private community. + * + * @param {CommunityIdRequest} req - The request object with the communityId in params and userId in the body. + * @param {Response} res - The response object used to send back the updated community. + * @returns {Promise} A promise that resolves once the member request is added or an error is sent. + */ + const addRequest = async (req: CommunityIdRequest, res: Response): Promise => { + const { communityId } = req.params; + const { userId } = req.body; + if (!userId) { + res.status(400).send('Missing userId'); + return; + } + try { + const result: CommunityResponse = await addMemberRequest(communityId, userId); + if ('error' in result) { + throw new Error(result.error); + } + const populatedCommunity = await populateDocument(result._id.toString(), 'community'); + if ('error' in populatedCommunity) { + throw new Error(populatedCommunity.error); + } + socket.emit('communityUpdate', { + community: populatedCommunity as PopulatedDatabaseCommunity, + type: 'memberRequestAdded', + }); + res.json(result); + } catch (err: unknown) { + const errorMsg = err instanceof Error ? err.message : 'Error adding member request'; + res.status(500).send(errorMsg); + } + }; + + /** + * Approves a member request. + * + * @param {CommunityIdRequest} req - The request object with the communityId in params and userId in the body. + * @param {Response} res - The response object used to send back the updated community. + * @returns {Promise} A promise that resolves once the member request is approved or an error is sent. + */ + const approveRequest = async (req: CommunityIdRequest, res: Response): Promise => { + const { communityId } = req.params; + const { userId } = req.body; + if (!userId) { + res.status(400).send('Missing userId'); + return; + } + try { + const result: CommunityResponse = await approveMemberRequest(communityId, userId); + if ('error' in result) { + throw new Error(result.error); + } + const populatedCommunity = await populateDocument(result._id.toString(), 'community'); + if ('error' in populatedCommunity) { + throw new Error(populatedCommunity.error); + } + socket.emit('communityUpdate', { + community: populatedCommunity as PopulatedDatabaseCommunity, + type: 'memberRequestApproved', + }); + res.json(result); + } catch (err: unknown) { + const errorMsg = err instanceof Error ? err.message : 'Error approving member request'; + res.status(500).send(errorMsg); + } + }; + + /** + * Rejects a member request. + * + * @param {CommunityIdRequest} req - The request object with the communityId in params and userId in the body. + * @param {Response} res - The response object used to send back the updated community. + * @returns {Promise} A promise that resolves once the member request is rejected or an error is sent. + */ + const rejectRequest = async (req: CommunityIdRequest, res: Response): Promise => { + const { communityId } = req.params; + const { userId } = req.body; + if (!userId) { + res.status(400).send('Missing userId'); + return; + } + try { + const result: CommunityResponse = await rejectMemberRequest(communityId, userId); + if ('error' in result) { + throw new Error(result.error); + } + const populatedCommunity = await populateDocument(result._id.toString(), 'community'); + if ('error' in populatedCommunity) { + throw new Error(populatedCommunity.error); + } + socket.emit('communityUpdate', { + community: populatedCommunity as PopulatedDatabaseCommunity, + type: 'memberRequestRejected', + }); + res.json(result); + } catch (err: unknown) { + const errorMsg = err instanceof Error ? err.message : 'Error rejecting member request'; + res.status(500).send(errorMsg); + } + }; + + /** + * Removes a member from a community. + * + * @param {CommunityIdRequest} req - The request object with the communityId in params and userId in the body. + * @param {Response} res - The response object used to send back the updated community. + * @returns {Promise} A promise that resolves once the member is removed or an error is sent. + */ + const removeMember = async (req: CommunityIdRequest, res: Response): Promise => { + const { communityId } = req.params; + const { userId } = req.body; + if (!userId) { + res.status(400).send('Missing userId'); + return; + } + try { + const result: CommunityResponse = await removeMemberFromCommunity(communityId, userId); + if ('error' in result) { + throw new Error(result.error); + } + const populatedCommunity = await populateDocument(result._id.toString(), 'community'); + if ('error' in populatedCommunity) { + throw new Error(populatedCommunity.error); + } + socket.emit('communityUpdate', { + community: populatedCommunity as PopulatedDatabaseCommunity, + type: 'memberRemoved', + }); + res.json(result); + } catch (err: unknown) { + const errorMsg = err instanceof Error ? err.message : 'Error removing member from community'; + res.status(500).send(errorMsg); + } + }; + + /** + * Removes a moderator from a community. + * + * @param {CommunityIdRequest} req - The request object with the communityId in params and userId in the body. + * @param {Response} res - The response object used to send back the updated community. + * @returns {Promise} A promise that resolves once the moderator is removed or an error is sent. + */ + const removeModerator = async (req: CommunityIdRequest, res: Response): Promise => { + const { communityId } = req.params; + const { userId } = req.body; + if (!userId) { + res.status(400).send('Missing userId'); + return; + } + try { + const result: CommunityResponse = await removeModeratorFromCommunity(communityId, userId); + if ('error' in result) { + throw new Error(result.error); + } + const populatedCommunity = await populateDocument(result._id.toString(), 'community'); + if ('error' in populatedCommunity) { + throw new Error(populatedCommunity.error); + } + socket.emit('communityUpdate', { + community: populatedCommunity as PopulatedDatabaseCommunity, + type: 'moderatorRemoved', + }); + res.json(result); + } catch (err: unknown) { + const errorMsg = + err instanceof Error ? err.message : 'Error removing moderator from community'; + res.status(500).send(errorMsg); + } + }; + + router.post('/addCommunity', addCommunity); + router.get('/getCommunity/:communityId', getCommunityById); + router.post('/addMember/:communityId', addMember); + router.post('/addModerator/:communityId', addModerator); + router.post('/addMemberRequest/:communityId', addRequest); + router.post('/approveMemberRequest/:communityId', approveRequest); + router.post('/rejectMemberRequest/:communityId', rejectRequest); + router.post('/removeMember/:communityId', removeMember); + router.post('/removeModerator/:communityId', removeModerator); + + return router; +}; + +export default communityController; diff --git a/server/models/community.model.ts b/server/models/community.model.ts new file mode 100644 index 0000000..3d843b7 --- /dev/null +++ b/server/models/community.model.ts @@ -0,0 +1,13 @@ +import mongoose, { Model } from 'mongoose'; +import communitySchema from './schema/community.schema'; +import { DatabaseCommunity } from '../types/types'; + +/** + * Mongoose model for the Community collection. + */ +const CommunityModel: Model = mongoose.model( + 'Community', + communitySchema, +); + +export default CommunityModel; diff --git a/server/models/schema/community.schema.ts b/server/models/schema/community.schema.ts new file mode 100644 index 0000000..0a37b86 --- /dev/null +++ b/server/models/schema/community.schema.ts @@ -0,0 +1,52 @@ +import { Schema } from 'mongoose'; +/** + * Mongoose schema for the Community collection. + * + * This schema defines the structure for storing communities in the database. + * Each answer includes the following fields: + * - `members`: An array of strings of the userIDs of the members. + * - `moderators`: An array of strings of the userIDs of the moderators. + * - `owner`: The userID of the owner of the community. + * - `visibility`: The visibility of the community, either 'public' or 'private'. + * - `memberRequests`: An array of objects containing the userID of the user who requested to join the community and the date and time of the request. + */ +const communitySchema: Schema = new Schema( + { + members: [ + { + type: String, + required: true, + }, + ], + moderators: [ + { + type: String, + required: true, + }, + ], + owner: { + type: String, + required: true, + }, + visibilitiy: { + type: String, + enum: ['public', 'private'], + required: true, + }, + memberRequests: [ + { + userId: { + type: String, + required: true, + }, + requestedDateTime: { + type: Date, + required: true, + }, + }, + ], + }, + { collection: 'Community' }, +); + +export default communitySchema; diff --git a/server/services/community.service.ts b/server/services/community.service.ts new file mode 100644 index 0000000..aee142f --- /dev/null +++ b/server/services/community.service.ts @@ -0,0 +1,244 @@ +import CommunityModel from '../models/community.model'; +import UserModel from '../models/users.model'; +import { Community, CommunityResponse, DatabaseCommunity } from '../types/types'; + +/** + * Saves a new community to the database. + * @param communityPayload - The community object containing members, moderators, owner, visibility, and optional memberRequests. + * @returns {Promise} - The saved community or an error message. + */ +export const saveCommunity = async (communityPayload: Community): Promise => { + try { + const result = await CommunityModel.create(communityPayload); + return result; + } catch (error) { + return { error: `Error saving community: ${(error as Error).message}` }; + } +}; + +/** + * Retrieves a community document by its ID. + * @param communityId - The ID of the community to retrieve. + * @returns {Promise} - The community or an error message. + */ +export const getCommunity = async (communityId: string): Promise => { + try { + const community: DatabaseCommunity | null = await CommunityModel.findById(communityId); + if (!community) { + throw new Error('Community not found'); + } + return community; + } catch (error) { + return { error: `Error retrieving community: ${(error as Error).message}` }; + } +}; + +/** + * Adds a member to a community. + * @param communityId - The ID of the community. + * @param userId - The user ID to add as a member. + * @returns {Promise} - The updated community or an error message. + */ +export const addMemberToCommunity = async ( + communityId: string, + userId: string, +): Promise => { + try { + const userExists = await UserModel.findById(userId); + if (!userExists) { + throw new Error('User does not exist.'); + } + + const updatedCommunity: DatabaseCommunity | null = await CommunityModel.findOneAndUpdate( + { _id: communityId, members: { $ne: userId } }, + { $push: { members: userId } }, + { new: true }, + ); + + if (!updatedCommunity) { + throw new Error('Community not found or user already a member.'); + } + return updatedCommunity; + } catch (error) { + return { error: `Error adding member to community: ${(error as Error).message}` }; + } +}; + +/** + * Adds a moderator to a community. + * @param communityId - The ID of the community. + * @param userId - The user ID to add as a moderator. + * @returns {Promise} - The updated community or an error message. + */ +export const addModeratorToCommunity = async ( + communityId: string, + userId: string, +): Promise => { + try { + const userExists = await UserModel.findById(userId); + if (!userExists) { + throw new Error('User does not exist.'); + } + + const updatedCommunity: DatabaseCommunity | null = await CommunityModel.findOneAndUpdate( + { _id: communityId, moderators: { $ne: userId } }, + { $push: { moderators: userId } }, + { new: true }, + ); + + if (!updatedCommunity) { + throw new Error('Community not found or user already a moderator.'); + } + return updatedCommunity; + } catch (error) { + return { error: `Error adding moderator to community: ${(error as Error).message}` }; + } +}; + +/** + * Adds a member request to a community. + * @param communityId - The ID of the community. + * @param userId - The user ID requesting to join. + * @returns {Promise} - The updated community or an error message. + */ +export const addMemberRequest = async ( + communityId: string, + userId: string, +): Promise => { + try { + const community = await CommunityModel.findById(communityId); + if (!community) { + throw new Error('Community not found'); + } + if (community.visibility !== 'private') { + throw new Error('Community is public'); + } + + const userExists = await UserModel.findById(userId); + if (!userExists) { + throw new Error('User does not exist.'); + } + + const updatedCommunity: DatabaseCommunity | null = await CommunityModel.findOneAndUpdate( + { '_id': communityId, 'memberRequests.userId': { $ne: userId } }, + { $push: { memberRequests: { userId, requestedAt: new Date() } } }, + { new: true }, + ); + + if (!updatedCommunity) { + throw new Error('Community not found or request already exists.'); + } + return updatedCommunity; + } catch (error) { + return { error: `Error adding member request: ${(error as Error).message}` }; + } +}; + +/** + * Approves a member request for a community. + * Moves the user from the memberRequests list to the members list. + * @param communityId - The ID of the community. + * @param userId - The user ID whose request is being approved. + * @returns {Promise} - The updated community or an error message. + */ +export const approveMemberRequest = async ( + communityId: string, + userId: string, +): Promise => { + try { + const updatedCommunity: DatabaseCommunity | null = await CommunityModel.findOneAndUpdate( + { '_id': communityId, 'memberRequests.userId': userId }, + { + $pull: { memberRequests: { userId } }, + $push: { members: userId }, + }, + { new: true }, + ); + + if (!updatedCommunity) { + throw new Error('Community not found or no pending member request for this user.'); + } + return updatedCommunity; + } catch (error) { + return { error: `Error approving member request: ${(error as Error).message}` }; + } +}; + +/** + * Rejects a member request for a community. + * Removes the user from the memberRequests list. + * @param communityId - The ID of the community. + * @param userId - The user ID whose request is being rejected. + * @returns {Promise} - The updated community or an error message. + */ +export const rejectMemberRequest = async ( + communityId: string, + userId: string, +): Promise => { + try { + const updatedCommunity: DatabaseCommunity | null = await CommunityModel.findOneAndUpdate( + { '_id': communityId, 'memberRequests.userId': userId }, + { $pull: { memberRequests: { userId } } }, + { new: true }, + ); + + if (!updatedCommunity) { + throw new Error('Community not found or no pending member request for this user.'); + } + return updatedCommunity; + } catch (error) { + return { error: `Error rejecting member request: ${(error as Error).message}` }; + } +}; + +/** + * Removes a member from a community. + * @param communityId - The ID of the community. + * @param userId - The user ID to remove from members. + * @returns {Promise} - The updated community or an error message. + */ +export const removeMemberFromCommunity = async ( + communityId: string, + userId: string, +): Promise => { + try { + const updatedCommunity: DatabaseCommunity | null = await CommunityModel.findOneAndUpdate( + { _id: communityId }, + { $pull: { members: userId } }, + { new: true }, + ); + + if (!updatedCommunity) { + throw new Error('Community not found.'); + } + return updatedCommunity; + } catch (error) { + return { error: `Error removing member from community: ${(error as Error).message}` }; + } +}; + +/** + * Removes a moderator from a community. + * @param communityId - The ID of the community. + * @param userId - The user ID to remove from moderators. + * @returns {Promise} - The updated community or an error message. + */ +export const removeModeratorFromCommunity = async ( + communityId: string, + userId: string, +): Promise => { + try { + const updatedCommunity: DatabaseCommunity | null = await CommunityModel.findOneAndUpdate( + { _id: communityId }, + { $pull: { moderators: userId } }, + { new: true }, + ); + + if (!updatedCommunity) { + throw new Error('Community not found.'); + } + return updatedCommunity; + } catch (error) { + return { error: `Error removing moderator from community: ${(error as Error).message}` }; + } +}; diff --git a/server/tests/controllers/community.controller.spec.ts b/server/tests/controllers/community.controller.spec.ts new file mode 100644 index 0000000..ec3db2b --- /dev/null +++ b/server/tests/controllers/community.controller.spec.ts @@ -0,0 +1,582 @@ +import mongoose from 'mongoose'; +import supertest from 'supertest'; +import express, { Express } from 'express'; +import communityController from '../../controllers/community.controller'; +import { + Community, + DatabaseCommunity, + PopulatedDatabaseCommunity, + FakeSOSocket, +} from '../../types/types'; +import * as communityService from '../../services/community.service'; +import * as databaseUtil from '../../utils/database.util'; + +const fakeSocket = { + emit: jest.fn(), +} as Partial as FakeSOSocket; + +describe('Community Controller', () => { + let app: Express; + beforeAll(() => { + app = express(); + app.use(express.json()); + app.use('/community', communityController(fakeSocket)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('POST /community/addCommunity', () => { + const validCommunityPayload: Community = { + members: [], + moderators: [], + memberRequests: [], + owner: 'Janie', + visibility: 'public', + }; + + it('should create a new community and emit a "created" update', async () => { + const createdCommunity: DatabaseCommunity = { + _id: new mongoose.Types.ObjectId(), + members: validCommunityPayload.members, + moderators: validCommunityPayload.moderators, + memberRequests: validCommunityPayload.memberRequests, + owner: validCommunityPayload.owner, + visibility: validCommunityPayload.visibility, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const populatedCommunity: PopulatedDatabaseCommunity = { + ...createdCommunity, + memberRequests: [], + }; + + jest.spyOn(communityService, 'saveCommunity').mockResolvedValue(createdCommunity); + jest.spyOn(databaseUtil, 'populateDocument').mockResolvedValue(populatedCommunity); + + const response = await supertest(app) + .post('/community/addCommunity') + .send(validCommunityPayload); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + _id: createdCommunity._id.toString(), + owner: validCommunityPayload.owner, + visibility: validCommunityPayload.visibility, + }); + expect(fakeSocket.emit).toHaveBeenCalledWith('communityUpdate', { + community: populatedCommunity, + type: 'created', + }); + }); + + it('should return 400 for invalid community data', async () => { + const invalidPayload = { members: [], moderators: [] }; + const response = await supertest(app).post('/community/addCommunity').send(invalidPayload); + expect(response.status).toBe(400); + expect(response.text).toBe('Invalid community data'); + }); + + it('should return 500 if saveCommunity returns an error', async () => { + jest.spyOn(communityService, 'saveCommunity').mockResolvedValue({ error: 'Service error' }); + const response = await supertest(app) + .post('/community/addCommunity') + .send(validCommunityPayload); + expect(response.status).toBe(500); + expect(response.text).toContain('Service error'); + }); + + it('should return 500 if populateDocument returns an error', async () => { + const createdCommunity: DatabaseCommunity = { + _id: new mongoose.Types.ObjectId(), + members: validCommunityPayload.members, + moderators: validCommunityPayload.moderators, + memberRequests: validCommunityPayload.memberRequests, + owner: validCommunityPayload.owner, + visibility: validCommunityPayload.visibility, + createdAt: new Date(), + updatedAt: new Date(), + }; + jest.spyOn(communityService, 'saveCommunity').mockResolvedValue(createdCommunity); + jest.spyOn(databaseUtil, 'populateDocument').mockResolvedValue({ error: 'Population error' }); + const response = await supertest(app) + .post('/community/addCommunity') + .send(validCommunityPayload); + expect(response.status).toBe(500); + expect(response.text).toContain('Population error'); + }); + }); + + describe('GET /community/getCommunity/:communityId', () => { + it('should return community if valid ID is provided', async () => { + const communityId = new mongoose.Types.ObjectId().toString(); + const communityData: DatabaseCommunity = { + _id: new mongoose.Types.ObjectId(communityId), + members: ['member1'], + moderators: ['mod1'], + memberRequests: [], + owner: 'Janie', + visibility: 'private', + createdAt: new Date(), + updatedAt: new Date(), + }; + jest.spyOn(communityService, 'getCommunity').mockResolvedValue(communityData); + + const response = await supertest(app).get(`/community/getCommunity/${communityId}`); + expect(response.status).toBe(200); + expect(response.body._id).toBe(communityData._id.toString()); + }); + + it('should return 400 if invalid community ID format', async () => { + const response = await supertest(app).get('/community/getCommunity/invalidID'); + expect(response.status).toBe(400); + expect(response.text).toBe('Invalid community ID format'); + }); + + it('should return 500 if getCommunity returns an error', async () => { + const communityId = new mongoose.Types.ObjectId().toString(); + jest.spyOn(communityService, 'getCommunity').mockResolvedValue({ error: 'Not found error' }); + const response = await supertest(app).get(`/community/getCommunity/${communityId}`); + expect(response.status).toBe(500); + expect(response.text).toContain('Not found error'); + }); + }); + + describe('POST /community/addMember/:communityId', () => { + const communityId = new mongoose.Types.ObjectId().toString(); + const userId = 'Aaron'; + const baseCommunity: DatabaseCommunity = { + _id: new mongoose.Types.ObjectId(communityId), + members: [], + moderators: [], + memberRequests: [], + owner: 'Janie', + visibility: 'public', + createdAt: new Date(), + updatedAt: new Date(), + }; + const populatedCommunity: PopulatedDatabaseCommunity = { + ...baseCommunity, + memberRequests: [], + }; + + it('should add a member and emit a "memberAdded" update', async () => { + jest.spyOn(communityService, 'addMemberToCommunity').mockResolvedValue(baseCommunity); + jest.spyOn(databaseUtil, 'populateDocument').mockResolvedValue(populatedCommunity); + + const response = await supertest(app) + .post(`/community/addMember/${communityId}`) + .send({ userId }); + expect(response.status).toBe(200); + expect(response.body._id).toBe(baseCommunity._id.toString()); + expect(fakeSocket.emit).toHaveBeenCalledWith('communityUpdate', { + community: populatedCommunity, + type: 'memberAdded', + }); + }); + + it('should return 400 if userId is missing', async () => { + const response = await supertest(app).post(`/community/addMember/${communityId}`).send({}); + expect(response.status).toBe(400); + expect(response.text).toBe('Missing userId'); + }); + + it('should return 500 if addMemberToCommunity returns an error', async () => { + jest + .spyOn(communityService, 'addMemberToCommunity') + .mockResolvedValue({ error: 'Add member error' }); + const response = await supertest(app) + .post(`/community/addMember/${communityId}`) + .send({ userId }); + expect(response.status).toBe(500); + expect(response.text).toContain('Add member error'); + }); + + it('should return 500 if populateDocument returns an error', async () => { + jest.spyOn(communityService, 'addMemberToCommunity').mockResolvedValue(baseCommunity); + jest.spyOn(databaseUtil, 'populateDocument').mockResolvedValue({ error: 'Population error' }); + const response = await supertest(app) + .post(`/community/addMember/${communityId}`) + .send({ userId }); + expect(response.status).toBe(500); + expect(response.text).toContain('Population error'); + }); + }); + + describe('POST /community/addModerator/:communityId', () => { + const communityId = new mongoose.Types.ObjectId().toString(); + const userId = 'Aaron'; + const baseCommunity: DatabaseCommunity = { + _id: new mongoose.Types.ObjectId(communityId), + members: [], + moderators: [], + memberRequests: [], + owner: 'Janie', + visibility: 'public', + createdAt: new Date(), + updatedAt: new Date(), + }; + const populatedCommunity: PopulatedDatabaseCommunity = { + ...baseCommunity, + memberRequests: [], + }; + + it('should add a moderator and emit a "moderatorAdded" update', async () => { + jest.spyOn(communityService, 'addModeratorToCommunity').mockResolvedValue(baseCommunity); + jest.spyOn(databaseUtil, 'populateDocument').mockResolvedValue(populatedCommunity); + + const response = await supertest(app) + .post(`/community/addModerator/${communityId}`) + .send({ userId }); + expect(response.status).toBe(200); + expect(fakeSocket.emit).toHaveBeenCalledWith('communityUpdate', { + community: populatedCommunity, + type: 'moderatorAdded', + }); + }); + + it('should return 400 if userId is missing', async () => { + const response = await supertest(app).post(`/community/addModerator/${communityId}`).send({}); + expect(response.status).toBe(400); + expect(response.text).toBe('Missing userId'); + }); + + it('should return 500 if addModeratorToCommunity returns an error', async () => { + jest + .spyOn(communityService, 'addModeratorToCommunity') + .mockResolvedValue({ error: 'Add moderator error' }); + const response = await supertest(app) + .post(`/community/addModerator/${communityId}`) + .send({ userId }); + expect(response.status).toBe(500); + expect(response.text).toContain('Add moderator error'); + }); + + it('should return 500 if populateDocument returns an error', async () => { + jest.spyOn(communityService, 'addModeratorToCommunity').mockResolvedValue(baseCommunity); + jest.spyOn(databaseUtil, 'populateDocument').mockResolvedValue({ error: 'Population error' }); + const response = await supertest(app) + .post(`/community/addModerator/${communityId}`) + .send({ userId }); + expect(response.status).toBe(500); + expect(response.text).toContain('Population error'); + }); + }); + + describe('POST /community/addMemberRequest/:communityId', () => { + const communityId = new mongoose.Types.ObjectId().toString(); + const userId = 'Aaron'; + const baseCommunity: DatabaseCommunity = { + _id: new mongoose.Types.ObjectId(communityId), + members: [], + moderators: [], + memberRequests: [], + owner: 'Janie', + visibility: 'private', + createdAt: new Date(), + updatedAt: new Date(), + }; + const populatedCommunity: PopulatedDatabaseCommunity = { + ...baseCommunity, + memberRequests: [ + { + userId, + requestedAt: new Date(), + user: { _id: new mongoose.Types.ObjectId(), username: 'Aaron' }, + }, + ], + }; + + it('should add a member request and emit "memberRequestAdded"', async () => { + jest.spyOn(communityService, 'addMemberRequest').mockResolvedValue(baseCommunity); + jest.spyOn(databaseUtil, 'populateDocument').mockResolvedValue(populatedCommunity); + + const response = await supertest(app) + .post(`/community/addMemberRequest/${communityId}`) + .send({ userId }); + expect(response.status).toBe(200); + expect(fakeSocket.emit).toHaveBeenCalledWith('communityUpdate', { + community: populatedCommunity, + type: 'memberRequestAdded', + }); + }); + + it('should return 400 if userId is missing', async () => { + const response = await supertest(app) + .post(`/community/addMemberRequest/${communityId}`) + .send({}); + expect(response.status).toBe(400); + expect(response.text).toBe('Missing userId'); + }); + + it('should return 500 if addMemberRequest returns an error', async () => { + jest + .spyOn(communityService, 'addMemberRequest') + .mockResolvedValue({ error: 'Member request error' }); + const response = await supertest(app) + .post(`/community/addMemberRequest/${communityId}`) + .send({ userId }); + expect(response.status).toBe(500); + expect(response.text).toContain('Member request error'); + }); + + it('should return 500 if populateDocument returns an error', async () => { + jest.spyOn(communityService, 'addMemberRequest').mockResolvedValue(baseCommunity); + jest.spyOn(databaseUtil, 'populateDocument').mockResolvedValue({ error: 'Population error' }); + const response = await supertest(app) + .post(`/community/addMemberRequest/${communityId}`) + .send({ userId }); + expect(response.status).toBe(500); + expect(response.text).toContain('Population error'); + }); + }); + + describe('POST /community/approveMemberRequest/:communityId', () => { + const communityId = new mongoose.Types.ObjectId().toString(); + const userId = 'Aaron'; + const baseCommunity: DatabaseCommunity = { + _id: new mongoose.Types.ObjectId(communityId), + members: [], + moderators: [], + memberRequests: [{ userId, requestedAt: new Date() }], + owner: 'Janie', + visibility: 'private', + createdAt: new Date(), + updatedAt: new Date(), + }; + const populatedCommunity: PopulatedDatabaseCommunity = { + ...baseCommunity, + memberRequests: [], + members: [userId], + }; + + it('should approve a member request and emit "memberRequestApproved"', async () => { + jest.spyOn(communityService, 'approveMemberRequest').mockResolvedValue(baseCommunity); + jest.spyOn(databaseUtil, 'populateDocument').mockResolvedValue(populatedCommunity); + + const response = await supertest(app) + .post(`/community/approveMemberRequest/${communityId}`) + .send({ userId }); + expect(response.status).toBe(200); + expect(fakeSocket.emit).toHaveBeenCalledWith('communityUpdate', { + community: populatedCommunity, + type: 'memberRequestApproved', + }); + }); + + it('should return 400 if userId is missing', async () => { + const response = await supertest(app) + .post(`/community/approveMemberRequest/${communityId}`) + .send({}); + expect(response.status).toBe(400); + expect(response.text).toBe('Missing userId'); + }); + + it('should return 500 if approveMemberRequest returns an error', async () => { + jest + .spyOn(communityService, 'approveMemberRequest') + .mockResolvedValue({ error: 'Approval error' }); + const response = await supertest(app) + .post(`/community/approveMemberRequest/${communityId}`) + .send({ userId }); + expect(response.status).toBe(500); + expect(response.text).toContain('Approval error'); + }); + + it('should return 500 if populateDocument returns an error', async () => { + jest.spyOn(communityService, 'approveMemberRequest').mockResolvedValue(baseCommunity); + jest.spyOn(databaseUtil, 'populateDocument').mockResolvedValue({ error: 'Population error' }); + const response = await supertest(app) + .post(`/community/approveMemberRequest/${communityId}`) + .send({ userId }); + expect(response.status).toBe(500); + expect(response.text).toContain('Population error'); + }); + }); + + describe('POST /community/rejectMemberRequest/:communityId', () => { + const communityId = new mongoose.Types.ObjectId().toString(); + const userId = 'Aaron'; + const baseCommunity: DatabaseCommunity = { + _id: new mongoose.Types.ObjectId(communityId), + members: [], + moderators: [], + memberRequests: [{ userId, requestedAt: new Date() }], + owner: 'Janie', + visibility: 'private', + createdAt: new Date(), + updatedAt: new Date(), + }; + const populatedCommunity: PopulatedDatabaseCommunity = { + ...baseCommunity, + memberRequests: [], + }; + + it('should reject a member request and emit "memberRequestRejected"', async () => { + jest.spyOn(communityService, 'rejectMemberRequest').mockResolvedValue(baseCommunity); + jest.spyOn(databaseUtil, 'populateDocument').mockResolvedValue(populatedCommunity); + + const response = await supertest(app) + .post(`/community/rejectMemberRequest/${communityId}`) + .send({ userId }); + expect(response.status).toBe(200); + expect(fakeSocket.emit).toHaveBeenCalledWith('communityUpdate', { + community: populatedCommunity, + type: 'memberRequestRejected', + }); + }); + + it('should return 400 if userId is missing', async () => { + const response = await supertest(app) + .post(`/community/rejectMemberRequest/${communityId}`) + .send({}); + expect(response.status).toBe(400); + expect(response.text).toBe('Missing userId'); + }); + + it('should return 500 if rejectMemberRequest returns an error', async () => { + jest + .spyOn(communityService, 'rejectMemberRequest') + .mockResolvedValue({ error: 'Rejection error' }); + const response = await supertest(app) + .post(`/community/rejectMemberRequest/${communityId}`) + .send({ userId }); + expect(response.status).toBe(500); + expect(response.text).toContain('Rejection error'); + }); + + it('should return 500 if populateDocument returns an error', async () => { + jest.spyOn(communityService, 'rejectMemberRequest').mockResolvedValue(baseCommunity); + jest.spyOn(databaseUtil, 'populateDocument').mockResolvedValue({ error: 'Population error' }); + const response = await supertest(app) + .post(`/community/rejectMemberRequest/${communityId}`) + .send({ userId }); + expect(response.status).toBe(500); + expect(response.text).toContain('Population error'); + }); + }); + + describe('POST /community/removeMember/:communityId', () => { + const communityId = new mongoose.Types.ObjectId().toString(); + const userId = 'Aaron'; + const baseCommunity: DatabaseCommunity = { + _id: new mongoose.Types.ObjectId(communityId), + members: [userId], + moderators: [], + memberRequests: [], + owner: 'Janie', + visibility: 'public', + createdAt: new Date(), + updatedAt: new Date(), + }; + const populatedCommunity: PopulatedDatabaseCommunity = { + ...baseCommunity, + memberRequests: [], + }; + + it('should remove a member and emit "memberRemoved"', async () => { + jest.spyOn(communityService, 'removeMemberFromCommunity').mockResolvedValue(baseCommunity); + jest.spyOn(databaseUtil, 'populateDocument').mockResolvedValue(populatedCommunity); + + const response = await supertest(app) + .post(`/community/removeMember/${communityId}`) + .send({ userId }); + expect(response.status).toBe(200); + expect(fakeSocket.emit).toHaveBeenCalledWith('communityUpdate', { + community: populatedCommunity, + type: 'memberRemoved', + }); + }); + + it('should return 400 if userId is missing', async () => { + const response = await supertest(app).post(`/community/removeMember/${communityId}`).send({}); + expect(response.status).toBe(400); + expect(response.text).toBe('Missing userId'); + }); + + it('should return 500 if removeMemberFromCommunity returns an error', async () => { + jest + .spyOn(communityService, 'removeMemberFromCommunity') + .mockResolvedValue({ error: 'Remove member error' }); + const response = await supertest(app) + .post(`/community/removeMember/${communityId}`) + .send({ userId }); + expect(response.status).toBe(500); + expect(response.text).toContain('Remove member error'); + }); + + it('should return 500 if populateDocument returns an error', async () => { + jest.spyOn(communityService, 'removeMemberFromCommunity').mockResolvedValue(baseCommunity); + jest.spyOn(databaseUtil, 'populateDocument').mockResolvedValue({ error: 'Population error' }); + const response = await supertest(app) + .post(`/community/removeMember/${communityId}`) + .send({ userId }); + expect(response.status).toBe(500); + expect(response.text).toContain('Population error'); + }); + }); + + describe('POST /community/removeModerator/:communityId', () => { + const communityId = new mongoose.Types.ObjectId().toString(); + const userId = 'Aaron'; + const baseCommunity: DatabaseCommunity = { + _id: new mongoose.Types.ObjectId(communityId), + members: [], + moderators: [userId], + memberRequests: [], + owner: 'Janie', + visibility: 'public', + createdAt: new Date(), + updatedAt: new Date(), + }; + const populatedCommunity: PopulatedDatabaseCommunity = { + ...baseCommunity, + memberRequests: [], + }; + + it('should remove a moderator and emit "moderatorRemoved"', async () => { + jest.spyOn(communityService, 'removeModeratorFromCommunity').mockResolvedValue(baseCommunity); + jest.spyOn(databaseUtil, 'populateDocument').mockResolvedValue(populatedCommunity); + + const response = await supertest(app) + .post(`/community/removeModerator/${communityId}`) + .send({ userId }); + expect(response.status).toBe(200); + expect(fakeSocket.emit).toHaveBeenCalledWith('communityUpdate', { + community: populatedCommunity, + type: 'moderatorRemoved', + }); + }); + + it('should return 400 if userId is missing', async () => { + const response = await supertest(app) + .post(`/community/removeModerator/${communityId}`) + .send({}); + expect(response.status).toBe(400); + expect(response.text).toBe('Missing userId'); + }); + + it('should return 500 if removeModeratorFromCommunity returns an error', async () => { + jest + .spyOn(communityService, 'removeModeratorFromCommunity') + .mockResolvedValue({ error: 'Remove moderator error' }); + const response = await supertest(app) + .post(`/community/removeModerator/${communityId}`) + .send({ userId }); + expect(response.status).toBe(500); + expect(response.text).toContain('Remove moderator error'); + }); + + it('should return 500 if populateDocument returns an error', async () => { + jest.spyOn(communityService, 'removeModeratorFromCommunity').mockResolvedValue(baseCommunity); + jest.spyOn(databaseUtil, 'populateDocument').mockResolvedValue({ error: 'Population error' }); + const response = await supertest(app) + .post(`/community/removeModerator/${communityId}`) + .send({ userId }); + expect(response.status).toBe(500); + expect(response.text).toContain('Population error'); + }); + }); +}); diff --git a/server/tests/services/community.service.spec.ts b/server/tests/services/community.service.spec.ts new file mode 100644 index 0000000..bf29aa3 --- /dev/null +++ b/server/tests/services/community.service.spec.ts @@ -0,0 +1,355 @@ +import { + saveCommunity, + getCommunity, + addMemberToCommunity, + addModeratorToCommunity, + addMemberRequest, + approveMemberRequest, + rejectMemberRequest, + removeMemberFromCommunity, + removeModeratorFromCommunity, +} from '../../services/community.service'; +import CommunityModel from '../../models/community.model'; +import UserModel from '../../models/users.model'; +import { Community } from '../../types/types'; + +jest.mock('../../models/community.model'); +jest.mock('../../models/users.model'); + +describe('Community Service', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('saveCommunity', () => { + it('should save and return a community', async () => { + const communityPayload: Community = { + members: [], + moderators: [], + owner: 'Janie', + visibility: 'public', + memberRequests: [], + }; + + const createdCommunity = { _id: '123', ...communityPayload }; + (CommunityModel.create as jest.Mock).mockResolvedValue(createdCommunity); + + const result = await saveCommunity(communityPayload); + expect(result).toEqual(createdCommunity); + expect(CommunityModel.create).toHaveBeenCalledWith(communityPayload); + }); + + it('should return error when creation fails', async () => { + const communityPayload: Community = { + members: [], + moderators: [], + owner: 'Janie', + visibility: 'public', + memberRequests: [], + }; + + (CommunityModel.create as jest.Mock).mockRejectedValue(new Error('Creation failed')); + + const result = await saveCommunity(communityPayload); + expect(result).toEqual({ error: 'Error saving community: Creation failed' }); + }); + }); + + describe('getCommunity', () => { + it('should return community if found', async () => { + const communityData = { + _id: '123', + members: [], + moderators: [], + owner: 'Janie', + visibility: 'public', + memberRequests: [], + }; + (CommunityModel.findById as jest.Mock).mockResolvedValue(communityData); + + const result = await getCommunity('123'); + expect(result).toEqual(communityData); + expect(CommunityModel.findById).toHaveBeenCalledWith('123'); + }); + + it('should return error if community not found', async () => { + (CommunityModel.findById as jest.Mock).mockResolvedValue(null); + + const result = await getCommunity('123'); + expect(result).toEqual({ error: 'Error retrieving community: Community not found' }); + }); + + it('should return error if findById throws an error', async () => { + (CommunityModel.findById as jest.Mock).mockRejectedValue(new Error('DB error')); + + const result = await getCommunity('123'); + expect(result).toEqual({ error: 'Error retrieving community: DB error' }); + }); + }); + + describe('addMemberToCommunity', () => { + const communityId = '123'; + const userId = 'Aaron'; + + it('should add member to community if user exists and is not already a member', async () => { + const userDoc = { _id: userId }; + (UserModel.findById as jest.Mock).mockResolvedValue(userDoc); + + const updatedCommunity = { + _id: communityId, + members: [userId], + moderators: [], + owner: 'Janie', + visibility: 'public', + memberRequests: [], + }; + (CommunityModel.findOneAndUpdate as jest.Mock).mockResolvedValue(updatedCommunity); + + const result = await addMemberToCommunity(communityId, userId); + expect(result).toEqual(updatedCommunity); + }); + + it('should return error if user does not exist', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(null); + const result = await addMemberToCommunity(communityId, userId); + expect(result).toEqual({ error: 'Error adding member to community: User does not exist.' }); + }); + + it('should return error if community not found or user already a member', async () => { + const userDoc = { _id: userId }; + (UserModel.findById as jest.Mock).mockResolvedValue(userDoc); + (CommunityModel.findOneAndUpdate as jest.Mock).mockResolvedValue(null); + + const result = await addMemberToCommunity(communityId, userId); + expect(result).toEqual({ + error: 'Error adding member to community: Community not found or user already a member.', + }); + }); + }); + + describe('addModeratorToCommunity', () => { + const communityId = '123'; + const userId = 'Aaron'; + + it('should add moderator if user exists and is not already a moderator', async () => { + const userDoc = { _id: userId }; + (UserModel.findById as jest.Mock).mockResolvedValue(userDoc); + + const updatedCommunity = { + _id: communityId, + members: [], + moderators: [userId], + owner: 'Janie', + visibility: 'public', + memberRequests: [], + }; + (CommunityModel.findOneAndUpdate as jest.Mock).mockResolvedValue(updatedCommunity); + + const result = await addModeratorToCommunity(communityId, userId); + expect(result).toEqual(updatedCommunity); + }); + + it('should return error if user does not exist', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(null); + + const result = await addModeratorToCommunity(communityId, userId); + expect(result).toEqual({ + error: 'Error adding moderator to community: User does not exist.', + }); + }); + + it('should return error if community not found or user already a moderator', async () => { + const userDoc = { _id: userId }; + (UserModel.findById as jest.Mock).mockResolvedValue(userDoc); + (CommunityModel.findOneAndUpdate as jest.Mock).mockResolvedValue(null); + + const result = await addModeratorToCommunity(communityId, userId); + expect(result).toEqual({ + error: + 'Error adding moderator to community: Community not found or user already a moderator.', + }); + }); + }); + + describe('addMemberRequest', () => { + const communityId = '123'; + const userId = 'Aaron'; + + it('should add member request if community is private, user exists, and request not already exists', async () => { + const community = { + _id: communityId, + visibility: 'private', + memberRequests: [], + }; + (CommunityModel.findById as jest.Mock).mockResolvedValue(community); + + const userDoc = { _id: userId }; + (UserModel.findById as jest.Mock).mockResolvedValue(userDoc); + + const updatedCommunity = { + _id: communityId, + visibility: 'private', + memberRequests: [{ userId, requestedAt: new Date() }], + members: [], + moderators: [], + owner: 'Janie', + }; + (CommunityModel.findOneAndUpdate as jest.Mock).mockResolvedValue(updatedCommunity); + + const result = await addMemberRequest(communityId, userId); + expect(result).toEqual(updatedCommunity); + }); + + it('should return error if community not found', async () => { + (CommunityModel.findById as jest.Mock).mockResolvedValue(null); + + const result = await addMemberRequest('123', userId); + expect(result).toEqual({ error: 'Error adding member request: Community not found' }); + }); + + it('should return error if community is public', async () => { + const community = { _id: communityId, visibility: 'public', memberRequests: [] }; + (CommunityModel.findById as jest.Mock).mockResolvedValue(community); + + const result = await addMemberRequest(communityId, userId); + expect(result).toEqual({ error: 'Error adding member request: Community is public' }); + }); + + it('should return error if user does not exist', async () => { + const community = { _id: communityId, visibility: 'private', memberRequests: [] }; + (CommunityModel.findById as jest.Mock).mockResolvedValue(community); + (UserModel.findById as jest.Mock).mockResolvedValue(null); + + const result = await addMemberRequest(communityId, userId); + expect(result).toEqual({ error: 'Error adding member request: User does not exist.' }); + }); + + it('should return error if update fails', async () => { + const community = { _id: communityId, visibility: 'private', memberRequests: [] }; + (CommunityModel.findById as jest.Mock).mockResolvedValue(community); + + const userDoc = { _id: userId }; + (UserModel.findById as jest.Mock).mockResolvedValue(userDoc); + (CommunityModel.findOneAndUpdate as jest.Mock).mockResolvedValue(null); + + const result = await addMemberRequest(communityId, userId); + expect(result).toEqual({ + error: 'Error adding member request: Community not found or request already exists.', + }); + }); + }); + + describe('approveMemberRequest', () => { + const communityId = '123'; + const userId = 'Aaron'; + + it('should approve a member request and add the user to members', async () => { + const updatedCommunity = { + _id: communityId, + members: [userId], + memberRequests: [], + moderators: [], + owner: 'Janie', + visibility: 'private', + }; + (CommunityModel.findOneAndUpdate as jest.Mock).mockResolvedValue(updatedCommunity); + + const result = await approveMemberRequest(communityId, userId); + expect(result).toEqual(updatedCommunity); + }); + + it('should return error if update fails', async () => { + (CommunityModel.findOneAndUpdate as jest.Mock).mockResolvedValue(null); + const result = await approveMemberRequest(communityId, userId); + expect(result).toEqual({ + error: + 'Error approving member request: Community not found or no pending member request for this user.', + }); + }); + }); + + describe('rejectMemberRequest', () => { + const communityId = '123'; + const userId = 'Aaron'; + + it('should reject a member request by removing it', async () => { + const updatedCommunity = { + _id: communityId, + memberRequests: [], + members: [], + moderators: [], + owner: 'Janie', + visibility: 'private', + }; + (CommunityModel.findOneAndUpdate as jest.Mock).mockResolvedValue(updatedCommunity); + + const result = await rejectMemberRequest(communityId, userId); + expect(result).toEqual(updatedCommunity); + }); + + it('should return error if update fails', async () => { + (CommunityModel.findOneAndUpdate as jest.Mock).mockResolvedValue(null); + const result = await rejectMemberRequest(communityId, userId); + expect(result).toEqual({ + error: + 'Error rejecting member request: Community not found or no pending member request for this user.', + }); + }); + }); + + describe('removeMemberFromCommunity', () => { + const communityId = '123'; + const userId = 'Aaron'; + + it('should remove a member from the community', async () => { + const updatedCommunity = { + _id: communityId, + members: [], + moderators: [], + owner: 'Janie', + visibility: 'public', + memberRequests: [], + }; + (CommunityModel.findOneAndUpdate as jest.Mock).mockResolvedValue(updatedCommunity); + + const result = await removeMemberFromCommunity(communityId, userId); + expect(result).toEqual(updatedCommunity); + }); + + it('should return error if update fails', async () => { + (CommunityModel.findOneAndUpdate as jest.Mock).mockResolvedValue(null); + const result = await removeMemberFromCommunity(communityId, userId); + expect(result).toEqual({ + error: 'Error removing member from community: Community not found.', + }); + }); + }); + + describe('removeModeratorFromCommunity', () => { + const communityId = '123'; + const userId = 'Aaron'; + + it('should remove a moderator from the community', async () => { + const updatedCommunity = { + _id: communityId, + moderators: [], + members: [], + owner: 'Janie', + visibility: 'public', + memberRequests: [], + }; + (CommunityModel.findOneAndUpdate as jest.Mock).mockResolvedValue(updatedCommunity); + + const result = await removeModeratorFromCommunity(communityId, userId); + expect(result).toEqual(updatedCommunity); + }); + + it('should return error if update fails', async () => { + (CommunityModel.findOneAndUpdate as jest.Mock).mockResolvedValue(null); + const result = await removeModeratorFromCommunity(communityId, userId); + expect(result).toEqual({ + error: 'Error removing moderator from community: Community not found.', + }); + }); + }); +}); diff --git a/server/tests/utils/database.util.spec.ts b/server/tests/utils/database.util.spec.ts index 8ee0d7c..9a828c8 100644 --- a/server/tests/utils/database.util.spec.ts +++ b/server/tests/utils/database.util.spec.ts @@ -3,6 +3,7 @@ import QuestionModel from '../../models/questions.model'; import AnswerModel from '../../models/answers.model'; import ChatModel from '../../models/chat.model'; import UserModel from '../../models/users.model'; +import CommunityModel from '../../models/community.model'; jest.mock('../../models/questions.model'); jest.mock('../../models/answers.model'); @@ -11,6 +12,7 @@ jest.mock('../../models/messages.model'); jest.mock('../../models/users.model'); jest.mock('../../models/tags.model'); jest.mock('../../models/comments.model'); +jest.mock('../../models/community.model'); describe('populateDocument', () => { afterEach(() => { @@ -43,9 +45,7 @@ describe('populateDocument', () => { const result = await populateDocument(questionID, 'question'); expect(result).toEqual({ - error: `Error when fetching and populating a document: Failed to fetch and populate question with ID: ${ - questionID - }`, + error: `Error when fetching and populating a document: Failed to fetch and populate question with ID: ${questionID}`, }); }); @@ -85,9 +85,7 @@ describe('populateDocument', () => { const result = await populateDocument(answerID, 'answer'); expect(result).toEqual({ - error: `Error when fetching and populating a document: Failed to fetch and populate answer with ID: ${ - answerID - }`, + error: `Error when fetching and populating a document: Failed to fetch and populate answer with ID: ${answerID}`, }); }); @@ -182,8 +180,70 @@ describe('populateDocument', () => { }); }); - it('should return an error message if type is invalid', async () => { - const invalidType = 'invalidType' as 'question' | 'answer' | 'chat'; + it('should fetch and populate a community document', async () => { + const communityId = 'communityId'; + const mockCommunityDoc = { + _id: communityId, + members: ['member1'], + moderators: ['mod1'], + owner: 'owner123', + visibility: 'private', + memberRequests: [{ userId: 'user123', requestedAt: new Date('2025-01-01') }], + toObject() { + return { ...this }; + }, + }; + const mockUser = { + _id: 'user123', + username: 'user123', + }; + + (CommunityModel.findOne as jest.Mock).mockResolvedValue(mockCommunityDoc); + (UserModel.findOne as jest.Mock).mockResolvedValue(mockUser); + + const result = await populateDocument(communityId, 'community'); + + expect(CommunityModel.findOne).toHaveBeenCalledWith({ _id: communityId }); + expect(result).toEqual({ + _id: communityId, + members: ['member1'], + moderators: ['mod1'], + owner: 'owner123', + visibility: 'private', + memberRequests: [ + { + userId: 'user123', + requestedAt: new Date('2025-01-01'), + user: { _id: 'user123', username: 'user123' }, + }, + ], + }); + }); + + it('should throw an error if community document is not found', async () => { + (CommunityModel.findOne as jest.Mock).mockResolvedValue(null); + const communityID = 'invalidCommunityId'; + const result = await populateDocument(communityID, 'community'); + + expect(result).toEqual({ + error: `Error when fetching and populating a document: Community not found`, + }); + }); + + it('should throw an error if fetching a community document throws an error', async () => { + (CommunityModel.findOne as jest.Mock).mockImplementation(() => { + throw new Error('Database error'); + }); + + const result = await populateDocument('communityId', 'community'); + + expect(result).toEqual({ + error: 'Error when fetching and populating a document: Database error', + }); + }); + + it('should return an error message if an invalid type is provided', async () => { + const invalidType = 'invalidType' as 'question' | 'answer' | 'chat' | 'community'; const result = await populateDocument('someId', invalidType); expect(result).toEqual({ error: 'Error when fetching and populating a document: Invalid type provided.', diff --git a/server/utils/database.util.ts b/server/utils/database.util.ts index 76dd50e..c1f69bd 100644 --- a/server/utils/database.util.ts +++ b/server/utils/database.util.ts @@ -7,6 +7,7 @@ import { PopulatedDatabaseAnswer, PopulatedDatabaseChat, PopulatedDatabaseQuestion, + PopulatedDatabaseCommunity, } from '../types/types'; import AnswerModel from '../models/answers.model'; import QuestionModel from '../models/questions.model'; @@ -15,6 +16,7 @@ import CommentModel from '../models/comments.model'; import ChatModel from '../models/chat.model'; import UserModel from '../models/users.model'; import MessageModel from '../models/messages.model'; +import CommunityModel from '../models/community.model'; /** * Fetches and populates a question document with its related tags, answers, and comments. @@ -106,20 +108,60 @@ const populateChat = async (chatID: string): Promise} - The populated community document, or null if not found. + */ +const populateCommunity = async ( + communityID: string, +): Promise => { + const communityDoc = await CommunityModel.findOne({ _id: communityID }); + if (!communityDoc) { + throw new Error('Community not found'); + } + + const communityObj = communityDoc.toObject(); + + if ('toObject' in communityObj) { + delete communityObj.toObject; + } + + if (communityObj.memberRequests && Array.isArray(communityObj.memberRequests)) { + const populatedMemberRequests = await Promise.all( + communityObj.memberRequests.map(async (mr: { userId: string; requestedAt: Date }) => { + const userDoc = await UserModel.findOne({ _id: mr.userId }); + return { + ...mr, + user: userDoc ? { _id: userDoc._id, username: userDoc.username } : null, + }; + }), + ); + communityObj.memberRequests = populatedMemberRequests; + } + + return communityObj as PopulatedDatabaseCommunity; +}; + /** * Fetches and populates a question, answer, or chat document based on the provided ID and type. * * @param {string | undefined} id - The ID of the document to fetch. - * @param {'question' | 'answer' | 'chat'} type - Specifies the type of document to fetch. - * @returns {Promise} - A promise resolving to the populated document or an error message if the operation fails. + * @param {'question' | 'answer' | 'chat' | 'community'} type - Specifies the type of document to fetch. + * @returns {Promise} - A promise resolving to the populated document or an error message if the operation fails. */ // eslint-disable is for testing purposes only, so that Jest spy functions can be used. // eslint-disable-next-line import/prefer-default-export export const populateDocument = async ( id: string, - type: 'question' | 'answer' | 'chat', + type: 'question' | 'answer' | 'chat' | 'community', ): Promise< - PopulatedDatabaseAnswer | PopulatedDatabaseChat | PopulatedDatabaseQuestion | { error: string } + | PopulatedDatabaseAnswer + | PopulatedDatabaseChat + | PopulatedDatabaseQuestion + | PopulatedDatabaseCommunity + | { error: string } > => { try { if (!id) { @@ -138,6 +180,9 @@ export const populateDocument = async ( case 'chat': result = await populateChat(id); break; + case 'community': + result = await populateCommunity(id); + break; default: throw new Error('Invalid type provided.'); } diff --git a/shared/types/community.d.ts b/shared/types/community.d.ts new file mode 100644 index 0000000..fd174d4 --- /dev/null +++ b/shared/types/community.d.ts @@ -0,0 +1,91 @@ +import { ObjectId } from 'mongodb'; +import { Request } from 'express'; +import { DatabaseUser } from './user'; + +/** + * Represents a request to join a community. + * - `userId`: The ID of the user requesting to join. + * - `requestedAt`: Timestamp for when the request was made. + */ +export interface MemberRequest { + userId: string; + requestedAt: Date; +} + +/** + * Extends a member request with populated user details. + * - `user`: Populated user details (from `DatabaseUser`), or `null` if not found. + */ +export interface PopulatedMemberRequest extends MemberRequest { + user: Pick | null; +} + +/** + * Represents a Community. + * - `members`: Array of userIDs (as strings) representing the community members. + * - `moderators`: Array of userIDs (as strings) representing the community moderators. + * - `owner`: The userID of the community owner. + * - `visibility`: The community's visibility, either 'public' or 'private'. + * - `memberRequests`: Array of member requests for joining the community. + */ +export interface Community { + members: string[]; + moderators: string[]; + owner: string; + visibility: 'public' | 'private'; + memberRequests: MemberRequest[]; +} + +/** + * Represents a Community stored in the database. + * - `_id`: Unique identifier for the community. + * - `members`: Array of userIDs (strings). + * - `moderators`: Array of userIDs (strings). + * - `owner`: The userID of the community owner. + * - `visibility`: The visibility of the community. + * - `memberRequests`: Array of member requests. + * - `createdAt`: Timestamp when the community was created. + * - `updatedAt`: Timestamp when the community was last updated. + */ +export interface DatabaseCommunity extends Community { + _id: ObjectId; + createdAt: Date; + updatedAt: Date; +} + +/** + * Represents a fully populated Community from the database. + * Here, member requests include the populated user details. + */ +export interface PopulatedDatabaseCommunity extends Omit { + memberRequests: PopulatedMemberRequest[]; +} + +/** + * Express request for creating a community. + * - `body`: Contains the community details for creation. + */ +export interface CreateCommunityRequest extends Request { + body: { + members?: string[]; + moderators?: string[]; + owner: string; + visibility: 'public' | 'private'; + memberRequests?: MemberRequest[]; + }; +} + +/** + * Custom request type for routes that require a `communityId` in the params. + */ +export interface CommunityIdRequest extends Request { + params: { + communityId: string; + }; +} + +/** + * A type representing the possible responses for a Community operation. + * - Either a `DatabaseCommunity` object or an error message. + */ +export type CommunityResponse = DatabaseCommunity | { error: string }; diff --git a/shared/types/socket.d.ts b/shared/types/socket.d.ts index a25fc84..67c9be7 100644 --- a/shared/types/socket.d.ts +++ b/shared/types/socket.d.ts @@ -4,6 +4,7 @@ import { DatabaseMessage } from './message'; import { PopulatedDatabaseQuestion } from './question'; import { SafeDatabaseUser } from './user'; import { BaseMove, GameInstance, GameInstanceID, GameMove, GameState } from './game'; +import { PopulatedDatabaseCommunity } from './community'; /** * Payload for an answer update event. @@ -93,6 +94,20 @@ export interface GameMovePayload { move: GameMove; } +export interface CommunityUpdatePayload { + community: PopulatedDatabaseCommunity; + type: + | 'created' + | 'updated' + | 'memberAdded' + | 'memberRemoved' + | 'moderatorAdded' + | 'moderatorRemoved' + | 'memberRequestAdded' + | 'memberRequestApproved' + | 'memberRequestRejected'; +} + /** * Interface representing the events the client can emit to the server. * - `makeMove`: Client can emit a move in the game. @@ -121,6 +136,7 @@ export interface ClientToServerEvents { * - `gameUpdate`: Server sends updated game state. * - `gameError`: Server sends error message related to game operation. * - `chatUpdate`: Server sends updated chat. + * - `communityUpdate`: Server sends updated community. */ export interface ServerToClientEvents { questionUpdate: (question: PopulatedDatabaseQuestion) => void; @@ -133,4 +149,5 @@ export interface ServerToClientEvents { gameUpdate: (game: GameUpdatePayload) => void; gameError: (error: GameErrorPayload) => void; chatUpdate: (chat: ChatUpdatePayload) => void; + communityUpdate: (community: CommunityUpdatePayload) => void; } diff --git a/shared/types/types.d.ts b/shared/types/types.d.ts index 37de3e9..04ba6eb 100644 --- a/shared/types/types.d.ts +++ b/shared/types/types.d.ts @@ -7,3 +7,4 @@ export * from './question'; export * from './socket'; export * from './tag'; export * from './user'; +export * from './community'; From c4a76c5e17a21be3ae56e4a768b98ac9068b670d Mon Sep 17 00:00:00 2001 From: jessicazha0 Date: Sat, 15 Mar 2025 01:17:17 -0400 Subject: [PATCH 012/144] changed routes --- server/controllers/community.controller.ts | 6 ++-- .../controllers/community.controller.spec.ts | 32 ++++++++++--------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/server/controllers/community.controller.ts b/server/controllers/community.controller.ts index b23e214..5cbbf11 100644 --- a/server/controllers/community.controller.ts +++ b/server/controllers/community.controller.ts @@ -339,9 +339,9 @@ const communityController = (socket: FakeSOSocket) => { router.post('/addModerator/:communityId', addModerator); router.post('/addMemberRequest/:communityId', addRequest); router.post('/approveMemberRequest/:communityId', approveRequest); - router.post('/rejectMemberRequest/:communityId', rejectRequest); - router.post('/removeMember/:communityId', removeMember); - router.post('/removeModerator/:communityId', removeModerator); + router.delete('/rejectMemberRequest/:communityId', rejectRequest); + router.delete('/removeMember/:communityId', removeMember); + router.delete('/removeModerator/:communityId', removeModerator); return router; }; diff --git a/server/tests/controllers/community.controller.spec.ts b/server/tests/controllers/community.controller.spec.ts index ec3db2b..d9fa7ff 100644 --- a/server/tests/controllers/community.controller.spec.ts +++ b/server/tests/controllers/community.controller.spec.ts @@ -396,7 +396,7 @@ describe('Community Controller', () => { }); }); - describe('POST /community/rejectMemberRequest/:communityId', () => { + describe('DELETE /community/rejectMemberRequest/:communityId', () => { const communityId = new mongoose.Types.ObjectId().toString(); const userId = 'Aaron'; const baseCommunity: DatabaseCommunity = { @@ -419,7 +419,7 @@ describe('Community Controller', () => { jest.spyOn(databaseUtil, 'populateDocument').mockResolvedValue(populatedCommunity); const response = await supertest(app) - .post(`/community/rejectMemberRequest/${communityId}`) + .delete(`/community/rejectMemberRequest/${communityId}`) .send({ userId }); expect(response.status).toBe(200); expect(fakeSocket.emit).toHaveBeenCalledWith('communityUpdate', { @@ -430,7 +430,7 @@ describe('Community Controller', () => { it('should return 400 if userId is missing', async () => { const response = await supertest(app) - .post(`/community/rejectMemberRequest/${communityId}`) + .delete(`/community/rejectMemberRequest/${communityId}`) .send({}); expect(response.status).toBe(400); expect(response.text).toBe('Missing userId'); @@ -441,7 +441,7 @@ describe('Community Controller', () => { .spyOn(communityService, 'rejectMemberRequest') .mockResolvedValue({ error: 'Rejection error' }); const response = await supertest(app) - .post(`/community/rejectMemberRequest/${communityId}`) + .delete(`/community/rejectMemberRequest/${communityId}`) .send({ userId }); expect(response.status).toBe(500); expect(response.text).toContain('Rejection error'); @@ -451,14 +451,14 @@ describe('Community Controller', () => { jest.spyOn(communityService, 'rejectMemberRequest').mockResolvedValue(baseCommunity); jest.spyOn(databaseUtil, 'populateDocument').mockResolvedValue({ error: 'Population error' }); const response = await supertest(app) - .post(`/community/rejectMemberRequest/${communityId}`) + .delete(`/community/rejectMemberRequest/${communityId}`) .send({ userId }); expect(response.status).toBe(500); expect(response.text).toContain('Population error'); }); }); - describe('POST /community/removeMember/:communityId', () => { + describe('DELETE /community/removeMember/:communityId', () => { const communityId = new mongoose.Types.ObjectId().toString(); const userId = 'Aaron'; const baseCommunity: DatabaseCommunity = { @@ -481,7 +481,7 @@ describe('Community Controller', () => { jest.spyOn(databaseUtil, 'populateDocument').mockResolvedValue(populatedCommunity); const response = await supertest(app) - .post(`/community/removeMember/${communityId}`) + .delete(`/community/removeMember/${communityId}`) .send({ userId }); expect(response.status).toBe(200); expect(fakeSocket.emit).toHaveBeenCalledWith('communityUpdate', { @@ -491,7 +491,9 @@ describe('Community Controller', () => { }); it('should return 400 if userId is missing', async () => { - const response = await supertest(app).post(`/community/removeMember/${communityId}`).send({}); + const response = await supertest(app) + .delete(`/community/removeMember/${communityId}`) + .send({}); expect(response.status).toBe(400); expect(response.text).toBe('Missing userId'); }); @@ -501,7 +503,7 @@ describe('Community Controller', () => { .spyOn(communityService, 'removeMemberFromCommunity') .mockResolvedValue({ error: 'Remove member error' }); const response = await supertest(app) - .post(`/community/removeMember/${communityId}`) + .delete(`/community/removeMember/${communityId}`) .send({ userId }); expect(response.status).toBe(500); expect(response.text).toContain('Remove member error'); @@ -511,14 +513,14 @@ describe('Community Controller', () => { jest.spyOn(communityService, 'removeMemberFromCommunity').mockResolvedValue(baseCommunity); jest.spyOn(databaseUtil, 'populateDocument').mockResolvedValue({ error: 'Population error' }); const response = await supertest(app) - .post(`/community/removeMember/${communityId}`) + .delete(`/community/removeMember/${communityId}`) .send({ userId }); expect(response.status).toBe(500); expect(response.text).toContain('Population error'); }); }); - describe('POST /community/removeModerator/:communityId', () => { + describe('DELETE /community/removeModerator/:communityId', () => { const communityId = new mongoose.Types.ObjectId().toString(); const userId = 'Aaron'; const baseCommunity: DatabaseCommunity = { @@ -541,7 +543,7 @@ describe('Community Controller', () => { jest.spyOn(databaseUtil, 'populateDocument').mockResolvedValue(populatedCommunity); const response = await supertest(app) - .post(`/community/removeModerator/${communityId}`) + .delete(`/community/removeModerator/${communityId}`) .send({ userId }); expect(response.status).toBe(200); expect(fakeSocket.emit).toHaveBeenCalledWith('communityUpdate', { @@ -552,7 +554,7 @@ describe('Community Controller', () => { it('should return 400 if userId is missing', async () => { const response = await supertest(app) - .post(`/community/removeModerator/${communityId}`) + .delete(`/community/removeModerator/${communityId}`) .send({}); expect(response.status).toBe(400); expect(response.text).toBe('Missing userId'); @@ -563,7 +565,7 @@ describe('Community Controller', () => { .spyOn(communityService, 'removeModeratorFromCommunity') .mockResolvedValue({ error: 'Remove moderator error' }); const response = await supertest(app) - .post(`/community/removeModerator/${communityId}`) + .delete(`/community/removeModerator/${communityId}`) .send({ userId }); expect(response.status).toBe(500); expect(response.text).toContain('Remove moderator error'); @@ -573,7 +575,7 @@ describe('Community Controller', () => { jest.spyOn(communityService, 'removeModeratorFromCommunity').mockResolvedValue(baseCommunity); jest.spyOn(databaseUtil, 'populateDocument').mockResolvedValue({ error: 'Population error' }); const response = await supertest(app) - .post(`/community/removeModerator/${communityId}`) + .delete(`/community/removeModerator/${communityId}`) .send({ userId }); expect(response.status).toBe(500); expect(response.text).toContain('Population error'); From 4907be681cf183fbcae2b59a0f83b1d6e775e0a0 Mon Sep 17 00:00:00 2001 From: jessicazha0 Date: Sat, 15 Mar 2025 20:41:51 -0400 Subject: [PATCH 013/144] removed a console log debug --- server/services/contribution.service.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/server/services/contribution.service.ts b/server/services/contribution.service.ts index 3230c54..e08ed67 100644 --- a/server/services/contribution.service.ts +++ b/server/services/contribution.service.ts @@ -23,9 +23,6 @@ const getUserContributions = async ( }, ]); - // eslint-disable-next-line - console.log('Questions found:', questions); - const answers = await AnswerModel.aggregate([ { $match: { ansBy: userID } }, { From 8bce34db71028cb9d6ee60239c8e9b67b95a64a4 Mon Sep 17 00:00:00 2001 From: jessicazha0 Date: Mon, 17 Mar 2025 15:38:12 -0400 Subject: [PATCH 014/144] tooltip cut off bug fix --- .../userContributionsComponent/index.css | 32 +++++++++++++++++-- .../userContributionsComponent/index.tsx | 19 +++++------ 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/client/src/components/profileSettings/userContributionsComponent/index.css b/client/src/components/profileSettings/userContributionsComponent/index.css index 40e16c2..b20c5ad 100644 --- a/client/src/components/profileSettings/userContributionsComponent/index.css +++ b/client/src/components/profileSettings/userContributionsComponent/index.css @@ -1,5 +1,6 @@ h3.headertext { - text-align: center; + text-align: left; + color: #555; } .grid-layout { @@ -17,4 +18,31 @@ h3.headertext { background-color: #D9D9D9; box-sizing: border-box; display: flex; -} \ No newline at end of file +} + +.user-contributions-wrapper { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + width: 100%; + position: relative; + overflow: visible; + margin: 0 auto; +} + +.contributions-main { + display: flex; + align-items: center; + justify-content: center; + gap: 2rem; + width: 100%; + position: relative; + overflow: visible; +} + +.contributions-top-row { + width: 100%; + display: flex; + justify-content: flex-start; + } \ No newline at end of file diff --git a/client/src/components/profileSettings/userContributionsComponent/index.tsx b/client/src/components/profileSettings/userContributionsComponent/index.tsx index 307e4fb..62f7d08 100644 --- a/client/src/components/profileSettings/userContributionsComponent/index.tsx +++ b/client/src/components/profileSettings/userContributionsComponent/index.tsx @@ -47,25 +47,26 @@ const UserContributionsComponent = ({ userID, err }: UserContributionProps) => { ); return ( -
-
+
+

- {totalContributions} {totalContributions === 1 ? 'Contribution' : 'Contibutions'} in{' '} + {totalContributions} {totalContributions === 1 ? 'Contribution' : 'Contributions'} in{' '} setSelectedMonth(monthIndex)} />

+
+
+
- -
); }; From e9bc6ca63555605b9c60d51094d27f8a452df440 Mon Sep 17 00:00:00 2001 From: jessicazha0 Date: Tue, 18 Mar 2025 03:32:15 -0400 Subject: [PATCH 015/144] add name, description, questions list to community backend --- server/controllers/community.controller.ts | 126 ++++++++- server/models/schema/community.schema.ts | 16 ++ server/services/community.service.ts | 63 +++++ .../controllers/community.controller.spec.ts | 240 +++++++++++++++++- .../tests/services/community.service.spec.ts | 205 ++++++++++++++- server/tests/utils/database.util.spec.ts | 10 +- server/utils/database.util.ts | 17 +- shared/types/community.d.ts | 41 ++- shared/types/socket.d.ts | 4 +- 9 files changed, 688 insertions(+), 34 deletions(-) diff --git a/server/controllers/community.controller.ts b/server/controllers/community.controller.ts index 5cbbf11..dd100aa 100644 --- a/server/controllers/community.controller.ts +++ b/server/controllers/community.controller.ts @@ -1,4 +1,4 @@ -import express, { Response } from 'express'; +import express, { Request, Response } from 'express'; import { ObjectId } from 'mongodb'; import { Community, @@ -11,6 +11,7 @@ import { import { saveCommunity, getCommunity, + getCommunities, addMemberToCommunity, addModeratorToCommunity, addMemberRequest, @@ -18,12 +19,29 @@ import { rejectMemberRequest, removeMemberFromCommunity, removeModeratorFromCommunity, + updateCommunityName, + updateCommunityDescription, } from '../services/community.service'; import { populateDocument } from '../utils/database.util'; const communityController = (socket: FakeSOSocket) => { const router = express.Router(); + /** + * Validates the community object to ensure it contains all the necessary fields. + * + * @param community The community object to validate. + * + * @returns `true` if the community is valid, otherwise `false`. + */ + const isCommunityBodyValid = (community: Community): boolean => + community.name !== undefined && + community.name.trim() !== '' && + community.owner !== undefined && + community.owner.trim() !== '' && + community.visibility !== undefined && + (community.visibility === 'public' || community.visibility === 'private'); + /** * Creates a new community. * @@ -32,19 +50,8 @@ const communityController = (socket: FakeSOSocket) => { * @returns {Promise} A promise that resolves once the community is created or an error is sent. */ const addCommunity = async (req: CreateCommunityRequest, res: Response): Promise => { - const community: Community = { - members: req.body.members || [], - moderators: req.body.moderators || [], - memberRequests: req.body.memberRequests || [], - owner: req.body.owner, - visibility: req.body.visibility, - }; - - if ( - !community.owner || - !community.visibility || - !['public', 'private'].includes(community.visibility) - ) { + const community: Community = req.body; + if (!isCommunityBodyValid(community)) { res.status(400).send('Invalid community data'); return; } @@ -94,6 +101,26 @@ const communityController = (socket: FakeSOSocket) => { } }; + /** + * Retrieves all communities. + * + * @param _ - The request object (not used). + * @param res - The response object used to send back the communities. + * @returns {Promise} A promise that resolves once the communities are retrieved or an error is sent. + */ + const getAllCommunities = async (_: Request, res: Response): Promise => { + try { + const communities = await getCommunities(); + if ('error' in communities) { + throw new Error(communities.error); + } + res.json(communities); + } catch (err: unknown) { + const errorMsg = err instanceof Error ? err.message : 'Error retrieving communities'; + res.status(500).send(errorMsg); + } + }; + /** * Adds a member to a community. * @@ -333,8 +360,77 @@ const communityController = (socket: FakeSOSocket) => { } }; + /** + * Updates the name of a community. + * + * @param {CommunityIdRequest} req - The request object with the communityId in params and new name in the body. + * @param {Response} res - The response object used to send back the updated community. + * @returns {Promise} A promise that resolves once the community name is updated or an error is sent. + */ + const updateName = async (req: CommunityIdRequest, res: Response): Promise => { + const { communityId } = req.params; + const { name } = req.body; + if (!name) { + res.status(400).send('Missing name'); + return; + } + try { + const result: CommunityResponse = await updateCommunityName(communityId, name); + if ('error' in result) { + throw new Error(result.error); + } + const populatedCommunity = await populateDocument(result._id.toString(), 'community'); + if ('error' in populatedCommunity) { + throw new Error(populatedCommunity.error); + } + socket.emit('communityUpdate', { + community: populatedCommunity as PopulatedDatabaseCommunity, + type: 'nameUpdated', + }); + res.json(result); + } catch (err: unknown) { + const errorMsg = err instanceof Error ? err.message : 'Error updating community name'; + res.status(500).send(errorMsg); + } + }; + + /** + * Updates the description (biography) of a community. + * + * @param {CommunityIdRequest} req - The request object with the communityId in params and new description in the body. + * @param {Response} res - The response object used to send back the updated community. + * @returns {Promise} A promise that resolves once the community description is updated or an error is sent. + */ + const updateDescription = async (req: CommunityIdRequest, res: Response): Promise => { + const { communityId } = req.params; + const { description } = req.body; + if (!description) { + res.status(400).send('Missing description'); + return; + } + try { + const result: CommunityResponse = await updateCommunityDescription(communityId, description); + if ('error' in result) { + throw new Error(result.error); + } + const populatedCommunity = await populateDocument(result._id.toString(), 'community'); + if ('error' in populatedCommunity) { + throw new Error(populatedCommunity.error); + } + socket.emit('communityUpdate', { + community: populatedCommunity as PopulatedDatabaseCommunity, + type: 'descriptionUpdated', + }); + res.json(result); + } catch (err: unknown) { + const errorMsg = err instanceof Error ? err.message : 'Error updating community description'; + res.status(500).send(errorMsg); + } + }; + router.post('/addCommunity', addCommunity); router.get('/getCommunity/:communityId', getCommunityById); + router.get('/getAllCommunities', getAllCommunities); router.post('/addMember/:communityId', addMember); router.post('/addModerator/:communityId', addModerator); router.post('/addMemberRequest/:communityId', addRequest); @@ -342,6 +438,8 @@ const communityController = (socket: FakeSOSocket) => { router.delete('/rejectMemberRequest/:communityId', rejectRequest); router.delete('/removeMember/:communityId', removeMember); router.delete('/removeModerator/:communityId', removeModerator); + router.patch('/updateName/:communityId', updateName); + router.patch('/updateDescription/:communityId', updateDescription); return router; }; diff --git a/server/models/schema/community.schema.ts b/server/models/schema/community.schema.ts index 0a37b86..4997761 100644 --- a/server/models/schema/community.schema.ts +++ b/server/models/schema/community.schema.ts @@ -4,14 +4,24 @@ import { Schema } from 'mongoose'; * * This schema defines the structure for storing communities in the database. * Each answer includes the following fields: + * - `name`: The name of the community. + * - `description`: A description of the community. * - `members`: An array of strings of the userIDs of the members. * - `moderators`: An array of strings of the userIDs of the moderators. * - `owner`: The userID of the owner of the community. * - `visibility`: The visibility of the community, either 'public' or 'private'. * - `memberRequests`: An array of objects containing the userID of the user who requested to join the community and the date and time of the request. + * - `questions`: An array of question IDs that belong to the community. */ const communitySchema: Schema = new Schema( { + name: { + type: String, + required: true, + }, + description: { + type: String, + }, members: [ { type: String, @@ -45,6 +55,12 @@ const communitySchema: Schema = new Schema( }, }, ], + questions: [ + { + type: Schema.Types.ObjectId, + ref: 'Question', + }, + ], }, { collection: 'Community' }, ); diff --git a/server/services/community.service.ts b/server/services/community.service.ts index aee142f..deb119c 100644 --- a/server/services/community.service.ts +++ b/server/services/community.service.ts @@ -33,6 +33,19 @@ export const getCommunity = async (communityId: string): Promise} - An array of communities or an error message. + */ +export const getCommunities = async (): Promise => { + try { + const communities: DatabaseCommunity[] = await CommunityModel.find({}); + return communities; + } catch (error) { + return [{ error: `Error retrieving communities: ${(error as Error).message}` }]; + } +}; + /** * Adds a member to a community. * @param communityId - The ID of the community. @@ -242,3 +255,53 @@ export const removeModeratorFromCommunity = async ( return { error: `Error removing moderator from community: ${(error as Error).message}` }; } }; + +/** + * Updates the name of a community. + * @param communityId - The ID of the community to update. + * @param name - The new name for the community. + * @returns {Promise} - The updated community or an error message. + */ +export const updateCommunityName = async ( + communityId: string, + name: string, +): Promise => { + try { + const updatedCommunity: DatabaseCommunity | null = await CommunityModel.findOneAndUpdate( + { _id: communityId }, + { $set: { name } }, + { new: true }, + ); + if (!updatedCommunity) { + throw new Error('Community not found.'); + } + return updatedCommunity; + } catch (error) { + return { error: `Error updating community name: ${(error as Error).message}` }; + } +}; + +/** + * Updates the description of a community. + * @param communityId - The ID of the community to update. + * @param description - The new description for the community. + * @returns {Promise} - The updated community or an error message. + */ +export const updateCommunityDescription = async ( + communityId: string, + description: string, +): Promise => { + try { + const updatedCommunity: DatabaseCommunity | null = await CommunityModel.findOneAndUpdate( + { _id: communityId }, + { $set: { description } }, + { new: true }, + ); + if (!updatedCommunity) { + throw new Error('Community not found.'); + } + return updatedCommunity; + } catch (error) { + return { error: `Error updating community description: ${(error as Error).message}` }; + } +}; diff --git a/server/tests/controllers/community.controller.spec.ts b/server/tests/controllers/community.controller.spec.ts index d9fa7ff..ef2a947 100644 --- a/server/tests/controllers/community.controller.spec.ts +++ b/server/tests/controllers/community.controller.spec.ts @@ -29,21 +29,27 @@ describe('Community Controller', () => { describe('POST /community/addCommunity', () => { const validCommunityPayload: Community = { + name: 'Software Lovers', + description: 'We love software', members: [], moderators: [], - memberRequests: [], owner: 'Janie', visibility: 'public', + memberRequests: [], + questions: [], }; it('should create a new community and emit a "created" update', async () => { const createdCommunity: DatabaseCommunity = { _id: new mongoose.Types.ObjectId(), + name: validCommunityPayload.name, + description: validCommunityPayload.description, members: validCommunityPayload.members, moderators: validCommunityPayload.moderators, - memberRequests: validCommunityPayload.memberRequests, owner: validCommunityPayload.owner, visibility: validCommunityPayload.visibility, + memberRequests: validCommunityPayload.memberRequests, + questions: validCommunityPayload.questions, createdAt: new Date(), updatedAt: new Date(), }; @@ -51,6 +57,7 @@ describe('Community Controller', () => { const populatedCommunity: PopulatedDatabaseCommunity = { ...createdCommunity, memberRequests: [], + questions: [], }; jest.spyOn(communityService, 'saveCommunity').mockResolvedValue(createdCommunity); @@ -63,6 +70,8 @@ describe('Community Controller', () => { expect(response.status).toBe(200); expect(response.body).toMatchObject({ _id: createdCommunity._id.toString(), + name: validCommunityPayload.name, + description: validCommunityPayload.description, owner: validCommunityPayload.owner, visibility: validCommunityPayload.visibility, }); @@ -91,11 +100,14 @@ describe('Community Controller', () => { it('should return 500 if populateDocument returns an error', async () => { const createdCommunity: DatabaseCommunity = { _id: new mongoose.Types.ObjectId(), + name: validCommunityPayload.name, + description: validCommunityPayload.description, members: validCommunityPayload.members, moderators: validCommunityPayload.moderators, - memberRequests: validCommunityPayload.memberRequests, owner: validCommunityPayload.owner, visibility: validCommunityPayload.visibility, + memberRequests: validCommunityPayload.memberRequests, + questions: validCommunityPayload.questions, createdAt: new Date(), updatedAt: new Date(), }; @@ -114,11 +126,14 @@ describe('Community Controller', () => { const communityId = new mongoose.Types.ObjectId().toString(); const communityData: DatabaseCommunity = { _id: new mongoose.Types.ObjectId(communityId), + name: 'Software Lovers', + description: 'We love software', members: ['member1'], moderators: ['mod1'], - memberRequests: [], owner: 'Janie', visibility: 'private', + memberRequests: [], + questions: [], createdAt: new Date(), updatedAt: new Date(), }; @@ -144,22 +159,86 @@ describe('Community Controller', () => { }); }); + describe('GET /community/getAllCommunities', () => { + it('should return an array of communities if found', async () => { + const communities = [ + { + _id: new mongoose.Types.ObjectId(), + name: 'Software Lovers', + description: 'We love software', + members: [], + moderators: [], + owner: 'Janie', + visibility: 'public', + memberRequests: [], + questions: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + { + _id: new mongoose.Types.ObjectId(), + name: 'Hardware Lovers', + description: 'We love hardware', + members: [], + moderators: [], + owner: 'Aaron', + visibility: 'private', + memberRequests: [], + questions: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + ] as DatabaseCommunity[]; + + jest.spyOn(communityService, 'getCommunities').mockResolvedValue(communities); + + const response = await supertest(app).get('/community/getAllCommunities'); + expect(response.status).toBe(200); + expect(response.body).toEqual( + communities.map(c => ({ + ...c, + _id: c._id.toString(), + createdAt: c.createdAt.toISOString(), + updatedAt: c.updatedAt.toISOString(), + })), + ); + }); + + it('should return 500 if getCommunities returns an error', async () => { + jest.spyOn(communityService, 'getCommunities').mockResolvedValue({ error: 'Service error' }); + const response = await supertest(app).get('/community/getAllCommunities'); + expect(response.status).toBe(500); + expect(response.text).toContain('Service error'); + }); + + it('should return an error message if find fails', async () => { + jest.spyOn(communityService, 'getCommunities').mockRejectedValue(new Error('DB error')); + const response = await supertest(app).get('/community/getAllCommunities'); + expect(response.status).toBe(500); + expect(response.text).toContain('DB error'); + }); + }); + describe('POST /community/addMember/:communityId', () => { const communityId = new mongoose.Types.ObjectId().toString(); const userId = 'Aaron'; const baseCommunity: DatabaseCommunity = { _id: new mongoose.Types.ObjectId(communityId), + name: 'Software Lovers', + description: 'We love software', members: [], moderators: [], memberRequests: [], owner: 'Janie', visibility: 'public', + questions: [], createdAt: new Date(), updatedAt: new Date(), }; const populatedCommunity: PopulatedDatabaseCommunity = { ...baseCommunity, memberRequests: [], + questions: [], }; it('should add a member and emit a "memberAdded" update', async () => { @@ -210,17 +289,21 @@ describe('Community Controller', () => { const userId = 'Aaron'; const baseCommunity: DatabaseCommunity = { _id: new mongoose.Types.ObjectId(communityId), + name: 'Software Lovers', + description: 'We love software', members: [], moderators: [], memberRequests: [], owner: 'Janie', visibility: 'public', + questions: [], createdAt: new Date(), updatedAt: new Date(), }; const populatedCommunity: PopulatedDatabaseCommunity = { ...baseCommunity, memberRequests: [], + questions: [], }; it('should add a moderator and emit a "moderatorAdded" update', async () => { @@ -270,11 +353,14 @@ describe('Community Controller', () => { const userId = 'Aaron'; const baseCommunity: DatabaseCommunity = { _id: new mongoose.Types.ObjectId(communityId), + name: 'Software Lovers', + description: 'We love software', members: [], moderators: [], memberRequests: [], owner: 'Janie', visibility: 'private', + questions: [], createdAt: new Date(), updatedAt: new Date(), }; @@ -338,11 +424,14 @@ describe('Community Controller', () => { const userId = 'Aaron'; const baseCommunity: DatabaseCommunity = { _id: new mongoose.Types.ObjectId(communityId), + name: 'Software Lovers', + description: 'We love software', members: [], moderators: [], memberRequests: [{ userId, requestedAt: new Date() }], owner: 'Janie', visibility: 'private', + questions: [], createdAt: new Date(), updatedAt: new Date(), }; @@ -401,11 +490,14 @@ describe('Community Controller', () => { const userId = 'Aaron'; const baseCommunity: DatabaseCommunity = { _id: new mongoose.Types.ObjectId(communityId), + name: 'Software Lovers', + description: 'We love software', + memberRequests: [{ userId, requestedAt: new Date() }], members: [], moderators: [], - memberRequests: [{ userId, requestedAt: new Date() }], owner: 'Janie', visibility: 'private', + questions: [], createdAt: new Date(), updatedAt: new Date(), }; @@ -463,11 +555,14 @@ describe('Community Controller', () => { const userId = 'Aaron'; const baseCommunity: DatabaseCommunity = { _id: new mongoose.Types.ObjectId(communityId), + name: 'Software Lovers', + description: 'We love software', members: [userId], moderators: [], memberRequests: [], owner: 'Janie', visibility: 'public', + questions: [], createdAt: new Date(), updatedAt: new Date(), }; @@ -525,11 +620,14 @@ describe('Community Controller', () => { const userId = 'Aaron'; const baseCommunity: DatabaseCommunity = { _id: new mongoose.Types.ObjectId(communityId), + name: 'Software Lovers', + description: 'We love software', members: [], moderators: [userId], memberRequests: [], owner: 'Janie', visibility: 'public', + questions: [], createdAt: new Date(), updatedAt: new Date(), }; @@ -581,4 +679,136 @@ describe('Community Controller', () => { expect(response.text).toContain('Population error'); }); }); + + describe('PATCH /community/updateName/:communityId', () => { + const communityId = new mongoose.Types.ObjectId().toString(); + const newName = 'New Community Name'; + const updatedCommunity: DatabaseCommunity = { + _id: new mongoose.Types.ObjectId(communityId), + name: newName, + description: 'We love software', + members: [], + moderators: [], + memberRequests: [], + owner: 'Janie', + visibility: 'public', + questions: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + const populatedCommunity: PopulatedDatabaseCommunity = { + ...updatedCommunity, + memberRequests: [], + }; + + it('should update the community name and emit "nameUpdated"', async () => { + jest.spyOn(communityService, 'updateCommunityName').mockResolvedValue(updatedCommunity); + jest.spyOn(databaseUtil, 'populateDocument').mockResolvedValue(populatedCommunity); + + const response = await supertest(app) + .patch(`/community/updateName/${communityId}`) + .send({ name: newName }); + expect(response.status).toBe(200); + expect(fakeSocket.emit).toHaveBeenCalledWith('communityUpdate', { + community: populatedCommunity, + type: 'nameUpdated', + }); + }); + + it('should return 400 if name is missing', async () => { + const response = await supertest(app).patch(`/community/updateName/${communityId}`).send({}); + expect(response.status).toBe(400); + expect(response.text).toBe('Missing name'); + }); + + it('should return 500 if updateCommunityName returns an error', async () => { + jest + .spyOn(communityService, 'updateCommunityName') + .mockResolvedValue({ error: 'Update error' }); + const response = await supertest(app) + .patch(`/community/updateName/${communityId}`) + .send({ name: newName }); + expect(response.status).toBe(500); + expect(response.text).toContain('Update error'); + }); + + it('should return 500 if populateDocument returns an error', async () => { + jest.spyOn(communityService, 'updateCommunityName').mockResolvedValue(updatedCommunity); + jest.spyOn(databaseUtil, 'populateDocument').mockResolvedValue({ error: 'Population error' }); + const response = await supertest(app) + .patch(`/community/updateName/${communityId}`) + .send({ name: newName }); + expect(response.status).toBe(500); + expect(response.text).toContain('Population error'); + }); + }); + + describe('PATCH /community/updateDescription/:communityId', () => { + const communityId = new mongoose.Types.ObjectId().toString(); + const newDescription = 'New community description'; + const updatedCommunity: DatabaseCommunity = { + _id: new mongoose.Types.ObjectId(communityId), + name: 'Software Lovers', + description: newDescription, + members: [], + moderators: [], + memberRequests: [], + owner: 'Janie', + visibility: 'public', + questions: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + const populatedCommunity: PopulatedDatabaseCommunity = { + ...updatedCommunity, + memberRequests: [], + }; + + it('should update the community description and emit "descriptionUpdated"', async () => { + jest + .spyOn(communityService, 'updateCommunityDescription') + .mockResolvedValue(updatedCommunity); + jest.spyOn(databaseUtil, 'populateDocument').mockResolvedValue(populatedCommunity); + + const response = await supertest(app) + .patch(`/community/updateDescription/${communityId}`) + .send({ description: newDescription }); + expect(response.status).toBe(200); + expect(fakeSocket.emit).toHaveBeenCalledWith('communityUpdate', { + community: populatedCommunity, + type: 'descriptionUpdated', + }); + }); + + it('should return 400 if description is missing', async () => { + const response = await supertest(app) + .patch(`/community/updateDescription/${communityId}`) + .send({}); + expect(response.status).toBe(400); + expect(response.text).toBe('Missing description'); + }); + + it('should return 500 if updateCommunityDescription returns an error', async () => { + jest + .spyOn(communityService, 'updateCommunityDescription') + .mockResolvedValue({ error: 'Update error' }); + const response = await supertest(app) + .patch(`/community/updateDescription/${communityId}`) + .send({ description: newDescription }); + expect(response.status).toBe(500); + expect(response.text).toContain('Update error'); + }); + + it('should return 500 if populateDocument returns an error', async () => { + jest + .spyOn(communityService, 'updateCommunityDescription') + .mockResolvedValue(updatedCommunity); + jest.spyOn(databaseUtil, 'populateDocument').mockResolvedValue({ error: 'Population error' }); + const response = await supertest(app) + .patch(`/community/updateDescription/${communityId}`) + .send({ description: newDescription }); + expect(response.status).toBe(500); + expect(response.text).toContain('Population error'); + }); + }); }); diff --git a/server/tests/services/community.service.spec.ts b/server/tests/services/community.service.spec.ts index bf29aa3..2a9a2be 100644 --- a/server/tests/services/community.service.spec.ts +++ b/server/tests/services/community.service.spec.ts @@ -1,6 +1,7 @@ import { saveCommunity, getCommunity, + getCommunities, addMemberToCommunity, addModeratorToCommunity, addMemberRequest, @@ -8,6 +9,8 @@ import { rejectMemberRequest, removeMemberFromCommunity, removeModeratorFromCommunity, + updateCommunityName, + updateCommunityDescription, } from '../../services/community.service'; import CommunityModel from '../../models/community.model'; import UserModel from '../../models/users.model'; @@ -24,11 +27,14 @@ describe('Community Service', () => { describe('saveCommunity', () => { it('should save and return a community', async () => { const communityPayload: Community = { + name: 'Software Lovers', + description: 'We love software', members: [], moderators: [], owner: 'Janie', visibility: 'public', memberRequests: [], + questions: [], }; const createdCommunity = { _id: '123', ...communityPayload }; @@ -41,11 +47,14 @@ describe('Community Service', () => { it('should return error when creation fails', async () => { const communityPayload: Community = { + name: 'Software Lovers', + description: 'We love software', members: [], moderators: [], owner: 'Janie', visibility: 'public', memberRequests: [], + questions: [], }; (CommunityModel.create as jest.Mock).mockRejectedValue(new Error('Creation failed')); @@ -59,11 +68,14 @@ describe('Community Service', () => { it('should return community if found', async () => { const communityData = { _id: '123', + name: 'Software Lovers', + description: 'We love software', members: [], moderators: [], owner: 'Janie', visibility: 'public', memberRequests: [], + questions: [], }; (CommunityModel.findById as jest.Mock).mockResolvedValue(communityData); @@ -97,11 +109,14 @@ describe('Community Service', () => { const updatedCommunity = { _id: communityId, + name: 'Software Lovers', + description: 'We love software', members: [userId], moderators: [], owner: 'Janie', visibility: 'public', memberRequests: [], + questions: [], }; (CommunityModel.findOneAndUpdate as jest.Mock).mockResolvedValue(updatedCommunity); @@ -137,11 +152,14 @@ describe('Community Service', () => { const updatedCommunity = { _id: communityId, + name: 'Software Lovers', + description: 'We love software', members: [], moderators: [userId], owner: 'Janie', visibility: 'public', memberRequests: [], + questions: [], }; (CommunityModel.findOneAndUpdate as jest.Mock).mockResolvedValue(updatedCommunity); @@ -178,8 +196,14 @@ describe('Community Service', () => { it('should add member request if community is private, user exists, and request not already exists', async () => { const community = { _id: communityId, + name: 'Software Lovers', + description: 'We love software', visibility: 'private', memberRequests: [], + members: [], + moderators: [], + owner: 'Janie', + questions: [], }; (CommunityModel.findById as jest.Mock).mockResolvedValue(community); @@ -188,11 +212,14 @@ describe('Community Service', () => { const updatedCommunity = { _id: communityId, + name: 'Software Lovers', + description: 'We love software', visibility: 'private', memberRequests: [{ userId, requestedAt: new Date() }], members: [], moderators: [], owner: 'Janie', + questions: [], }; (CommunityModel.findOneAndUpdate as jest.Mock).mockResolvedValue(updatedCommunity); @@ -208,7 +235,17 @@ describe('Community Service', () => { }); it('should return error if community is public', async () => { - const community = { _id: communityId, visibility: 'public', memberRequests: [] }; + const community = { + _id: communityId, + name: 'Software Lovers', + description: 'We love software', + visibility: 'public', + memberRequests: [], + members: [], + moderators: [], + owner: 'Janie', + questions: [], + }; (CommunityModel.findById as jest.Mock).mockResolvedValue(community); const result = await addMemberRequest(communityId, userId); @@ -216,7 +253,17 @@ describe('Community Service', () => { }); it('should return error if user does not exist', async () => { - const community = { _id: communityId, visibility: 'private', memberRequests: [] }; + const community = { + _id: communityId, + name: 'Software Lovers', + description: 'We love software', + visibility: 'private', + memberRequests: [], + members: [], + moderators: [], + owner: 'Janie', + questions: [], + }; (CommunityModel.findById as jest.Mock).mockResolvedValue(community); (UserModel.findById as jest.Mock).mockResolvedValue(null); @@ -225,7 +272,17 @@ describe('Community Service', () => { }); it('should return error if update fails', async () => { - const community = { _id: communityId, visibility: 'private', memberRequests: [] }; + const community = { + _id: communityId, + name: 'Software Lovers', + description: 'We love software', + visibility: 'private', + memberRequests: [], + members: [], + moderators: [], + owner: 'Janie', + questions: [], + }; (CommunityModel.findById as jest.Mock).mockResolvedValue(community); const userDoc = { _id: userId }; @@ -246,11 +303,14 @@ describe('Community Service', () => { it('should approve a member request and add the user to members', async () => { const updatedCommunity = { _id: communityId, + name: 'Software Lovers', + description: 'We love software', members: [userId], memberRequests: [], moderators: [], owner: 'Janie', visibility: 'private', + questions: [], }; (CommunityModel.findOneAndUpdate as jest.Mock).mockResolvedValue(updatedCommunity); @@ -275,11 +335,14 @@ describe('Community Service', () => { it('should reject a member request by removing it', async () => { const updatedCommunity = { _id: communityId, + name: 'Software Lovers', + description: 'We love software', memberRequests: [], members: [], moderators: [], owner: 'Janie', visibility: 'private', + questions: [], }; (CommunityModel.findOneAndUpdate as jest.Mock).mockResolvedValue(updatedCommunity); @@ -304,11 +367,14 @@ describe('Community Service', () => { it('should remove a member from the community', async () => { const updatedCommunity = { _id: communityId, + name: 'Software Lovers', + description: 'We love software', members: [], moderators: [], owner: 'Janie', visibility: 'public', memberRequests: [], + questions: [], }; (CommunityModel.findOneAndUpdate as jest.Mock).mockResolvedValue(updatedCommunity); @@ -332,11 +398,14 @@ describe('Community Service', () => { it('should remove a moderator from the community', async () => { const updatedCommunity = { _id: communityId, + name: 'Software Lovers', + description: 'We love software', moderators: [], members: [], owner: 'Janie', visibility: 'public', memberRequests: [], + questions: [], }; (CommunityModel.findOneAndUpdate as jest.Mock).mockResolvedValue(updatedCommunity); @@ -352,4 +421,134 @@ describe('Community Service', () => { }); }); }); + + describe('updateCommunityName', () => { + const communityId = '123'; + const newName = 'New community name'; + + it('should update the community name and return the updated community', async () => { + const updatedCommunity = { + _id: communityId, + name: newName, + description: 'We love software', + members: [], + moderators: [], + owner: 'Janie', + visibility: 'public', + memberRequests: [], + questions: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + (CommunityModel.findOneAndUpdate as jest.Mock).mockResolvedValue(updatedCommunity); + + const result = await updateCommunityName(communityId, newName); + expect(result).toEqual(updatedCommunity); + expect(CommunityModel.findOneAndUpdate).toHaveBeenCalledWith( + { _id: communityId }, + { $set: { name: newName } }, + { new: true }, + ); + }); + + it('should return error if community is not found', async () => { + (CommunityModel.findOneAndUpdate as jest.Mock).mockResolvedValue(null); + const result = await updateCommunityName(communityId, newName); + expect(result).toEqual({ error: 'Error updating community name: Community not found.' }); + }); + + it('should return error if findOneAndUpdate throws an error', async () => { + (CommunityModel.findOneAndUpdate as jest.Mock).mockRejectedValue(new Error('DB error')); + const result = await updateCommunityName(communityId, newName); + expect(result).toEqual({ error: 'Error updating community name: DB error' }); + }); + }); + + describe('updateCommunityDescription', () => { + const communityId = '123'; + const newDescription = 'New community description'; + + it('should update the community description and return the updated community', async () => { + const updatedCommunity = { + _id: communityId, + name: 'Software Lovers', + description: newDescription, + members: [], + moderators: [], + owner: 'Janie', + visibility: 'public', + memberRequests: [], + questions: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + (CommunityModel.findOneAndUpdate as jest.Mock).mockResolvedValue(updatedCommunity); + + const result = await updateCommunityDescription(communityId, newDescription); + expect(result).toEqual(updatedCommunity); + expect(CommunityModel.findOneAndUpdate).toHaveBeenCalledWith( + { _id: communityId }, + { $set: { description: newDescription } }, + { new: true }, + ); + }); + + it('should return error if community is not found', async () => { + (CommunityModel.findOneAndUpdate as jest.Mock).mockResolvedValue(null); + const result = await updateCommunityDescription(communityId, newDescription); + expect(result).toEqual({ + error: 'Error updating community description: Community not found.', + }); + }); + + it('should return error if findOneAndUpdate throws an error', async () => { + (CommunityModel.findOneAndUpdate as jest.Mock).mockRejectedValue(new Error('DB error')); + const result = await updateCommunityDescription(communityId, newDescription); + expect(result).toEqual({ error: 'Error updating community description: DB error' }); + }); + }); + + describe('getAllCommunities', () => { + it('should return an array of communities if found', async () => { + const communities = [ + { + _id: '123', + name: 'Software Lovers', + description: 'We love software', + members: [], + moderators: [], + owner: 'Janie', + visibility: 'public', + memberRequests: [], + questions: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + { + _id: '456', + name: 'Hardware Lovers', + description: 'We love hardware', + members: [], + moderators: [], + owner: 'Aaron', + visibility: 'private', + memberRequests: [], + questions: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + (CommunityModel.find as jest.Mock).mockResolvedValue(communities); + + const result = await getCommunities(); + expect(result).toEqual(communities); + }); + + it('should return an error message if find fails', async () => { + (CommunityModel.find as jest.Mock).mockRejectedValue(new Error('DB error')); + + const result = await getCommunities(); + expect(result).toEqual([{ error: 'Error retrieving communities: DB error' }]); + }); + }); }); diff --git a/server/tests/utils/database.util.spec.ts b/server/tests/utils/database.util.spec.ts index 9a828c8..5212630 100644 --- a/server/tests/utils/database.util.spec.ts +++ b/server/tests/utils/database.util.spec.ts @@ -189,6 +189,7 @@ describe('populateDocument', () => { owner: 'owner123', visibility: 'private', memberRequests: [{ userId: 'user123', requestedAt: new Date('2025-01-01') }], + questions: [], toObject() { return { ...this }; }, @@ -198,7 +199,9 @@ describe('populateDocument', () => { username: 'user123', }; - (CommunityModel.findOne as jest.Mock).mockResolvedValue(mockCommunityDoc); + (CommunityModel.findOne as jest.Mock).mockReturnValue({ + populate: jest.fn().mockResolvedValue(mockCommunityDoc), + }); (UserModel.findOne as jest.Mock).mockResolvedValue(mockUser); const result = await populateDocument(communityId, 'community'); @@ -217,11 +220,14 @@ describe('populateDocument', () => { user: { _id: 'user123', username: 'user123' }, }, ], + questions: [], }); }); it('should throw an error if community document is not found', async () => { - (CommunityModel.findOne as jest.Mock).mockResolvedValue(null); + (CommunityModel.findOne as jest.Mock).mockReturnValue({ + populate: jest.fn().mockResolvedValue(null), + }); const communityID = 'invalidCommunityId'; const result = await populateDocument(communityID, 'community'); diff --git a/server/utils/database.util.ts b/server/utils/database.util.ts index c1f69bd..415dccf 100644 --- a/server/utils/database.util.ts +++ b/server/utils/database.util.ts @@ -117,7 +117,22 @@ const populateChat = async (chatID: string): Promise => { - const communityDoc = await CommunityModel.findOne({ _id: communityID }); + const communityDoc = await CommunityModel.findOne({ _id: communityID }).populate([ + { + path: 'questions', + model: QuestionModel, + populate: [ + { path: 'tags', model: 'Tag' }, + { + path: 'answers', + model: 'Answer', + populate: { path: 'comments', model: 'Comment' }, + }, + { path: 'comments', model: 'Comment' }, + ], + }, + ]); + if (!communityDoc) { throw new Error('Community not found'); } diff --git a/shared/types/community.d.ts b/shared/types/community.d.ts index fd174d4..86f48ed 100644 --- a/shared/types/community.d.ts +++ b/shared/types/community.d.ts @@ -1,6 +1,7 @@ import { ObjectId } from 'mongodb'; import { Request } from 'express'; import { DatabaseUser } from './user'; +import { DatabaseQuestion } from './question'; /** * Represents a request to join a community. @@ -22,28 +23,37 @@ export interface PopulatedMemberRequest extends MemberRequest { /** * Represents a Community. + * - `name`: The name of the community. + * - `description`: A description of the community. * - `members`: Array of userIDs (as strings) representing the community members. * - `moderators`: Array of userIDs (as strings) representing the community moderators. * - `owner`: The userID of the community owner. * - `visibility`: The community's visibility, either 'public' or 'private'. * - `memberRequests`: Array of member requests for joining the community. + * - `questions`: Array of questions associated with the community. */ export interface Community { + name: string; + description?: string; members: string[]; moderators: string[]; owner: string; visibility: 'public' | 'private'; memberRequests: MemberRequest[]; + questions: DatabaseQuestion[]; } /** * Represents a Community stored in the database. * - `_id`: Unique identifier for the community. + * - `name`: The name of the community. + * - `description`: A description of the community. * - `members`: Array of userIDs (strings). * - `moderators`: Array of userIDs (strings). * - `owner`: The userID of the community owner. * - `visibility`: The visibility of the community. * - `memberRequests`: Array of member requests. + * - `questions`: Array of questions associated with the community. * - `createdAt`: Timestamp when the community was created. * - `updatedAt`: Timestamp when the community was last updated. */ @@ -55,7 +65,8 @@ export interface DatabaseCommunity extends Community { /** * Represents a fully populated Community from the database. - * Here, member requests include the populated user details. + * - `memberRequests`: Array of populated `PopulatedMemberRequest` objects. + * - `questions`: Array of populated `DatabaseQuestion` objects. */ export interface PopulatedDatabaseCommunity extends Omit { memberRequests: PopulatedMemberRequest[]; @@ -66,13 +77,7 @@ export interface PopulatedDatabaseCommunity extends Omit Date: Tue, 18 Mar 2025 03:53:04 -0400 Subject: [PATCH 016/144] can only become moderator if already member --- server/services/community.service.ts | 11 +++- .../tests/services/community.service.spec.ts | 66 +++++++++++++++++-- 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/server/services/community.service.ts b/server/services/community.service.ts index deb119c..f1253e4 100644 --- a/server/services/community.service.ts +++ b/server/services/community.service.ts @@ -93,6 +93,15 @@ export const addModeratorToCommunity = async ( throw new Error('User does not exist.'); } + const community = await CommunityModel.findById(communityId); + if (!community) { + throw new Error('Community not found.'); + } + + if (!community.members.includes(userId)) { + throw new Error('User must be a member before being promoted to moderator.'); + } + const updatedCommunity: DatabaseCommunity | null = await CommunityModel.findOneAndUpdate( { _id: communityId, moderators: { $ne: userId } }, { $push: { moderators: userId } }, @@ -100,7 +109,7 @@ export const addModeratorToCommunity = async ( ); if (!updatedCommunity) { - throw new Error('Community not found or user already a moderator.'); + throw new Error('User is already a moderator.'); } return updatedCommunity; } catch (error) { diff --git a/server/tests/services/community.service.spec.ts b/server/tests/services/community.service.spec.ts index 2a9a2be..d6deca4 100644 --- a/server/tests/services/community.service.spec.ts +++ b/server/tests/services/community.service.spec.ts @@ -146,21 +146,27 @@ describe('Community Service', () => { const communityId = '123'; const userId = 'Aaron'; - it('should add moderator if user exists and is not already a moderator', async () => { + it('should add moderator if user exists, is a member, and is not already a moderator', async () => { const userDoc = { _id: userId }; (UserModel.findById as jest.Mock).mockResolvedValue(userDoc); - const updatedCommunity = { + const communityDoc = { _id: communityId, name: 'Software Lovers', description: 'We love software', - members: [], - moderators: [userId], + members: [userId], + moderators: [], owner: 'Janie', visibility: 'public', memberRequests: [], questions: [], }; + (CommunityModel.findById as jest.Mock).mockResolvedValue(communityDoc); + + const updatedCommunity = { + ...communityDoc, + moderators: [userId], + }; (CommunityModel.findOneAndUpdate as jest.Mock).mockResolvedValue(updatedCommunity); const result = await addModeratorToCommunity(communityId, userId); @@ -176,15 +182,61 @@ describe('Community Service', () => { }); }); - it('should return error if community not found or user already a moderator', async () => { + it('should return error if user is not a member', async () => { const userDoc = { _id: userId }; (UserModel.findById as jest.Mock).mockResolvedValue(userDoc); - (CommunityModel.findOneAndUpdate as jest.Mock).mockResolvedValue(null); + + const communityDoc = { + _id: communityId, + name: 'Software Lovers', + description: 'We love software', + members: [], + moderators: [], + owner: 'Janie', + visibility: 'public', + memberRequests: [], + questions: [], + }; + (CommunityModel.findById as jest.Mock).mockResolvedValue(communityDoc); const result = await addModeratorToCommunity(communityId, userId); expect(result).toEqual({ error: - 'Error adding moderator to community: Community not found or user already a moderator.', + 'Error adding moderator to community: User must be a member before being promoted to moderator.', + }); + }); + + it('should return error if community not found', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue({ _id: userId }); + (CommunityModel.findById as jest.Mock).mockResolvedValue(null); + + const result = await addModeratorToCommunity(communityId, userId); + expect(result).toEqual({ + error: 'Error adding moderator to community: Community not found.', + }); + }); + + it('should return error if user is already a moderator', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue({ _id: userId }); + + const communityDoc = { + _id: communityId, + name: 'Software Lovers', + description: 'We love software', + members: [userId], + moderators: [userId], + owner: 'Janie', + visibility: 'public', + memberRequests: [], + questions: [], + }; + (CommunityModel.findById as jest.Mock).mockResolvedValue(communityDoc); + + (CommunityModel.findOneAndUpdate as jest.Mock).mockResolvedValue(null); + + const result = await addModeratorToCommunity(communityId, userId); + expect(result).toEqual({ + error: 'Error adding moderator to community: User is already a moderator.', }); }); }); From cdd8765908f9e6abbd7b836c8f328dd306001057 Mon Sep 17 00:00:00 2001 From: Jessica Zhao <132928939+jessicayzhao@users.noreply.github.com> Date: Tue, 18 Mar 2025 04:02:14 -0400 Subject: [PATCH 017/144] Update README.md --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 69871ee..30d057d 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,12 @@ A class diagram for the schema definition is shown below: | /leave | POST | Leave a game | | /games | GET | Retrieve all games | +### `/contributions` + +| Endpoint | Method | Description | +| ------------------ | ------ | -------------------------------- | +| /getContributions | GET | Fetch a user's contributions | + ## Running Stryker Mutation Testing Mutation testing helps you measure the effectiveness of your tests by introducing small changes (mutations) to your code and checking if your tests catch them. To run mutation testing with Stryker, use the following command in `server/`: From 6d530c587905fc47ba0797b4e82631afeba04a4c Mon Sep 17 00:00:00 2001 From: Khushi Khan <76977629+khushikhan0@users.noreply.github.com> Date: Thu, 20 Mar 2025 17:56:09 -0400 Subject: [PATCH 018/144] very simple starter code for leaderboard --- .../main/games/allGamesPage/index.css | 12 ++++ .../main/games/allGamesPage/index.tsx | 58 +++++++++++-------- 2 files changed, 46 insertions(+), 24 deletions(-) diff --git a/client/src/components/main/games/allGamesPage/index.css b/client/src/components/main/games/allGamesPage/index.css index f2ec73e..ab6273f 100644 --- a/client/src/components/main/games/allGamesPage/index.css +++ b/client/src/components/main/games/allGamesPage/index.css @@ -6,7 +6,15 @@ body { padding: 0; } +.game-container { + padding: 20px; + max-width: 900px; + margin: 0 auto; + border-radius: 8px; +} + .game-page { + display: inline-block; padding: 20px; max-width: 900px; margin: 0 auto; @@ -88,3 +96,7 @@ button:disabled { font-weight: bold; margin-top: 15px; } + +hr.solid-divider { + border-top: 1px solid #bbb; +} \ No newline at end of file diff --git a/client/src/components/main/games/allGamesPage/index.tsx b/client/src/components/main/games/allGamesPage/index.tsx index 96d3d47..f9f6fb7 100644 --- a/client/src/components/main/games/allGamesPage/index.tsx +++ b/client/src/components/main/games/allGamesPage/index.tsx @@ -22,34 +22,44 @@ const AllGamesPage = () => { } = useAllGamesPage(); return ( -
-
- +
+ {/* Top Ten Users who played the most games of Nim */} +
+

Nim Leaderboard

+
+ ... TODO: Finish this
- {isModalOpen && ( -
-
-

Select Game Type

- - -
+ {/* Start a new game */} +
+
+
- )} -
-
- {error &&
{error}
} -

Available Games

- -
- {availableGames.map(game => ( - - ))} + {isModalOpen && ( +
+
+

Select Game Type

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

Available Games

+ +
+ {availableGames.map(game => ( + + ))} +
From 958575a01f5128ab4829a63e522104b8ce54ae3e Mon Sep 17 00:00:00 2001 From: Khushi Khan <76977629+khushikhan0@users.noreply.github.com> Date: Thu, 20 Mar 2025 18:56:10 -0400 Subject: [PATCH 019/144] gamer types, schema, and model --- server/models/gamer.model.ts | 15 ++++++ server/models/schema/gamer.schema.ts | 32 +++++++++++++ shared/types/gamer.d.ts | 68 ++++++++++++++++++++++++++++ shared/types/types.d.ts | 1 + 4 files changed, 116 insertions(+) create mode 100644 server/models/gamer.model.ts create mode 100644 server/models/schema/gamer.schema.ts create mode 100644 shared/types/gamer.d.ts diff --git a/server/models/gamer.model.ts b/server/models/gamer.model.ts new file mode 100644 index 0000000..e4aff3d --- /dev/null +++ b/server/models/gamer.model.ts @@ -0,0 +1,15 @@ +import mongoose, { Model } from 'mongoose'; +import gamerSchema from './schema/gamer.schema'; +import { DatabaseGamer } from '../types/types'; + +/** + * Mongoose model for the `Game` collection. + * + * This model is created using the `gameSchema`, representing the `Game` collection in the MongoDB database, + * and provides an interface for interacting with the stored game data. + * + * @type {Model} + */ +const GamerModel: Model = mongoose.model('Gamer', gamerSchema); + +export default GamerModel; diff --git a/server/models/schema/gamer.schema.ts b/server/models/schema/gamer.schema.ts new file mode 100644 index 0000000..68ae1c7 --- /dev/null +++ b/server/models/schema/gamer.schema.ts @@ -0,0 +1,32 @@ +import { Schema } from 'mongoose'; + +/** + * Mongoose schema for the Gamer collection. + * + * This schema defines the structure of a gamer document in the database. + * Each game includes the following fields: + * - `username`: The username of the user. + * - `wins`: The number of games the user won. + * - `gameID`: List of games the user has played. + */ +const gamerSchema: Schema = new Schema( + { + username: { + type: String, + unique: true, + immutable: true, + }, + wins: { + type: Number, + }, + gameID: [ + { + type: String, + unique: true, + }, + ], + }, + { collection: 'Gamer' }, +); + +export default gamerSchema; diff --git a/shared/types/gamer.d.ts b/shared/types/gamer.d.ts new file mode 100644 index 0000000..9ee3237 --- /dev/null +++ b/shared/types/gamer.d.ts @@ -0,0 +1,68 @@ +import { Request } from 'express'; +import { ObjectId } from 'mongodb'; + +/** + * Interface representing a Gamer, which contains: + * - `username`: The unique username of the user. + * - `wins`: The number of games the user won. + * - `gameIds`: An array of all the games the user participated in. + */ +export interface Gamer { + username: string; + wins: number; + gameIds: string[]; +} + +/** + * Represents a user document in the database. + * - `username`: The unique username of the user. + * - `wins`: The number of games the user won. + * - `gameIds`: An array of all the games the user participated in. + * - `_id`: The unique identifier for the user, generated by MongoDB. + */ +export interface DatabaseGamer extends Gamer { + _id: ObjectId; +} + +/** + * Express request for gamer request, containing a gamer's username and game information. + * - `username`: The unique username of the user. + * - `wins`: The number of games the user won. + * - `gameIds`: An array of all the games the user participated in. + */ +export interface GamerRequest extends Request { + body: { + username: string; + wins: number; + gameIds: string[]; + }; +} + +/** + * Express request for querying a gamer by their username. + * - `username`: The username provided as a route parameter. + */ +export interface GamerByUsernameRequest extends Request { + params: { + username: string; + }; +} + +/** + * Represents a "safe" Gamer object that excludes sensitive information like the number of wins. + */ +export type SafeDatabaseGamer = Omit; + +/** + * Represents the response for user-related operations. + * - `SafeDatabaseGamer`: A Gamer object without sensitive data if the operation is successful. + * - `error`: An error message if the operation fails. + */ +export type GamerResponse = SafeDatabaseGamer | { error: string }; + +/** + * Represents the response for multiple user-related operations. + * - `SafeDatabaseGamer[]`: A list of user objects without sensitive data if the operation is successful. + * - `error`: An error message if the operation fails. + */ +export type GamersResponse = SafeDatabaseGamer[] | { error: string }; diff --git a/shared/types/types.d.ts b/shared/types/types.d.ts index 37de3e9..2f80064 100644 --- a/shared/types/types.d.ts +++ b/shared/types/types.d.ts @@ -7,3 +7,4 @@ export * from './question'; export * from './socket'; export * from './tag'; export * from './user'; +export * from './gamer'; \ No newline at end of file From 23c608e2552fb886b3ca6946c4bb64d13db7a8f4 Mon Sep 17 00:00:00 2001 From: saanvi-vutukur <113549288+saanvi-vutukur@users.noreply.github.com> Date: Sun, 23 Mar 2025 09:20:28 -0400 Subject: [PATCH 020/144] allcommunity hook, components, client service --- .../communityPage/communityView/index.css | 0 .../main/communityPage/communityView/index.ts | 37 ++++++++ .../components/main/communityPage/index.css | 28 ++++++ .../components/main/communityPage/index.ts | 28 ++++++ client/src/hooks/useAllCommunitiesPage.ts | 39 ++++++++ client/src/services/communityService.ts | 93 +++++++++++++++++++ 6 files changed, 225 insertions(+) create mode 100644 client/src/components/main/communityPage/communityView/index.css create mode 100644 client/src/components/main/communityPage/communityView/index.ts create mode 100644 client/src/components/main/communityPage/index.css create mode 100644 client/src/components/main/communityPage/index.ts create mode 100644 client/src/hooks/useAllCommunitiesPage.ts create mode 100644 client/src/services/communityService.ts diff --git a/client/src/components/main/communityPage/communityView/index.css b/client/src/components/main/communityPage/communityView/index.css new file mode 100644 index 0000000..e69de29 diff --git a/client/src/components/main/communityPage/communityView/index.ts b/client/src/components/main/communityPage/communityView/index.ts new file mode 100644 index 0000000..0f9f55e --- /dev/null +++ b/client/src/components/main/communityPage/communityView/index.ts @@ -0,0 +1,37 @@ +import React, { useEffect, useState } from 'react'; +import { getCommunitiesWithMemberCount } from '../../../services/communityService'; // Import service to get community data +import { Community } from '../../../types/types'; // Type for the community data +import './index.css'; // Add styles specific to communityView + +const CommunityView = () => { + const [communityList, setCommunityList] = useState([]); + + useEffect(() => { + const fetchData = async () => { + try { + const communities = await getCommunitiesWithMemberCount(); + setCommunityList(communities); + } catch (error) { + console.error("Failed to fetch communities:", error); + } + }; + + fetchData(); + }, []); + + return ( +
+

Community List

+
    + {communityList.map((community) => ( +
  • +

    {community.name}

    +

    {community.memberCount} Members

    +
  • + ))} +
+
+ ); +}; + +export default CommunityView; diff --git a/client/src/components/main/communityPage/index.css b/client/src/components/main/communityPage/index.css new file mode 100644 index 0000000..53cd737 --- /dev/null +++ b/client/src/components/main/communityPage/index.css @@ -0,0 +1,28 @@ +/* General styles for community page */ +.community-page-container { + padding: 20px; + display: flex; + flex-direction: column; + } + + .community-title { + font-size: 2rem; + font-weight: bold; + } + + .community-list { + list-style-type: none; + padding: 0; + } + + .community-list-item { + padding: 10px; + border: 1px solid #ccc; + margin-bottom: 10px; + cursor: pointer; + } + + .community-list-item:hover { + background-color: #f0f0f0; + } + \ No newline at end of file diff --git a/client/src/components/main/communityPage/index.ts b/client/src/components/main/communityPage/index.ts new file mode 100644 index 0000000..1d930f4 --- /dev/null +++ b/client/src/components/main/communityPage/index.ts @@ -0,0 +1,28 @@ +import React from 'react'; +import './index.css'; +import CommunityView from './communityView'; +import useCommunityPage from '../../../hooks/useAllCommunitiesPage'; + +/** + * Represents the CommunityPage component which displays a list of communities + * and provides functionality to handle community clicks and ask a new question. + */ +const CommunityPage = () => { + const { communityList, clickCommunity } = useCommunityPage(); + + return ( + <> +
+
{communityList.length} Communities
+
All Communities
+
+
+ {communityList.map(c => ( + + ))} +
+ + ); +}; + +export default CommunityPage; diff --git a/client/src/hooks/useAllCommunitiesPage.ts b/client/src/hooks/useAllCommunitiesPage.ts new file mode 100644 index 0000000..0ccc6f3 --- /dev/null +++ b/client/src/hooks/useAllCommunitiesPage.ts @@ -0,0 +1,39 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { getCommunitiesWithMemberCount } from '../services/communityService'; +import { Community } from '../types/types'; + +/** + * Custom hook for managing the community page's state and navigation. + * + * @returns communityList - An array of community data retrieved from the server + * @returns clickCommunity - Function to navigate to the community's page with the selected community as a URL parameter. + */ +const useCommunityPage = () => { + const navigate = useNavigate(); + const [communityList, setCommunityList] = useState([]); + + const clickCommunity = (communityName: string) => { + const searchParams = new URLSearchParams(); + searchParams.set('community', communityName); + + navigate(`/home?${searchParams.toString()}`); + }; + + useEffect(() => { + const fetchData = async () => { + try { + const res = await getCommunitiesWithMemberCount(); + setCommunityList(res || []); + } catch (e) { + console.error(e); + } + }; + + fetchData(); + }, []); + + return { communityList, clickCommunity }; +}; + +export default useCommunityPage; diff --git a/client/src/services/communityService.ts b/client/src/services/communityService.ts new file mode 100644 index 0000000..4ac12dc --- /dev/null +++ b/client/src/services/communityService.ts @@ -0,0 +1,93 @@ +import { Community, DatabaseCommunity } from '../types/types'; +import api from './config'; + +const COMMUNITY_API_URL = `${process.env.REACT_APP_SERVER_URL}/community`; + +/** + * Function to get communities with the number of members. + * + * @throws Error if there is an issue fetching communities with member count. + */ +const getCommunitiesWithMemberCount = async (): Promise => { + const res = await api.get(`${COMMUNITY_API_URL}/getAllCommunities`); + if (res.status !== 200) { + throw new Error('Error when fetching communities with member count'); + } + return res.data; +}; + +/** + * Function to get a community by its ID. + * + * @param communityId - The ID of the community to retrieve. + * @throws Error if there is an issue fetching the community by ID. + */ +const getCommunityById = async (communityId: string): Promise => { + const res = await api.get(`${COMMUNITY_API_URL}/getCommunity/${communityId}`); + if (res.status !== 200) { + throw new Error(`Error when fetching community by ID: ${communityId}`); + } + return res.data; +}; + +/** + * Function to add a new member to a community. + * + * @param communityId - The ID of the community. + * @param userId - The ID of the user to add. + * @throws Error if there is an issue adding the member. + */ +const addMember = async (communityId: string, userId: string): Promise => { + const res = await api.post(`${COMMUNITY_API_URL}/addMember/${communityId}`, { userId }); + if (res.status !== 200) { + throw new Error('Error when adding member to community'); + } + return res.data; +}; + +/** + * Function to remove a member from a community. + * + * @param communityId - The ID of the community. + * @param userId - The ID of the user to remove. + * @throws Error if there is an issue removing the member. + */ +const removeMember = async (communityId: string, userId: string): Promise => { + const res = await api.delete(`${COMMUNITY_API_URL}/removeMember/${communityId}`, { data: { userId } }); + if (res.status !== 200) { + throw new Error('Error when removing member from community'); + } + return res.data; +}; + +/** + * Function to update the community name. + * + * @param communityId - The ID of the community. + * @param name - The new name for the community. + * @throws Error if there is an issue updating the name. + */ +const updateCommunityName = async (communityId: string, name: string): Promise => { + const res = await api.patch(`${COMMUNITY_API_URL}/updateName/${communityId}`, { name }); + if (res.status !== 200) { + throw new Error('Error when updating community name'); + } + return res.data; +}; + +/** + * Function to update the community description. + * + * @param communityId - The ID of the community. + * @param description - The new description for the community. + * @throws Error if there is an issue updating the description. + */ +const updateCommunityDescription = async (communityId: string, description: string): Promise => { + const res = await api.patch(`${COMMUNITY_API_URL}/updateDescription/${communityId}`, { description }); + if (res.status !== 200) { + throw new Error('Error when updating community description'); + } + return res.data; +}; + +export { getCommunitiesWithMemberCount, getCommunityById, addMember, removeMember, updateCommunityName, updateCommunityDescription }; \ No newline at end of file From 9be367020fce3d718ec7c43d631f5ee56e4ad6f2 Mon Sep 17 00:00:00 2001 From: saanvi-vutukur <113549288+saanvi-vutukur@users.noreply.github.com> Date: Sun, 23 Mar 2025 09:43:50 -0400 Subject: [PATCH 021/144] fixed tsx files --- .../communityPage/communityView/{index.ts => index.tsx} | 6 +++--- .../components/main/communityPage/{index.ts => index.tsx} | 0 2 files changed, 3 insertions(+), 3 deletions(-) rename client/src/components/main/communityPage/communityView/{index.ts => index.tsx} (75%) rename client/src/components/main/communityPage/{index.ts => index.tsx} (100%) diff --git a/client/src/components/main/communityPage/communityView/index.ts b/client/src/components/main/communityPage/communityView/index.tsx similarity index 75% rename from client/src/components/main/communityPage/communityView/index.ts rename to client/src/components/main/communityPage/communityView/index.tsx index 0f9f55e..419c47e 100644 --- a/client/src/components/main/communityPage/communityView/index.ts +++ b/client/src/components/main/communityPage/communityView/index.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; -import { getCommunitiesWithMemberCount } from '../../../services/communityService'; // Import service to get community data -import { Community } from '../../../types/types'; // Type for the community data -import './index.css'; // Add styles specific to communityView +import { getCommunitiesWithMemberCount } from '../services/communityService'; +import { Community } from '../types/types'; +import './index.css'; const CommunityView = () => { const [communityList, setCommunityList] = useState([]); diff --git a/client/src/components/main/communityPage/index.ts b/client/src/components/main/communityPage/index.tsx similarity index 100% rename from client/src/components/main/communityPage/index.ts rename to client/src/components/main/communityPage/index.tsx From bb0c229a0d76b751286b8b0d138e611796e02893 Mon Sep 17 00:00:00 2001 From: jessicazha0 Date: Sun, 23 Mar 2025 17:08:49 +0100 Subject: [PATCH 022/144] community frontend services --- .../communityView/index.css | 0 .../communityView/index.tsx | 0 .../allCommunitiesPage}/index.css | 0 .../communities/allCommunitiesPage/index.tsx | 28 +++ .../components/main/communityPage/index.tsx | 28 --- .../src/components/main/sideBarNav/index.tsx | 6 + .../communities/useAllCommunitiesPage.ts | 101 ++++++++++ client/src/hooks/useAllCommunitiesPage.ts | 39 ---- client/src/services/communityService.ts | 178 ++++++++++++++++-- server/models/schema/community.schema.ts | 1 + 10 files changed, 300 insertions(+), 81 deletions(-) rename client/src/components/main/{communityPage => communities/allCommunitiesPage}/communityView/index.css (100%) rename client/src/components/main/{communityPage => communities/allCommunitiesPage}/communityView/index.tsx (100%) rename client/src/components/main/{communityPage => communities/allCommunitiesPage}/index.css (100%) create mode 100644 client/src/components/main/communities/allCommunitiesPage/index.tsx delete mode 100644 client/src/components/main/communityPage/index.tsx create mode 100644 client/src/hooks/communities/useAllCommunitiesPage.ts delete mode 100644 client/src/hooks/useAllCommunitiesPage.ts diff --git a/client/src/components/main/communityPage/communityView/index.css b/client/src/components/main/communities/allCommunitiesPage/communityView/index.css similarity index 100% rename from client/src/components/main/communityPage/communityView/index.css rename to client/src/components/main/communities/allCommunitiesPage/communityView/index.css diff --git a/client/src/components/main/communityPage/communityView/index.tsx b/client/src/components/main/communities/allCommunitiesPage/communityView/index.tsx similarity index 100% rename from client/src/components/main/communityPage/communityView/index.tsx rename to client/src/components/main/communities/allCommunitiesPage/communityView/index.tsx diff --git a/client/src/components/main/communityPage/index.css b/client/src/components/main/communities/allCommunitiesPage/index.css similarity index 100% rename from client/src/components/main/communityPage/index.css rename to client/src/components/main/communities/allCommunitiesPage/index.css diff --git a/client/src/components/main/communities/allCommunitiesPage/index.tsx b/client/src/components/main/communities/allCommunitiesPage/index.tsx new file mode 100644 index 0000000..ef85f59 --- /dev/null +++ b/client/src/components/main/communities/allCommunitiesPage/index.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import './index.css'; +import CommunityView from './communityView'; +import useAllCommunitiesPage from '../../../../hooks/communities/useAllCommunitiesPage'; + +/** + * Represents the CommunityPage component which displays a list of communities + * and provides functionality to handle community clicks and ask a new question. + */ +const AllCommunitiesPage = () => { + const { communities, handleCommunityClick, handleJoin } = useAllCommunitiesPage(); + + return ( +
+

All Communities

+
+ {communities.map(community => ( +
+

handleCommunityClick(community.name)}>{community.name}

+ +
+ ))} +
+
+ ); +}; + +export default AllCommunitiesPage; diff --git a/client/src/components/main/communityPage/index.tsx b/client/src/components/main/communityPage/index.tsx deleted file mode 100644 index 1d930f4..0000000 --- a/client/src/components/main/communityPage/index.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from 'react'; -import './index.css'; -import CommunityView from './communityView'; -import useCommunityPage from '../../../hooks/useAllCommunitiesPage'; - -/** - * Represents the CommunityPage component which displays a list of communities - * and provides functionality to handle community clicks and ask a new question. - */ -const CommunityPage = () => { - const { communityList, clickCommunity } = useCommunityPage(); - - return ( - <> -
-
{communityList.length} Communities
-
All Communities
-
-
- {communityList.map(c => ( - - ))} -
- - ); -}; - -export default CommunityPage; diff --git a/client/src/components/main/sideBarNav/index.tsx b/client/src/components/main/sideBarNav/index.tsx index 4a5d1af..9562966 100644 --- a/client/src/components/main/sideBarNav/index.tsx +++ b/client/src/components/main/sideBarNav/index.tsx @@ -65,6 +65,12 @@ const SideBarNav = () => { className={({ isActive }) => `menu_button ${isActive ? 'menu_selected' : ''}`}> Games + `menu_button ${isActive ? 'menu_selected' : ''}`}> + Communities +
); }; diff --git a/client/src/hooks/communities/useAllCommunitiesPage.ts b/client/src/hooks/communities/useAllCommunitiesPage.ts new file mode 100644 index 0000000..6a2b1a1 --- /dev/null +++ b/client/src/hooks/communities/useAllCommunitiesPage.ts @@ -0,0 +1,101 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import useUserContext from '../useUserContext'; +import { getAllCommunities, addMember } from '../../services/communityService'; +import { PopulatedDatabaseCommunity } from '../../types/types'; + +/** + * Custom hook for managing the All Communities page state and interactions. + * + * @returns communities - An array of all communities + * @returns handleCommunityClick - Navigates to a specific community's page + * @returns handleJoin - Joins a community (if user is logged in) + */ +const useAllCommunitiesPage = () => { + const navigate = useNavigate(); + const { user, socket } = useUserContext(); + + const [communities, setCommunities] = useState([]); + + useEffect(() => { + const fetchCommunities = async () => { + try { + const allCommunities = await getAllCommunities(); + setCommunities(allCommunities); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error fetching communities:', error); + } + }; + + fetchCommunities(); + + const handleCommunityUpdate = (data: { + community: PopulatedDatabaseCommunity; + type: string; + }) => { + const { community, type } = data; + + setCommunities(prev => { + const exists = prev.some(c => c.name === community.name); + if (!exists && type === 'created') { + return [community, ...prev]; + } + if (exists) { + return prev.map(c => (c.name === community.name ? community : c)); + } + return prev; + }); + }; + + socket?.on('communityUpdate', handleCommunityUpdate); + + return () => { + socket?.off('communityUpdate', handleCommunityUpdate); + }; + }, [socket]); + + /** + * Navigates to the community's detail page. + * + * @param communityId - The ID of the community to view. + */ + const handleCommunityClick = (communityId: string) => { + navigate(`/community/${communityId}`); + }; + + /** + * Joins a community if the user is logged in. + * + * @param communityId - The ID of the community to join. + */ + const handleJoin = async (communityId: string) => { + try { + if (!user?.username) { + // eslint-disable-next-line no-console + console.warn('User not logged in. Redirecting to login page...'); + navigate('/login'); + return; + } + + await addMember(communityId, user.username); + + setCommunities(prev => + prev.map(c => + c.name === communityId ? { ...c, members: [...c.members, user.username] } : c, + ), + ); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error joining community:', error); + } + }; + + return { + communities, + handleCommunityClick, + handleJoin, + }; +}; + +export default useAllCommunitiesPage; diff --git a/client/src/hooks/useAllCommunitiesPage.ts b/client/src/hooks/useAllCommunitiesPage.ts deleted file mode 100644 index 0ccc6f3..0000000 --- a/client/src/hooks/useAllCommunitiesPage.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { getCommunitiesWithMemberCount } from '../services/communityService'; -import { Community } from '../types/types'; - -/** - * Custom hook for managing the community page's state and navigation. - * - * @returns communityList - An array of community data retrieved from the server - * @returns clickCommunity - Function to navigate to the community's page with the selected community as a URL parameter. - */ -const useCommunityPage = () => { - const navigate = useNavigate(); - const [communityList, setCommunityList] = useState([]); - - const clickCommunity = (communityName: string) => { - const searchParams = new URLSearchParams(); - searchParams.set('community', communityName); - - navigate(`/home?${searchParams.toString()}`); - }; - - useEffect(() => { - const fetchData = async () => { - try { - const res = await getCommunitiesWithMemberCount(); - setCommunityList(res || []); - } catch (e) { - console.error(e); - } - }; - - fetchData(); - }, []); - - return { communityList, clickCommunity }; -}; - -export default useCommunityPage; diff --git a/client/src/services/communityService.ts b/client/src/services/communityService.ts index 4ac12dc..a35f35c 100644 --- a/client/src/services/communityService.ts +++ b/client/src/services/communityService.ts @@ -1,17 +1,19 @@ -import { Community, DatabaseCommunity } from '../types/types'; +import { Community, PopulatedDatabaseCommunity } from '../types/types'; import api from './config'; const COMMUNITY_API_URL = `${process.env.REACT_APP_SERVER_URL}/community`; /** - * Function to get communities with the number of members. + * Creates a new community. * - * @throws Error if there is an issue fetching communities with member count. + * @param community - The community object to create. + * @throws Error if there is an issue creating the community. + * @returns The created community. */ -const getCommunitiesWithMemberCount = async (): Promise => { - const res = await api.get(`${COMMUNITY_API_URL}/getAllCommunities`); +const addCommunity = async (community: Community): Promise => { + const res = await api.post(`${COMMUNITY_API_URL}/addCommunity`, community); if (res.status !== 200) { - throw new Error('Error when fetching communities with member count'); + throw new Error('Error while creating a new community'); } return res.data; }; @@ -21,8 +23,9 @@ const getCommunitiesWithMemberCount = async (): Promise => { * * @param communityId - The ID of the community to retrieve. * @throws Error if there is an issue fetching the community by ID. + * @returns The community. */ -const getCommunityById = async (communityId: string): Promise => { +const getCommunityById = async (communityId: string): Promise => { const res = await api.get(`${COMMUNITY_API_URL}/getCommunity/${communityId}`); if (res.status !== 200) { throw new Error(`Error when fetching community by ID: ${communityId}`); @@ -30,14 +33,32 @@ const getCommunityById = async (communityId: string): Promise return res.data; }; +/** + * Retrieves all communities. + * + * @throws Error if there is an issue fetching communities. + * @returns An array of communities. + */ +const getAllCommunities = async (): Promise => { + const res = await api.get(`${COMMUNITY_API_URL}/getAllCommunities`); + if (res.status !== 200) { + throw new Error('Error when fetching communities'); + } + return res.data; +}; + /** * Function to add a new member to a community. * * @param communityId - The ID of the community. * @param userId - The ID of the user to add. * @throws Error if there is an issue adding the member. + * @returns The updated community. */ -const addMember = async (communityId: string, userId: string): Promise => { +const addMember = async ( + communityId: string, + userId: string, +): Promise => { const res = await api.post(`${COMMUNITY_API_URL}/addMember/${communityId}`, { userId }); if (res.status !== 200) { throw new Error('Error when adding member to community'); @@ -45,29 +66,140 @@ const addMember = async (communityId: string, userId: string): Promise => { + const res = await api.post(`${COMMUNITY_API_URL}/addModerator/${communityId}`, { userId }); + if (res.status !== 200) { + throw new Error('Error while adding moderator to community'); + } + return res.data; +}; + +/** + * Adds a member request to a private community. + * + * @param communityId - The ID of the community. + * @param userId - The user ID requesting membership. + * @throws Error if there is an issue with the member request. + * @returns The updated community. + */ +const addMemberRequest = async ( + communityId: string, + userId: string, +): Promise => { + const res = await api.post(`${COMMUNITY_API_URL}/addMemberRequest/${communityId}`, { userId }); + if (res.status !== 200) { + throw new Error('Error while adding member request'); + } + return res.data; +}; + +/** + * Approves a member request for a community. + * + * @param communityId - The ID of the community. + * @param userId - The user ID whose request is approved. + * @throws Error if there is an issue approving the request. + * @returns The updated community. + */ +const approveMemberRequest = async ( + communityId: string, + userId: string, +): Promise => { + const res = await api.post(`${COMMUNITY_API_URL}/approveMemberRequest/${communityId}`, { + userId, + }); + if (res.status !== 200) { + throw new Error('Error while approving member request'); + } + return res.data; +}; + +/** + * Rejects a member request for a community. + * + * @param communityId - The ID of the community. + * @param userId - The user ID whose request is rejected. + * @throws Error if there is an issue rejecting the request. + * @returns The updated community. + */ +const rejectMemberRequest = async ( + communityId: string, + userId: string, +): Promise => { + const res = await api.delete(`${COMMUNITY_API_URL}/rejectMemberRequest/${communityId}`, { + data: { userId }, + }); + if (res.status !== 200) { + throw new Error('Error while rejecting member request'); + } + return res.data; +}; + /** * Function to remove a member from a community. * * @param communityId - The ID of the community. * @param userId - The ID of the user to remove. * @throws Error if there is an issue removing the member. + * @returns The updated community. */ -const removeMember = async (communityId: string, userId: string): Promise => { - const res = await api.delete(`${COMMUNITY_API_URL}/removeMember/${communityId}`, { data: { userId } }); +const removeMember = async ( + communityId: string, + userId: string, +): Promise => { + const res = await api.delete(`${COMMUNITY_API_URL}/removeMember/${communityId}`, { + data: { userId }, + }); if (res.status !== 200) { throw new Error('Error when removing member from community'); } return res.data; }; +/** + * Removes a moderator from a community. + * + * @param communityId - The ID of the community. + * @param userId - The user ID to remove from moderators. + * @throws Error if there is an issue removing the moderator. + * @returns The updated community. + */ +const removeModerator = async ( + communityId: string, + userId: string, +): Promise => { + const res = await api.delete(`${COMMUNITY_API_URL}/removeModerator/${communityId}`, { + data: { userId }, + }); + if (res.status !== 200) { + throw new Error('Error while removing moderator from community'); + } + return res.data; +}; + /** * Function to update the community name. * * @param communityId - The ID of the community. * @param name - The new name for the community. * @throws Error if there is an issue updating the name. + * @returns The updated community. */ -const updateCommunityName = async (communityId: string, name: string): Promise => { +const updateCommunityName = async ( + communityId: string, + name: string, +): Promise => { const res = await api.patch(`${COMMUNITY_API_URL}/updateName/${communityId}`, { name }); if (res.status !== 200) { throw new Error('Error when updating community name'); @@ -82,12 +214,30 @@ const updateCommunityName = async (communityId: string, name: string): Promise => { - const res = await api.patch(`${COMMUNITY_API_URL}/updateDescription/${communityId}`, { description }); +const updateCommunityDescription = async ( + communityId: string, + description: string, +): Promise => { + const res = await api.patch(`${COMMUNITY_API_URL}/updateDescription/${communityId}`, { + description, + }); if (res.status !== 200) { throw new Error('Error when updating community description'); } return res.data; }; -export { getCommunitiesWithMemberCount, getCommunityById, addMember, removeMember, updateCommunityName, updateCommunityDescription }; \ No newline at end of file +export { + addCommunity, + getCommunityById, + getAllCommunities, + addMember, + addModerator, + addMemberRequest, + approveMemberRequest, + rejectMemberRequest, + removeMember, + removeModerator, + updateCommunityName, + updateCommunityDescription, +}; diff --git a/server/models/schema/community.schema.ts b/server/models/schema/community.schema.ts index 4997761..0726441 100644 --- a/server/models/schema/community.schema.ts +++ b/server/models/schema/community.schema.ts @@ -18,6 +18,7 @@ const communitySchema: Schema = new Schema( name: { type: String, required: true, + unique: true, }, description: { type: String, From 64ef588ee794b402ebf34b4d4cb016728364e771 Mon Sep 17 00:00:00 2001 From: jessicazha0 Date: Sun, 23 Mar 2025 17:12:41 +0100 Subject: [PATCH 023/144] remove --- .../communityView/index.css | 0 .../communityView/index.tsx | 37 ------------------- .../communities/allCommunitiesPage/index.tsx | 1 - 3 files changed, 38 deletions(-) delete mode 100644 client/src/components/main/communities/allCommunitiesPage/communityView/index.css delete mode 100644 client/src/components/main/communities/allCommunitiesPage/communityView/index.tsx diff --git a/client/src/components/main/communities/allCommunitiesPage/communityView/index.css b/client/src/components/main/communities/allCommunitiesPage/communityView/index.css deleted file mode 100644 index e69de29..0000000 diff --git a/client/src/components/main/communities/allCommunitiesPage/communityView/index.tsx b/client/src/components/main/communities/allCommunitiesPage/communityView/index.tsx deleted file mode 100644 index 419c47e..0000000 --- a/client/src/components/main/communities/allCommunitiesPage/communityView/index.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { getCommunitiesWithMemberCount } from '../services/communityService'; -import { Community } from '../types/types'; -import './index.css'; - -const CommunityView = () => { - const [communityList, setCommunityList] = useState([]); - - useEffect(() => { - const fetchData = async () => { - try { - const communities = await getCommunitiesWithMemberCount(); - setCommunityList(communities); - } catch (error) { - console.error("Failed to fetch communities:", error); - } - }; - - fetchData(); - }, []); - - return ( -
-

Community List

-
    - {communityList.map((community) => ( -
  • -

    {community.name}

    -

    {community.memberCount} Members

    -
  • - ))} -
-
- ); -}; - -export default CommunityView; diff --git a/client/src/components/main/communities/allCommunitiesPage/index.tsx b/client/src/components/main/communities/allCommunitiesPage/index.tsx index ef85f59..a35afcf 100644 --- a/client/src/components/main/communities/allCommunitiesPage/index.tsx +++ b/client/src/components/main/communities/allCommunitiesPage/index.tsx @@ -1,6 +1,5 @@ import React from 'react'; import './index.css'; -import CommunityView from './communityView'; import useAllCommunitiesPage from '../../../../hooks/communities/useAllCommunitiesPage'; /** From 00b4fdb5733b47120cc7a79b9b8fd11a2e423f0a Mon Sep 17 00:00:00 2001 From: jessicazha0 Date: Sun, 23 Mar 2025 17:30:01 +0100 Subject: [PATCH 024/144] add communities to sidebar --- client/src/components/fakestackoverflow.tsx | 2 ++ client/src/components/main/sideBarNav/index.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/client/src/components/fakestackoverflow.tsx b/client/src/components/fakestackoverflow.tsx index 8701b23..efde8aa 100644 --- a/client/src/components/fakestackoverflow.tsx +++ b/client/src/components/fakestackoverflow.tsx @@ -17,6 +17,7 @@ import UsersListPage from './main/usersListPage'; import ProfileSettings from './profileSettings'; import AllGamesPage from './main/games/allGamesPage'; import GamePage from './main/games/gamePage'; +import AllCommunitiesPage from './main/communities/allCommunitiesPage'; const ProtectedRoute = ({ user, @@ -66,6 +67,7 @@ const FakeStackOverflow = ({ socket }: { socket: FakeSOSocket | null }) => { } /> } /> } /> + } /> } diff --git a/client/src/components/main/sideBarNav/index.tsx b/client/src/components/main/sideBarNav/index.tsx index 9562966..ac3d1cc 100644 --- a/client/src/components/main/sideBarNav/index.tsx +++ b/client/src/components/main/sideBarNav/index.tsx @@ -66,7 +66,7 @@ const SideBarNav = () => { Games `menu_button ${isActive ? 'menu_selected' : ''}`}> Communities From 0f51294892ab16a67c557d86e3cc107faa071ef7 Mon Sep 17 00:00:00 2001 From: jessicazha0 Date: Sun, 23 Mar 2025 17:44:57 +0100 Subject: [PATCH 025/144] addcommunity hook --- .../communities/allCommunitiesPage/index.tsx | 3 +- .../communities/useAllCommunitiesPage.ts | 34 +++++++++++++++++-- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/client/src/components/main/communities/allCommunitiesPage/index.tsx b/client/src/components/main/communities/allCommunitiesPage/index.tsx index a35afcf..5d1f757 100644 --- a/client/src/components/main/communities/allCommunitiesPage/index.tsx +++ b/client/src/components/main/communities/allCommunitiesPage/index.tsx @@ -7,7 +7,8 @@ import useAllCommunitiesPage from '../../../../hooks/communities/useAllCommuniti * and provides functionality to handle community clicks and ask a new question. */ const AllCommunitiesPage = () => { - const { communities, handleCommunityClick, handleJoin } = useAllCommunitiesPage(); + const { communities, handleCommunityClick, handleJoin, handleAddCommunity } = + useAllCommunitiesPage(); return (
diff --git a/client/src/hooks/communities/useAllCommunitiesPage.ts b/client/src/hooks/communities/useAllCommunitiesPage.ts index 6a2b1a1..67eeb99 100644 --- a/client/src/hooks/communities/useAllCommunitiesPage.ts +++ b/client/src/hooks/communities/useAllCommunitiesPage.ts @@ -1,8 +1,8 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import useUserContext from '../useUserContext'; -import { getAllCommunities, addMember } from '../../services/communityService'; -import { PopulatedDatabaseCommunity } from '../../types/types'; +import { getAllCommunities, addMember, addCommunity } from '../../services/communityService'; +import { PopulatedDatabaseCommunity, Community } from '../../types/types'; /** * Custom hook for managing the All Communities page state and interactions. @@ -61,7 +61,7 @@ const useAllCommunitiesPage = () => { * @param communityId - The ID of the community to view. */ const handleCommunityClick = (communityId: string) => { - navigate(`/community/${communityId}`); + navigate(`/communities/${communityId}`); }; /** @@ -91,10 +91,38 @@ const useAllCommunitiesPage = () => { } }; + /** + * Creates a new community. + * + * @param communityData - The new community data (should include at least name, visibility, and optionally description). + */ + const handleAddCommunity = async (communityData: Community) => { + try { + if (!user?.username) { + // eslint-disable-next-line no-console + console.warn('User not logged in. Redirecting to login page...'); + navigate('/login'); + return; + } + + if (!communityData.owner) { + communityData.owner = user.username; + } + + const newCommunity = await addCommunity(communityData); + + setCommunities(prev => [newCommunity, ...prev]); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error adding community:', error); + } + }; + return { communities, handleCommunityClick, handleJoin, + handleAddCommunity, }; }; From 49c81fe66237c08f12f97a1e9407ce0fada7a287 Mon Sep 17 00:00:00 2001 From: saanvi-vutukur <113549288+saanvi-vutukur@users.noreply.github.com> Date: Sun, 23 Mar 2025 17:13:30 -0400 Subject: [PATCH 026/144] addCommunityForm --- .../communities/addCommunityForm/index.css | 65 +++++++++++++++++++ .../communities/addCommunityForm/index.tsx | 45 +++++++++++++ .../communities/allCommunitiesPage/index.tsx | 1 + 3 files changed, 111 insertions(+) create mode 100644 client/src/components/main/communities/addCommunityForm/index.css create mode 100644 client/src/components/main/communities/addCommunityForm/index.tsx diff --git a/client/src/components/main/communities/addCommunityForm/index.css b/client/src/components/main/communities/addCommunityForm/index.css new file mode 100644 index 0000000..4938469 --- /dev/null +++ b/client/src/components/main/communities/addCommunityForm/index.css @@ -0,0 +1,65 @@ +.add-community-form { + background: #ffffff; + border: 1px solid #e0e0e0; + padding: 20px; + border-radius: 12px; + max-width: 400px; + margin: 20px auto; + } + + .add-community-form h2 { + font-size: 1.5rem; + margin-bottom: 1rem; + text-align: center; + } + + .add-community-form label { + display: block; + margin-bottom: 0.5rem; + font-weight: bold; + } + + .add-community-form input, + .add-community-form textarea { + width: 100%; + padding: 8px 10px; + margin-bottom: 1rem; + border: 1px solid #ccc; + border-radius: 8px; + font-size: 1rem; + } + + .add-community-form textarea { + resize: vertical; + min-height: 80px; + } + + .add-community-form .form-actions { + display: flex; + justify-content: space-between; + gap: 10px; + } + + .add-community-form button { + flex: 1; + padding: 10px; + border: none; + border-radius: 8px; + font-size: 1rem; + cursor: pointer; + } + + .add-community-form button.submit { + background-color: #4caf50; + color: white; + } + + .add-community-form button.cancel { + background-color: #f44336; + color: white; + } + + .add-community-form button:hover { + opacity: 0.9; + } + \ No newline at end of file diff --git a/client/src/components/main/communities/addCommunityForm/index.tsx b/client/src/components/main/communities/addCommunityForm/index.tsx new file mode 100644 index 0000000..7576f0f --- /dev/null +++ b/client/src/components/main/communities/addCommunityForm/index.tsx @@ -0,0 +1,45 @@ +import React, { useState } from 'react'; + +interface AddCommunityFormProps { + onSubmit: (data : { name: string; description: string }) => void; + onCancel: () => void; +} + +const AddCommunityForm: React.FC = ({ onSubmit, onCancel }) => { + const [formData, setFormData] = useState({ name: '', description: '' }); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit(formData); + setFormData({ name: '', description: '' }); + }; + return ( +
+ +