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 (
+
+ Name, email, role and group number are required.
+