Follow Unfollow User Approach using the MEAN Stack
It is not uncommon to have the follow unfollow user model on many social media platforms. User A follows B. User A can list all his/her followers. User B is also able to determine how many people are following him/her.
What is one approach to get that going using the MEAN (MongoDB, Express, Angular 5, Node) Stack?
Let’s take it step by step.
In the end, we’re looking at a result like this:
MongoDB
Honestly, I’m not gonna tell you how to install MongoDB. I have mine installed. See their documentation to install their latest.
We’re here for logics, not installation steps, or?
Schemas Involved
First, here’s how the example below structures the schema and I explain the reason next.
For a successful follow/unfollow approach in this example, we’re gonna need 3 models:
- User Model
- Account Model
- Follow Model
Our User Model will have, well, User stuff, such as username, hash_password, etc.
The Account Model will be an extension of the User Model, which will have extra details such as location, bio, telephone, etc.
NOTE: The reason for the separation of the User Model from the Account Model is the issue related to hiding sensitive data like the
hash_password
from the User Model during$lookup
queries.I couldn’t find a way to omit the
hash_password
from$lookup
queries, so I thought of it wise to separate any extra data into an account model, therefore the User model is only touched for Auth-related functionalities, whereas the Account deals with all user profile related functionalities.
Lastly, the Follow Model will be dedicated to holding the relationships between who follows who.
Therefore, User Model:
let mongoose = require('mongoose'); let bcrypt = require('bcrypt'); let Schema = mongoose.Schema; let UserSchema = new Schema({ username: { type: String, unique: true, lowercase: true, trim: true, required: true, minlength: 3 }, email: { type: String, unique: true, lowercase: true, trim: true, required: true }, hash_password: { type: String, required: true, }, // Other fields can be down here. Cut short for brevity }); UserSchema.methods.comparePassword = function(password) { return bcrypt.compareSync(password, this.hash_password); }; module.exports = mongoose.model('User', UserSchema);
Then our Account Model
let mongoose = require('mongoose'); let Schema = mongoose.Schema; let AccountSchema = new Schema({ user: { type: Schema.Types.ObjectId, ref: 'User' }, username: { type: String, }, fullName: { type: String, trim: true, }, // Other fields can be here. Cut short for brevity }); module.exports = mongoose.model('Account', AccountSchema);
And finally, our Follow Model
let mongoose = require('mongoose'); let Schema = mongoose.Schema; let FollowSchema = new Schema({ user: { type: Schema.Types.ObjectId, ref: 'User' }, followers: [{ type: Schema.Types.ObjectId, ref: 'Card' }], following: [{ type: Schema.Types.ObjectId, ref: 'Card' }] }, { toJSON: { virtuals: true } } ); module.exports = mongoose.model('Follow', FollowSchema);
The ref
is to our cardModel
which is the cards
collection. The Card
model isn’t relevant to our follow unfollow approach, so will omit that for now.
The next part explains how we save the follow unfollow relationship into our Follow
collection with the above schemas in mind.
Express-cum-Mongoose
In Express, here’s the logic.
router.post('/follow', function(req, res) { const user_id = req.user._id; const follow = req.body.follow_id; let bulk = Follow.collection.initializeUnorderedBulkOp(); bulk.find({ 'user': Types.ObjectId(user_id) }).upsert().updateOne({ $addToSet: { following: Types.ObjectId(follow) } }); bulk.find({ 'user': Types.ObjectId(follow) }).upsert().updateOne({ $addToSet: { followers: Types.ObjectId(user_id) } }) bulk.execute(function(err, doc) { if (err) { return res.json({ 'state': false, 'msg': err }) } res.json({ 'state': true, 'msg': 'Followed' }) }) })
This is an endpoint for following. Here’s what’s happening in general.
- When user A follows user B, and user B follows A, and user C follows A and B, here’s what happens to user A’s follow object:
A’s follow Object
{ "_id" : ObjectId("59e3e27d..."), "user" : ObjectId("User A"), "followers" : [ ObjectId("User B") ObjectId("User C") ], "following": [ ObjectId("User B") ] }
B’s follow Object
{ "_id" : ObjectId("59e3e27d..."), "user" : ObjectId("User B"), "followers" : [ ObjectId("User A") ObjectId("User C") ], "following": [ ObjectId("User A") ] }
C’s follow Object
{ "_id" : ObjectId("59e3e27dace..."), "user" : ObjectId("User C"), "followers" : [ ], "following": [ ObjectId("User B") ObjectId("User A") ] }
Did I get the above structure right? I guess I’m right.
To get a similar structure above, we make this bulk commands:
router.post('/follow', function(req, res) { const user_id = req.user._id; const to_follow_id = req.body.follow_id; let bulk = Follow.collection.initializeUnorderedBulkOp(); bulk.find({ 'user': Types.ObjectId(user_id) }).upsert().updateOne({ $addToSet: { following: Types.ObjectId(to_follow_id) } }); bulk.find({ 'user': Types.ObjectId(to_follow_id) }).upsert().updateOne({ $addToSet: { followers: Types.ObjectId(user_id) } }) bulk.execute(function(err, doc) { if (err) { return res.json({ 'state': false, 'msg': err }) } res.json({ 'state': true, 'msg': 'Followed' }) }) })
We’re simply
- updating both users involved at a time in a bulk transaction.
- We add user A to user B’s followers and B to user A’s following
Kinda weird to think of it, but we’re simply adding Alice to Bob’s list of followers, and adding Bob to Alice’s list of those following.
If we want to reverse the process, as in, let A unfollow B, here’s the command to get that done:
router.post('/unfollow', function(req, res) { const user_id = req.user._id; const to_follow_id = req.body.follow_id; let bulk = Follow.collection.initializeUnorderedBulkOp(); bulk.find({ 'user': Types.ObjectId(user_id) }).upsert().updateOne({ $pull: { following: Types.ObjectId(to_follow_id) } }); bulk.find({ 'user': Types.ObjectId(to_follow_id) }).upsert().updateOne({ $pull: { followers: Types.ObjectId(user_id) } }) bulk.execute(function(err, doc) { if (err) { return res.json({ 'state': false, 'msg': err }) } res.json({ 'state': true, 'msg': 'Unfollowed' }) }) })
Querying
Now, we would want to retrieve ALL those user A is both following and those following user A (followers).
We can do that in one single scoop of an aggregate command via:
router.get('/follow/list', function(req, res) { const username = req.query.username; User.findOne({ 'username': username }, function(err, user) { if (!user) { return res.json({ 'state': false, 'msg': `No user found with username ${username}` }) } else { const user_id = user._id; Follow.aggregate([{ $match: { "user": Types.ObjectId(user_id) } }, { $lookup: { "from": "accounts", "localField": "following", "foreignField": "user", "as": "userFollowing" } }, { $lookup: { "from": "accounts", "localField": "followers", "foreignField": "user", "as": "userFollowers" } }, { $project: { "user": 1, "userFollowers": 1, "userFollowing": 1 } } ]).exec(function(err, doc) { res.json({ 'state': true, 'msg': 'Follow list', 'doc': doc }) }) } }) });
The above command returns: (Note: the 1
as options for the field name is there to indicate we want the value from that field NOT suppressed.)
"user": 1,
which is the user we want to load the cards from those following and followers"userFollowers": 1,
which is account profile of every user following this user"userFollowing": 1
which is account profile of every user followed by this user
Tie Together with Client
Now to the Angular part.
Here’s the UI flow:
If we want to load all followers/following of a user, we only make this request, and process the response as such in our component:
loadFollow(username: string) { this.account.follow_list(username) .subscribe((res) => { if(res.json().state) { let response = res.json().doc; let followers = []; let following = []; response.forEach(function(obj){ followers = followers.concat(obj.userFollowers); following = following.concat(obj.userFollowing); }); this.all_user_following = followers; this.all_user_followers = following; } else { this.toastr.info(res.json().msg, 'Something is wrong'); this.msg = res.json().msg; } }) }
Then we iterate in our component as such:
<div *ngFor="let user of all_user_followers"> <div class="card"> <div class="card-body"> <h4 class="card-title my-0">{{ user.fullName }}</h4> <p class="text-muted my-0"><a [routerLink]="['/account', user.username]">@{{ user.username }}</a></p> <p class="card-text my-0">{{ user.bio || 'No bio info'}}</p> </div> </div> </div>
The above shows the part of all people following this username
This part shows all users this username
is following:
<div *ngFor="let user of all_user_following"> <div class="card"> <div class="card-body"> <h4 class="card-title my-0">{{ user.fullName }}</h4> <p class="text-muted my-0"><a [routerLink]="['/account', user.username]">@{{ user.username }}</a></p> <p class="card-text my-0">{{ user.bio || 'No bio info'}}</p> </div> </div> </div>
Follow Unfollow button
There’s no magic to what’s happening behind the scene.
It is purely this CSS response to Follow/Unfollow button tweaked for the UI above. Nothing else.
Conclusion
This is the approach I took for the follow unfollow for the KhoPhi application. You can check the application out to see how the follow unfollow button works.
This article contains, hopefully more than enough information to help you get a similar or even better results with your follow unfollow approach.