Table of contents
Hey there, I am a fellow MEVN stack developer and a freelancer. So one of my clients required a MLM aka Multi-Level Marketing web app. If you are not aware of MLM, well as the name suggests it's a marketing strategy where people are rewarded for bringing in more people i.e. sponsoring other people.
This is not a full-fledged tutorial, this article focuses on the logic for updating different levels and ranks of any user associated with an opinionated MLM structure.
Hang on tight, this might take a while.
Here we go
Ok, enough of this and that, now onto the gist.
I did a lot of research on Google, YouTube and GitHub and didn't find any template-ish website or any other resources related directly to implementing the MLM app with Mongoose, which was related to what my actual app's specifications were, it would have helped me a lot as it was my first time working on such a web app, so I decided to implement it on my own.
MLM can be accurately represented by a tree data structure. Where each node (people) is connected with more nodes (other people), through edges (sponsorships). It may be a bit controversial, but I chose MongoDB as my database of choice for this web app, as it's easy to represent a hierarchical data structure through a NoSQL database. I bumped into MongoDB docs on how to model tree data structure. For my specific use case, I had to use Child References, as well as Array of Ancestors.
The Mongoose Schema
This is the schema that I came up with for my specific use case
rank: {
type: Number,
default: 1,
},
ancestors: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'Users'
}],
levels: [{
levelNo: {
type: Number,
default: 0
},
referrals: [{
commission: {
type: Number,
default: 0,
},
userRef: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Users'
},
}]
}]
The sponsorships of any user are divided into different levels
. Based on a minimum number of people in a level, the sponsorer aka the user that is sponsoring other people will be assigned a rank
and based on that rank
, rewards will be granted to that user.
The Logic
The code snippet below is the entire logic that I implemented. The code below is a part of the Nuxt 3 Nitro request handler. There are a couple of things that may seem intimidating at first, but don't worry I will explain them further in the article.
// validating the sponsorer
const sponsorerQueryFields = ['active', 'ancestors', 'referralId'];
const sponsorer = await User.findOne({ referralId: body.sponsorerId }, sponsorerQueryFields);
if(!sponsorer) {
return createError({
statusCode: 404,
statusMessage: 'Sponsorer not found'
})
}
// creating new user
const newUserInfo = {
name: body.fullname,
email: body.email,
password: await hash(
body.password, parseInt(process.env.PWD_SALT)
),
ancestors: [...sponsorer.ancestors, `${sponsorer._id}`].sort()
}
const newUser = await User.create(newUserInfo);
// for each ancestor
const ancestorsQueryFields = ['referralId', 'role', 'levels', 'rank'];
const ancestors = await User.find({ _id: { $in: newUser.ancestors } }, ancestorsQueryFields);
const updateOperations = ancestors.map((ancestor, index) => {
const indexedLevel = ancestors.length - index;
if(ancestor.role !== 'admin' && indexedLevel > 15) {
return null;
}
const incentive = getIncentive(indexedLevel)
// inserting new member
const foundLevelIndex = ancestor.levels.findIndex(level => level.levelNo === indexedLevel);
if(foundLevelIndex !== -1) {
ancestor.levels[foundLevelIndex].referrals.push({
commission: incentive,
userRef: newUser._id
})
} else {
ancestor.levels.push({
levelNo: indexedLevel,
referrals: {
commission: incentive,
userRef: newUser._id
}
})
}
// setting rank
let tempRank = 1;
for(const level of ancestor.levels) {
if(!isRankValid(level.levelNo, level.referrals.length)) {
break;
}
tempRank = level.levelNo === 0 ? 1 : level.levelNo
}
ancestor.rank = tempRank;
return {
updateOne: {
filter: { _id: ancestor._id },
update: {
$set: {
levels: ancestor.levels,
rank: ancestor.rank
}
}
}
};
}).filter(i => i !== null);
await User.bulkWrite(updateOperations)
If you are wondering what
sponsorerQueryFields
andancestorsQueryFields
are in the above code snippet, well both of them are filters for the fields of the document that I need in a query, so I don't need to query every field.
Explanation
So first of all, I am validating the sponsorer, whether he/she exists or not.
const sponsorerQueryFields = ['active', 'ancestors', 'referralId'];
const sponsorer = await User.findOne({ referralId: body.sponsorerId }, sponsorerQueryFields);
if(!sponsorer) {
return createError({
statusCode: 404,
statusMessage: 'Sponsorer not found'
})
}
After validating the sponsorer, I am creating a new user. Notice that the newly created user has its ancestors set to the ancestors of its sponsorer and, and the sponsorer itself is also an ancestor of the new user. I am sorting that array of strings because MongoDB _id
can be sorted in ascending order (based on the time when the document was created), to preserve the original hierarchy of ancestors.
const newUserInfo = {
name: body.fullname,
email: body.email,
password: await hash(
body.password, parseInt(process.env.PWD_SALT)
),
ancestors: [...sponsorer.ancestors, `${sponsorer._id}`].sort()
}
const newUser = await User.create(newUserInfo);
Now, I am querying the ancestors of the newly created user for inserting the newly created user in that user's respective ancestor levels, because when a new user joins, then the levels of all the ancestors of that new user are affected (for different rewards eligibility).
const ancestorsQueryFields = ['referralId', 'role', 'levels', 'rank'];
const ancestors = await User.find({ _id: { $in: newUser.ancestors } }, ancestorsQueryFields);
The ancestors
are being mapped over to form updateOperations
array, which is the most important part where all the magic happens. You might notice that there's a filter at the end, I will explain later why that filter is present. So I will be deep diving into this map callback function block by block. The code snippets underneath this very code snippet are all inside the map callback function.
const updateOperations = ancestors.map((ancestor, index) => {
// ...stuff going on here explained below
}).filter(i => i !== null);
Firstly, we are looping over all the ancestors of that newly created user, in order to assign the newly created user to their respective ancestor levels. The following approach that I took fiddles with array indexes to find out the respective level of the ancestor to which the new user should be assigned.
indexedLevel
is the level of a particular ancestor to which that newly created user will be assigned. This indexedLevel
might seem to come out of the blue, but it's quite simple.
const indexedLevel = ancestors.length - index;
Here's an example to better understand
indexedLevel
:Let the new joiner or new user be
F
, going by the alphabetical order, let the sponsorer ofF
beE
. Hence the ancestors ofF
will be[A, B, C, D, E]
array, whose length is5
. Now, the indexed level for the ancestorA
will be (ancestors.length - index) or (5 - 0) = 5, now thelevel 5
is the respective level whereF
should be assigned relative to its ancestorA
, or in other words inA
's5th level
,F
should be assigned as its child. Coming toB
, the indexed level for B will be (ancestors.length - index) or (5 - 1) = 4, so inB
's4th level
,F
should be assigned as its child. ForE
, its indexed level will be (ancestors.length - index) or (5 - 4) = 1, hence inE
's1st level
,F
should be assigned as its child. I hope you understood howindexedLevel
works.
There was a criterion that normal members (non-admin) would only be able to sponsor members until level 15, so if any member tries to sponsor someone to level 16, that iteration would be marked as null
and in the end, I am filtering the mapped array for values that are !null
, to not cause any errors while bulk writing the updateOperations
.
if(ancestor.role !== 'admin' && indexedLevel > 15) {
return null;
}
Here, I am finding out the incentive or commission of a particular ancestor according to their indexedLevel
, which they will receive for the new user's joining.
const incentive = getIncentive(indexedLevel);
Here I am checking if the indexedLevel
of an ancestor is already present within its levels
array or not, if present, then I am simply pushing the new user to that level. If not, then I am pushing a new level with the new user.
// inserting new member
const foundLevelIndex = ancestor.levels.findIndex(level => level.levelNo === indexedLevel);
if(foundLevelIndex !== -1) {
ancestor.levels[foundLevelIndex].referrals.push({
commission: incentive,
userRef: newUser._id
})
} else {
ancestor.levels.push({
levelNo: indexedLevel,
referrals: {
commission: incentive,
userRef: newUser._id
}
})
}
In this snippet, I am looping over all the levels of an ancestor and setting the rank
based on the minimum number of members in a particular level. What the isRankValid()
function does is that it takes the levelNo
and the members of that level i.e. level.referrals.length
as arguments and inside the function, it first calculates the minimum number of members for the passed-in argument levelNo
and if the actual members passed in the 2nd argument i.e. level.referrals.length
is greater than equals to (>=) the minimum level, then that rankedLevel
is valid. If it's not valid, then it breaks the loop and doesn't check further.
// setting rank
let tempRank = 1;
for(const level of ancestor.levels) {
if(!isRankValid(level.levelNo, level.referrals.length)) {
break;
}
tempRank = level.levelNo === 0 ? 1 : level.levelNo
}
ancestor.rank = tempRank;
The object that is returned, contains an updateOne
query with its filter
and $set
for updating the necessary fields.
return {
updateOne: {
filter: { _id: ancestor._id },
update: {
$set: {
levels: ancestor.levels,
rank: ancestor.rank
}
}
}
};
So we have finally come to the end of the ancestors.map()
function, now all the updateOne
objects are present within updateOperations
. All that's left to do is bulkWrite
those operations.
await User.bulkWrite(updateOperations)
Conclusion
At first, I thought of a very inefficient solution of querying every ancestor (performs a network call), and then updating the necessary data, which will further perform another network call. So if there were say 10 users, then 20 network calls would be performed for querying and updating the data of each of the 10 users. I tried my best to reduce the number of network calls and make the logic efficient, which is exactly the code snippet that I explained in this article. In this code snippet, for any number of users, only 2 network calls are performed, 1 for fetching all the users and another 1 for updating all the users using bulkWrite
. As this is my first article I will take any reviews or feedback, I hope you liked it. If you have any solutions better than mine or just want to say 'Hi', feel free to contact me on Twitter. Thank you for reading, I hope you have a nice day.