Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions backend/src/db/daos/groupDao.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
);
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Sets the state variables for a group
* @param {String} groupId MongoDB ID of group
Expand Down Expand Up @@ -64,6 +114,7 @@ export {
getGroup,
getCurrentScene,
createGroup,
addUserToGroup,
getGroupByScenarioId,
setGroupStateVariables,
};
8 changes: 8 additions & 0 deletions backend/src/db/models/group.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ const groupSchema = new Schema({
scenarioId: {
type: String,
},
group: {
type: String,
},
Comment on lines +19 to +21

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i dont quite understand this field and the logic associated with it . what does the "group" of the group actually mean?

currentFlags: [String],
stateVariables: [Schema.Types.Mixed],
stateVersion: {
Expand All @@ -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;
195 changes: 195 additions & 0 deletions backend/src/routes/api/__tests__/groupApi.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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,
},
});
});
});
61 changes: 61 additions & 0 deletions backend/src/routes/api/group.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Router } from "express";
import {
addUserToGroup,
createGroup,
getCurrentScene,
getGroup,
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading