MLM web app with Nuxt 3 and Mongoose

MLM web app with Nuxt 3 and Mongoose

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 and ancestorsQueryFields 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 of F be E. Hence the ancestors of F will be [A, B, C, D, E] array, whose length is 5. Now, the indexed level for the ancestor A will be (ancestors.length - index) or (5 - 0) = 5, now the level 5 is the respective level where F should be assigned relative to its ancestor A, or in other words in A's 5th level, F should be assigned as its child. Coming to B, the indexed level for B will be (ancestors.length - index) or (5 - 1) = 4, so in B's 4th level, F should be assigned as its child. For E, its indexed level will be (ancestors.length - index) or (5 - 4) = 1, hence in E's 1st level, F should be assigned as its child. I hope you understood how indexedLevel 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.