diff --git a/backend/src/db/daos/groupDao.js b/backend/src/db/daos/groupDao.js index b91292636..6d5867943 100644 --- a/backend/src/db/daos/groupDao.js +++ b/backend/src/db/daos/groupDao.js @@ -30,11 +30,61 @@ const createGroup = async (scenarioId, userList) => { notes: new Map(), path: [], scenarioId, + group: String(userList[0]?.group ?? ""), }); await dbGroup.save(); return dbGroup; }; +const addUserToGroup = async (scenarioId, user) => { + const group = String(user.group); + const legacyGroup = await Group.findOneAndUpdate( + { + scenarioId, + group: { $exists: false }, + "users.group": group, + }, + { + $set: { group }, + $push: { users: user }, + }, + { new: true } + ); + + if (legacyGroup) { + return legacyGroup; + } + + const filter = { + scenarioId, + group, + }; + const update = { + $setOnInsert: { + group, + notes: new Map(), + path: [], + scenarioId, + }, + $push: { users: user }, + }; + + try { + return await Group.findOneAndUpdate(filter, update, { + new: true, + upsert: true, + }); + } catch (error) { + if (error.code !== 11000) throw error; + + return Group.findOneAndUpdate( + filter, + { $push: { users: user } }, + { new: true } + ); + } +}; + /** * Sets the state variables for a group * @param {String} groupId MongoDB ID of group @@ -64,6 +114,7 @@ export { getGroup, getCurrentScene, createGroup, + addUserToGroup, getGroupByScenarioId, setGroupStateVariables, }; diff --git a/backend/src/db/models/group.js b/backend/src/db/models/group.js index 35b07dcb4..f48a17f7f 100644 --- a/backend/src/db/models/group.js +++ b/backend/src/db/models/group.js @@ -16,6 +16,9 @@ const groupSchema = new Schema({ scenarioId: { type: String, }, + group: { + type: String, + }, currentFlags: [String], stateVariables: [Schema.Types.Mixed], stateVersion: { @@ -24,6 +27,11 @@ const groupSchema = new Schema({ }, }); +groupSchema.index( + { scenarioId: 1, group: 1 }, + { unique: true, partialFilterExpression: { group: { $exists: true } } } +); + const Group = mongoose.model("Group", groupSchema, "groups"); export default Group; diff --git a/backend/src/routes/api/__tests__/groupApi.test.js b/backend/src/routes/api/__tests__/groupApi.test.js new file mode 100644 index 000000000..63e5b5efa --- /dev/null +++ b/backend/src/routes/api/__tests__/groupApi.test.js @@ -0,0 +1,195 @@ +import { + jest, + describe, + beforeAll, + beforeEach, + afterEach, + afterAll, + it, + expect, +} from "@jest/globals"; + +import { MongoMemoryServer } from "mongodb-memory-server"; +import express from "express"; +import mongoose from "mongoose"; +import axios from "axios"; +import routes from "../../index.js"; +import Scenario from "../../../db/models/scenario.js"; +import Group from "../../../db/models/group.js"; + +jest.mock("firebase-admin"); + +describe("Group API tests", () => { + const HTTP_OK = 200; + const HTTP_BAD_REQUEST = 400; + + let mongoServer; + let server; + let port; + + const scenarioId = new mongoose.mongo.ObjectId("000000000000000000000001"); + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + const uri = mongoServer.getUri(); + + await mongoose.connect(uri); + + const app = express(); + app.use(express.json()); + app.use("/", routes); + + server = app.listen(0); + port = server.address().port; + }); + + beforeEach(async () => { + await Scenario.create({ + _id: scenarioId, + name: "Scenario 1", + uid: "user1", + roleList: ["doctor"], + }); + + await Group.syncIndexes(); + + await Group.create({ + scenarioId: scenarioId.toString(), + group: "1", + users: [ + { + email: "alex@example.com", + name: "Alex", + role: "Doctor", + group: "1", + }, + ], + notes: new Map(), + path: ["scene-a"], + }); + }); + + afterEach(async () => { + await mongoose.connection.db.dropDatabase(); + }); + + afterAll(async () => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + server.closeAllConnections?.(); + }); + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + it("adds a member to an existing group without recreating the group", async () => { + const originalGroup = await Group.findOne({ scenarioId }).lean(); + + const response = await axios.post( + `http://localhost:${port}/api/group/${scenarioId}/member`, + { + email: "sam@example.com", + name: "Sam", + role: "Nurse", + group: "1", + } + ); + + expect(response.status).toBe(HTTP_OK); + + const group = await Group.findById(originalGroup._id).lean(); + expect(group.users).toHaveLength(2); + expect(group.path).toEqual(["scene-a"]); + expect(group.users[1]).toMatchObject({ + email: "sam@example.com", + name: "Sam", + role: "Nurse", + group: "1", + }); + + const scenario = await Scenario.findById(scenarioId).lean(); + expect(scenario.roleList).toEqual(["doctor", "nurse"]); + }); + + it("creates the group when the selected group number does not exist", async () => { + const response = await axios.post( + `http://localhost:${port}/api/group/${scenarioId}/member`, + { + email: "casey@example.com", + name: "Casey", + role: "Observer", + group: "2", + } + ); + + expect(response.status).toBe(HTTP_OK); + + const groups = await Group.find({ scenarioId }).sort({ _id: 1 }).lean(); + expect(groups).toHaveLength(2); + const group = groups.find((group) => group.group === "2"); + expect(group.users).toEqual([ + expect.objectContaining({ + email: "casey@example.com", + name: "Casey", + role: "Observer", + group: "2", + }), + ]); + }); + + it("handles concurrent additions to the same new group", async () => { + await Promise.all([ + axios.post(`http://localhost:${port}/api/group/${scenarioId}/member`, { + email: "casey@example.com", + name: "Casey", + role: "Observer", + group: "2", + }), + axios.post(`http://localhost:${port}/api/group/${scenarioId}/member`, { + email: "sam@example.com", + name: "Sam", + role: "Nurse", + group: "2", + }), + ]); + + const groups = await Group.find({ scenarioId, group: "2" }).lean(); + expect(groups).toHaveLength(1); + expect(groups[0].users).toEqual( + expect.arrayContaining([ + expect.objectContaining({ email: "casey@example.com" }), + expect.objectContaining({ email: "sam@example.com" }), + ]) + ); + }); + + it("rejects duplicate emails in the scenario", async () => { + await expect( + axios.post(`http://localhost:${port}/api/group/${scenarioId}/member`, { + email: "alex@example.com", + name: "Alex 2", + role: "Nurse", + group: "1", + }) + ).rejects.toMatchObject({ + response: { + status: HTTP_BAD_REQUEST, + }, + }); + }); + + it("rejects duplicate roles in the same group", async () => { + await expect( + axios.post(`http://localhost:${port}/api/group/${scenarioId}/member`, { + email: "sam@example.com", + name: "Sam", + role: "doctor", + group: "1", + }) + ).rejects.toMatchObject({ + response: { + status: HTTP_BAD_REQUEST, + }, + }); + }); +}); diff --git a/backend/src/routes/api/group.js b/backend/src/routes/api/group.js index 5376da386..642207c71 100644 --- a/backend/src/routes/api/group.js +++ b/backend/src/routes/api/group.js @@ -1,5 +1,6 @@ import { Router } from "express"; import { + addUserToGroup, createGroup, getCurrentScene, getGroup, @@ -17,6 +18,15 @@ const HTTP_OK = 200; const HTTP_BAD_REQUEST = 400; const HTTP_NOT_FOUND = 404; +const normalizeRole = (role) => role.trim().toLowerCase(); + +const trimUser = ({ email, name, role, group }) => ({ + email: email?.trim(), + name: name?.trim(), + role: role?.trim(), + group: String(group ?? "").trim(), +}); + // get the groups assigned to a scenario router.get("/scenario/:scenarioId", async (req, res) => { try { @@ -52,6 +62,57 @@ router.get("/retrieve/:groupId", async (req, res) => { export default router; router.use("/:scenarioId", validScenarioId); + +router.post("/:scenarioId/member", async (req, res) => { + const { scenarioId } = req.params; + const user = trimUser(req.body); + + if (!user.email || !user.name || !user.role || !user.group) { + return res + .status(HTTP_BAD_REQUEST) + .send("Member must have a name, email, role and group"); + } + + const groups = await getGroupByScenarioId(scenarioId); + const users = groups.flatMap((group) => group.users); + const email = user.email.toLowerCase(); + + if (users.some((member) => member.email?.toLowerCase() === email)) { + return res + .status(HTTP_BAD_REQUEST) + .send("A member with that email already exists in this scenario"); + } + + const targetGroup = groups.find((group) => + group.users.some((member) => String(member.group).trim() === user.group) + ); + + if ( + targetGroup?.users.some( + (member) => normalizeRole(member.role) === normalizeRole(user.role) + ) + ) { + return res + .status(HTTP_BAD_REQUEST) + .send("All students must have different roles in a group"); + } + + const roleList = await retrieveRoleList(scenarioId); + const normalizedRole = normalizeRole(user.role); + const nextRoleList = roleList.some( + (role) => normalizeRole(role) === normalizedRole + ) + ? roleList + : [...roleList, normalizedRole]; + + if (nextRoleList !== roleList) { + await updateRoleList(scenarioId, nextRoleList); + } + + const group = await addUserToGroup(scenarioId, user); + return res.status(HTTP_OK).json(group); +}); + // create a new group router.post("/:scenarioId", async (req, res) => { const { groupList, roleList } = req.body; diff --git a/frontend/src/features/groups/AddGroupMemberModal.jsx b/frontend/src/features/groups/AddGroupMemberModal.jsx new file mode 100644 index 000000000..598355f32 --- /dev/null +++ b/frontend/src/features/groups/AddGroupMemberModal.jsx @@ -0,0 +1,128 @@ +import { useEffect, useMemo, useState } from "react"; +import ModalDialog from "../../components/ModalDialogue"; + +const getInitialMember = (groupNumbers) => ({ + name: "", + email: "", + role: "", + group: groupNumbers[0] ?? "1", +}); + +export default function AddGroupMemberModal({ + open, + onClose, + onSubmit, + groupNumbers, + roles, + isSaving, +}) { + const [member, setMember] = useState(() => getInitialMember(groupNumbers)); + const [submitted, setSubmitted] = useState(false); + + useEffect(() => { + if (open) { + setMember(getInitialMember(groupNumbers)); + setSubmitted(false); + } + }, [groupNumbers, open]); + + const isSubmittable = useMemo( + () => + Boolean( + member.name.trim() && + member.email.trim() && + member.role.trim() && + member.group.trim() + ), + [member] + ); + + function updateMember(field, value) { + setMember((current) => ({ + ...current, + [field]: value, + })); + } + + function handleSubmit() { + setSubmitted(true); + if (!isSubmittable || isSaving) return; + + onSubmit({ + name: member.name.trim(), + email: member.email.trim(), + role: member.role.trim(), + group: member.group.trim(), + }); + } + + return ( + +
+ + updateMember("name", event.target.value)} + /> + + + updateMember("email", event.target.value)} + /> + + + updateMember("role", event.target.value)} + /> + + {roles.map((role) => ( + + + + updateMember("group", event.target.value)} + /> + + {groupNumbers.map((group) => ( + +
+ + {submitted && !isSubmittable ? ( +

+ Name, email, role and group number are required. +

+ ) : null} + +
+ + +
+
+ ); +} diff --git a/frontend/src/features/groups/ManageGroupsPage.jsx b/frontend/src/features/groups/ManageGroupsPage.jsx index 0530406d6..f52de630a 100644 --- a/frontend/src/features/groups/ManageGroupsPage.jsx +++ b/frontend/src/features/groups/ManageGroupsPage.jsx @@ -1,12 +1,14 @@ import axios from "axios"; import Papa from "papaparse"; -import { useContext, useRef } from "react"; +import { useContext, useMemo, useRef, useState } from "react"; import { useParams } from "react-router-dom"; import GroupsTable from "./GroupTable"; +import AddGroupMemberModal from "./AddGroupMemberModal"; import { ArrowLeftIcon, DownloadIcon, FileSpreadsheetIcon, + PlusIcon, UploadIcon, } from "lucide-react"; import toast from "react-hot-toast"; @@ -45,6 +47,8 @@ async function getGroups(user, scenarioId) { export default function ManageGroupsPage() { const { scenarioId } = useParams(); const { user } = useContext(AuthenticationContext); + const [isAddMemberOpen, setIsAddMemberOpen] = useState(false); + const [isAddingMember, setIsAddingMember] = useState(false); const history = useHistory(); @@ -56,6 +60,26 @@ export default function ManageGroupsPage() { queryKey: ["groupData", scenarioId], }); + const users = useMemo(() => data?.flatMap((g) => g.users) ?? [], [data]); + const groupNumbers = useMemo( + () => + [ + ...new Set( + users.map((member) => String(member.group).trim()).filter(Boolean) + ), + ].sort((a, b) => Number(a) - Number(b) || a.localeCompare(b)), + [users] + ); + const roles = useMemo( + () => + [ + ...new Set( + users.map((member) => String(member.role).trim()).filter(Boolean) + ), + ].sort((a, b) => a.localeCompare(b)), + [users] + ); + if (isLoading) { return ; } @@ -65,8 +89,6 @@ export default function ManageGroupsPage() { return ; } - const users = data.flatMap((g) => g.users); - function handleFileUpload(event) { const file = event.target.files[0]; Papa.parse(file, { @@ -147,6 +169,30 @@ export default function ManageGroupsPage() { history.push(`/scenario/${scenarioId}`); } + async function addMember(member) { + setIsAddingMember(true); + + try { + await api.post(user, `/api/group/${scenarioId}/member`, member); + setIsAddMemberOpen(false); + toast.success("Member added successfully!"); + + try { + await refetch(); + } catch (refetchError) { + toast("Member added but failed to refresh list"); + console.error("error refreshing group members:", refetchError); + } + } catch (error) { + toast.error( + error.response?.data || "There was an error adding the member" + ); + console.error("error adding group member:", error.response?.data); + } finally { + setIsAddingMember(false); + } + } + return (
@@ -154,7 +200,14 @@ export default function ManageGroupsPage() { Back - + @@ -178,6 +231,14 @@ export default function ManageGroupsPage() {

Uploaded Groups

+ setIsAddMemberOpen(false)} + onSubmit={addMember} + groupNumbers={groupNumbers} + roles={roles} + isSaving={isAddingMember} + />
); }