Our users will probably want to always be paired to meet new people, so we have to avoid repetitive meetings. How should we handle this?
First, we need to allow for a method to set up new meetings. Think of it as a button in an app that would trigger a request to the route POST/meeting/new
.
This endpoint will reply with the status 200
when the request is allowed and a pair is found, or if there is no pair but they are now attached to a meeting
object and can now be matched with another user; 412
if the user is already scheduled in another meeting and 400
in case the expected e-mail of the user isn't sent; in this case, it can't be fulfilled because the user wasn't specified.
The usage of status codes is somewhat subjective, (see a more comprehensive list on Wikipedia at http://en.wikipedia.org/wiki/List_of_HTTP_status_codes). However, having distinct responses is important so that the client can display meaningful messages to the user.
Let's implement an Express.js middleware, that requires an e-mail for all requests that are made on behalf of the user. It should also load their document and attach it to res.locals
, which can be used in subsequent routes.
Our src/routes/index.js
will look like this:
'''javascript //... app.post("/register", register.create); app.post("/meeting", [filter.requireUser], meeting.create); //... ''' The filter in 'src/routes/filter.js' is: '''javascript module.exports = function(Model) { var methods = {}; methods.loadUser = function(req,res,next) { var email = req.query.email || req.body.email if(!email) return res.status(400).send({error: "email missing, it should be either in the body or querystring"}); Model.User.loadByEmail(email, function(err,user) { if(err) return next(err); if(!user) return res.status(400).send({error: "email not associated with an user"}); res.locals.user = user; next(); }) } return methods; };
The goal of this middleware is to stop and return an error message for every request that doesn't have a user email. It's a validation that would usually require a username and password or a secret token.
Let's set up a small but important test suite for this middleware:
Now that we have a way to load the user who's making the request, let's go back to the goal of matching people without repetition. As a pre-condition, their past meeting time has to be in the past already, otherwise it returns a 412
code.
If we want to schedule a meeting for our users but any scheduled meeting will be set for tomorrow, how can we test it? Meet timekeeper (https://github.com/vesln/timekeeper), library with a simple interface to alter the system dates in Node.js; this is especially useful for tests. Look closely for the snippet of this test:
'''javascript describe('Meeting Setup', function() { before(dbCleanup); after(function() { timekeeper.reset(); }); // ... it('should try matching an already matched user', function(done) { request(app) .post('/meeting') .send({email:userRes1.email}) .expect(412, done); }); it('should be able match the user again, 2 days later', function() { var nextNextDay = moment().add(2,'d'), timekeeper.travel(nextNextDay.toDate()); request(app) .post('/meeting') .send({email:userRes1.email}) .expect(200, function(err,res){ done(err); }); });
It's of vital importance to set an after
hook to reset timekeeper so that the dates go back to normal after the scenario is finished in either success or failure; otherwise, there is a chance it will alter the results of other tests. It's also worth checking how date manipulation is made easy with moment()
method and once you use timekeeper.travel() function
, the time is warped to that date. For all Node.js knows, the new warped time is the actual time (although it does not affect any other applications). We can also switch it back and forth as required.
The Meeting
method to perform this check on our user (defined at models/meeting.js
) is as follows:
methods.isUserScheduled = function(user, cb) { Meeting.count({ $or:[ {'user1.email': user.email}, {'user2.email': user.email} ], at: {$gt: new Date()} }, function(err,count) { cb(err, count > 0); }); };
The $or
operator is necessary because we don't know whether the user we are looking for is going to be user1
or user2
, so we take advantage of the query capabilities of MongoDB that can look inside objects in a document and match the email
as a String
, and the at
field as mentioned earlier.
Our newly created src/routes/meeting.js
, is given as follows:
'''javascript module.exports = function(Model) { var methods = {}; methods.create = function(req,res,next) { var user = res.locals.user; Model.Meeting.isUserScheduled(user, function(err,isScheduled) { if(err) return next(err); if(isScheduled) return res.status(412).send({error: "user is already scheduled"}); Model.Meeting.pair(user, function(err,result) { // we don't really expect this function to fail, if that's the case it should be an internal error if(err) return next(err); res.send({}); }) }) } return methods; };
Moving on, we'll define a very important helper function that finds previous meetings involving the user who's making the request and returns the emails of everyone they have been matched with, so we can avoid matching those two users again.
Helper functions like this are super useful to keep the code understandable when dealing with complicated pieces of logic. As a rule of thumb, always separate into smaller functions when a chunk of code can be abstracted into a concept.
/** * the callback returns an array with emails that have previously been * matched with this user */ methods.userMatchHistory = function(user,cb) { var email = user.email; Meeting.find({ $or:[ {'user1.email': email}, {'user2.email': email} ], user1: {$exists: true}, user2: {$exists: true} }, function(err, meetings) { if(err) return cb(err); var pastMatches = meetings.map(function(m) { if( m.user1.email != email) return m.user1.email; else return m.user2.email; }); // avoid matching themselves! pastMatches.push(user.email); cb(null, pastMatches); }) }
The key to userMatchHistory
object; is through the MongoDB $nin
operator, which performs a match when the element doesn't match what's in the array. The matching logic follows the very same logic we had in naive pairing.
In our Meeting
model, we removed our previous pairNaive
method with the pair
method, which does similar, but first build a list of the previous matches to ensure we don't match those again.
methods.pair = function(user,done) { // find the people we shouldn't be matched with again methods.userMatchHistory(user, function(err, emailList) { if(err) return done(err); Meeting.findAndModify({ new: true, query: { user2: { $exists: false }, 'user1.email': {$nin: emailList} }, update: { $set: { user2: user, at: arrangeTime() } } }, function(err, newPair) { if (err) { return done(err); } if (newPair){ return done(null, newPair); } Meeting.insert({user1: user}, function(err,meeting) { done(); }) return; }); }) }