From 6ba6481b4c1e97c270e6e57b69974370b1b24dac Mon Sep 17 00:00:00 2001 From: jessicazha0 Date: Sat, 15 Mar 2025 01:02:41 -0400 Subject: [PATCH 01/22] 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 02/22] 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 e9bc6ca63555605b9c60d51094d27f8a452df440 Mon Sep 17 00:00:00 2001 From: jessicazha0 Date: Tue, 18 Mar 2025 03:32:15 -0400 Subject: [PATCH 03/22] 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 04/22] 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 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 05/22] 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 06/22] 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 07/22] 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 08/22] 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 09/22] 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 10/22] 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 11/22] 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 ( +
+ +