This is actually the last major piece in the puzzle. I have been focused on one-one chat all along and as I have finally completed that, I took on the group chat. It needed to have all the features a group chat would have minus the group video call - it could be implemented but too many possible contingencies.
I decided to build the backend first which I think gives me this kind of assurance that most of the issues I'll get after completing the backend will be frontend related and it will help narrow my focus. It would also potentially reduce the way I shuffle between the frontend and backend while working which could save me ample amount of time.
I defined the Group Schema, the routes (which were a lot of posts) and controllers. I only have three collections in my database (Users, Groups and Messages). The messages is a single collection that contains both 1-1 and group messages.
GROUP SCHEMA
const groupSchema = new Schema({
name: {type: String, required: true},
members: [{type: Schema.Types.ObjectId, required: true, ref: 'Users'}],
creator: {type:Schema.Types.ObjectId, required: true },
admins:[{type:Schema.Types.ObjectId, required: true}],
}, {
timestamps: true
})
Nothing much going on here. I could have opted to have the group messages as an array in each group but I didn't. I only stopped at having members of the group as an array only. Then the creator, and admins.
GROUP CHAT FEATURES
- Create Group
module.exports.createGroup = async(req, res)=> {
let {name, creator, members} = req.body
members.push(creator)
if (name && creator && members) {
let newGroup = new groupModel({
name,
creator,
members:members,
admins: creator
})
newGroup.save().then((message)=> {
res.status(201).json(`Successfully created the group ${message}`)
})
.catch(err=> res.status(400).json({message: err.message, members: members}))
}
else {
res.status(400).json ({message: "some fields are empty"})
}
}
Obviously, we need to be able to create group. It takes in the name of the group, the creator which is our userID and then the members of which is an array of members that have been updated to include the creator. The admins is initially solely set to the creator.
- Update Group
module.exports.updateGroup = async(req,res)=> {
const {userId, name, groupId} = req.body
if (userId && name, groupId) {
const isAdmin = await groupModel.find({_id: groupId,
admins: userId
})
if (isAdmin && isAdmin.length) {
groupModel.findByIdAndUpdate(groupId, {name})
.then((result)=> res.status(200).json(`Successfully Updated ${result}`))
.catch((err)=> res.status(400).json({message: err.message}))
}
else {
res.status(401).json("Unauthorized")
}
}
else {
res.status(400).json("Empty fields")
}
}
We are only updating the name of the group for now, so it takes in the name. It also takes it the userID to check if it is contained in the admin field before authorising the update. The GroupID is also important to know which of the groups we would like to update.
- Add a New Group Message
module.exports.addGroupMessage = async (req, res)=> {
const {groupId, from, message, userId} = req.body
if (groupId && from && message && userId) {
const isMember = await groupModel.find({
_id: groupId,
members: userId
})
if (isMember && isMember.length) {
let newMessage = new messageModel ({
groupId : groupId,
sender: from,
message,
})
newMessage.save()
.then((message)=> {
res.status(201).json(`Successfully created ${message}`)
})
.catch((err)=>{
res.status(404).json({message: `An error occured ${err.message}`})
})
}
else {
res.status(401).json({message: "You are not a member of this group"})
}
}
else {
res.status(400).json("Empty fields")
}
}
I know this is not being really validated but its not the crux of this piece. Basically, we are creating a new message model with membership rights. Get the userId, check if it is contained in the members of the groupId before creating a new message with the GroupId as one of the message fields.
- Get Group Messages
module.exports.messages = async (req,res)=> {
const {groupId, userId} = req.body
if (groupId && userId){
const isMember = await groupModel.find({
_id: groupId,
members: userId
})
if (isMember && isMember.length){
let messages = await messageModel.find({
groupId: groupId
})
messages = await messageModel.populate(messages, {path: "sender", select: "nickname avatarImage"})
messages = messages.map((msg)=> {
return {
fromSelf: msg.sender._id.toString() === userId,
message: msg.message,
details: msg.sender
}
})
res.status(200).json(messages)
}
else {
res.status(401).json({message: "Not a member"})
}
}
else {
res.status(400).json({message: "Empty fields"})
}
}
We are performing quite a number of operations here. We only need the UserId and the groupID to check if the user is a member of that group. Then we populate the 'sender' path with the nickname and avatar of that user. Kindly note that for the populate method to work, the path to be populated need to reference another model. In this case, my message model has a 'sender' field that references the 'Users' model.
const messageSchema = new Schema ({
message: {type: String, required: true},
users: {type: Array, required:true, default: null}, groupId: {type: Schema.Types.ObjectId, ref: "Groups", default: null},
sender: {type: Schema.Types.ObjectId, ref: "Users", required: true},
to: {type: Schema.Types.ObjectId, ref: "Users", default: null},
}
After populating, we then modify our result by returning a new map of the result that contains an added field. The field 'fromSelf' returns true if the sender of that message is equal to the userId. This will help us when we get to our frontend to identify and style our own messages differently.
- Add new Admin with Admin rights
module.exports.addAdmins = async(req,res)=> {
const {groupId, admins, userId} = req.body
if (userId && groupId && admins) {
const isCreator = await groupModel.find({_id: groupId,
creator: userId
})
if (isCreator && isCreator.length) {
groupModel.updateOne({_id: groupId}, {
$push: {admins}
}).then((result)=> res.status(200).json(result))
.catch(err=> res.status(404).json({message: err.message}))
}
else {
res.status(401).json("Unauthorized")
}
}
else {
res.status(400).json({message: "Empty Fields"})
}
}
We are trying to make sure that only the creator is able to add new admins. The 'admins' is a new array that gets pushed into the admin array field.
- Remove Admins with Creator rights
module.exports.removeAdmins = async(req, res)=> {
const {userId, admins, groupId} = req.body
if (userId && admins) {
const isCreator = await groupModel.find({_id: groupId,
creator: userId
})
if (isCreator && isCreator.length) {
groupModel.updateOne({_id: groupId}, {
$pull: {admins: {$in: admins}}
}).then((result)=> {
res.status(200).json({result})
}).catch(err=> res.status(400).json({message: err.message}))
}
else {
res.status(401).json({message: "Unauthorized"})
}
}
else {
res.status(400).json({message: "Empty fields"})
}
}
We use the Mongodb Update one function to pull admins from the admins field. This only works only if the user executing this call is the creator.
- Add Members with Admin rights
module.exports.addMembers = async(req, res)=> {
const {groupId, members, userId} = req.body
if (userId && groupId && members ) {
const isAdmin = await groupModel.find({
_id: groupId,
admins: userId
})
if (isAdmin && isAdmin.length) {
groupModel.updateOne({_id: groupId}, {
$push: {members}
})
.then((result)=> res.status(200).json(`Successfully Updated ${result}`))
.catch((err)=> res.status(400).json({message: err.message}))
}
else {
res.status(401).json({message: "Unauthorized"})
}
}
else {
res.status(400).json({message: "Empty fields"})
}
}
- Remove Members with Admin rights
module.exports.removeMembers = async(req, res)=> {
const {groupId, userId, members} = req.body
if (groupId && id && members) {
const isAdmin = await groupModel.find({
_id: groupId,
admins: userId
})
if (isAdmin && isAdmin.length) {
try{
const updatedMembers = await groupModel.updateOne({_id: groupId}, {
$pull: {members: {$in: members}}
})
res.status(200).json(updatedMembers)
}
catch(err) {
res.status(400).json({message: err.message})
}
}
else {
res.status(401).json({message: "Not authorized"})
}
}
else {
res.status(400).json({message: "Empty fields"})
}
}
- Get User Groups along with the groups last message to be displayed in Chat
module.exports.getGroups = async (req,res)=> {
const { userId} = req.body
if (userId) {
let groups = await groupModel.find({
members: userId
})
if (groups && groups.length){
const projectedGroupMsgs = await Promise.all(groups.map( async (group)=> {
let messages= await messageModel.find({
groupId: group._id
})
if (messages && messages.length) {
return await messageModel.aggregate([
{$match: {groupId: ObjectId(group._id)}},
{$sort: {
"timeStamp": -1
}},
{
$group: {
_id: "$groupId",
"name": {
"$first": `${group.name}`
},
"lastMessage": {
"$last": "$message"
},
"messages": {
"$push" : "$message"
},
"groupId": {
"$first": "$groupId"
}
}
}
])
}
else {
return await groupModel.aggregate([
{$match: {_id: ObjectId(group._id)}},
{
"$project": {
_id: 1,
name: 1,
}
},
])
}
}))
return res.status(200).json(projectedGroupMsgs)
}
else {
res.status(404).json({message: "User does not belong to any group"})
}
}
else {
res.status(400).json({message: "Empty fields"})
}
}
We only need the userId but we are performing a lot of operations. First we need to check for the groups that the user belongs to any group, map through those groups and perform an aggregation. Before that, we need to check if the group contains any messages, if it does we aggregate the message model, match the messages that contains the groupId of each of those mapped groups and group those messages by the groupId.
Also worthy to note that we sneaked in a field that contains the group name coming from our map.
If the group does not contain any message, instead of returning an empty array without either of our group name and the messages. We just return the aggregated group model instead with just the name and id fields projected.
*** I included the messages array in order to use the search functionality to search through messages and get the results on our chat page.
And that's that. I have started the frontend part of the chat group. Here's a little sneak peak. Follow for more❤️ Information: Cover designed on Canva