Meteor Methods

Note: If you skipped ahead to this section, click here to download a zip of the tutorial at this point.

In this step we will learn how to use Meteor methods and how to use Meteor.call method from our AngularJS code.

Meteor methods are a way to perform more complex logic than the direct Mongo.Collection API. The Meteor methods are also responsible for checking permissions, just like the allow method does.

In our case, we will create an invite method that invites a user to a party.

Create a new file under imports/api/parties called methods.js and paste the following code into it:

14.2 Create invite method imports/api/parties/methods.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import _ from 'underscore';
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
 
import { Parties } from './collection';
 
function getContactEmail(user) {
  if (user.emails && user.emails.length)
    return user.emails[0].address;
 
  if (user.services && user.services.facebook && user.services.facebook.email)
    return user.services.facebook.email;
 
  return null;
}
 
export function invite(partyId, userId) {
  check(partyId, String);
  check(userId, String);
 
  if (!this.userId) {
    throw new Meteor.Error(400, 'You have to be logged in!');
  }
 
  const party = Parties.findOne(partyId);
 
  if (!party) {
    throw new Meteor.Error(404, 'No such party!');
  }
 
  if (party.owner !== this.userId) {
    throw new Meteor.Error(404, 'No permissions!');
  }
 
  if (party.public) {
    throw new Meteor.Error(400, 'That party is public. No need to invite people.');
  }
 
  if (userId !== party.owner && ! _.contains(party.invited, userId)) {
    Parties.update(partyId, {
      $addToSet: {
        invited: userId
      }
    });
 
    const replyTo = getContactEmail(Meteor.users.findOne(this.userId));
    const to = getContactEmail(Meteor.users.findOne(userId));
 
    if (Meteor.isServer && to) {
      Email.send({
        to,
        replyTo,
        from: [email protected]',
        subject: `PARTY: ${party.title}`,
        text: `
          Hey, I just invited you to ${party.title} on Socially.
          Come check it out: ${Meteor.absoluteUrl()}
        `
      });
    }
  }
}
 
Meteor.methods({
  invite
});

We have to import it in the index.js

14.3 Import methods imports/api/parties/index.js
1
2
3
4
import './publish';
import './methods';
 
export * from './collection';

Let's look at the code.

First, all Meteor methods are defined inside Meteor.methods({}); object.

Each property of that object is a method and the name of that property in the name of the method. In our case - invite.

Then the value of the property is the function we call. In our case it takes 2 parameters - the party id and the invited user id.

As you can see, invite function is exported. It's just to make testing easier.

First, we check validation with the the check function.

To use check we need to add the check package:

meteor add check

The rest of the code is pretty much self explanatory, but important thing to notice is the Email function that sends email to the invited client. This function can't be called from the client side so we have to put it inside an isServer statement.

Don't forget to add the email package to your project in the command line:

meteor add email

And import Email object from its module:

14.5 Import email from module imports/api/parties/methods.js
1
2
3
4
5
6
7
import _ from 'underscore';
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import { Email } from 'meteor/email';
 
import { Parties } from './collection';
 

Now let's call that method from the client.

Add a method to the component called PartyUninvited:

14.6 Add invite method to PartyUninvited imports/ui/components/partyUninvited/partyUninvited.js
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
      }
    });
  }
 
  invite(user) {
    Meteor.call('invite', this.party._id, user._id,
      (error) => {
        if (error) {
          console.log('Oops, unable to invite!');
        } else {
          console.log('Invited!');
        }
      }
    );
  }
}
 
const name = 'partyUninvited';

We just used a regular Meteor API to call a method, inside our component.

Note that we also used another parameter, a callback function that called when Meteor is done with our method.

The callback have 2 parameters:

  • Parameter 1 - error - which is undefined when the call succeeded.
  • Parameter 2 - result - which is the return value from the server method.

Now let's add a button to invite each user we want. Edit the users list to look like this:

2
3
4
5
6
7
  Users to invite:
  <li ng-repeat="user in partyUninvited.users | uninvitedFilter:partyUninvited.party">
    <div>{{ user | displayNameFilter }}</div>
    <button ng-click="partyUninvited.invite(user)">Invite</button>
  </li>
</ul>

Now that we have the invite function working, we also want to publish the parties to the invited users. Let's add that permission to the publish parties method:

14.8 Update the parties subscription to include invited imports/api/parties/publish.js
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
            $exists: true
          }
        }]
      }, {
        // when logged in user is one of invited
        $and: [{
          invited: this.userId
        }, {
          invited: {
            $exists: true
          }
        }]
      }]
    };
 

Serve Email

If you would like test email functionality locally with your own GMail account, create a new file called located in server/startup/environments.js, and add the following lines substituting [YOUR_EMAIL] and [YOUR_PASSWORD]:

Meteor.startup(function () {
    process.env.MAIL_URL="smtp://[YOUR_EMAIL]@gmail.com:[YOUR_PASSWORD]@smtp.gmail.com:465/";
})

You may need to set your GMail account to use Less Secure Apps. Once it's done, you can use Meteor's emailing package which can be installed by typing the following command:

$ meteor add email

For development, setting your own email explicitly is a good practice because it's quick and easy. However, you don't want to set your email account in production mode, since everyone can see it. A recommended solution would be using an emailing service like EmailJS. More information about EmailJS can be found here.

Great!

Now test the app. Create a private party with user1. Then invite user2. Log in as user2 and check if he can see the party in his own parties list.

Now let's add the RSVP functionality so invited users can respond to invitations.

First let's add a Meteor.method to methods.js in the parties folder (remember to place it as a property inside the Meteor.methods object):

14.9 Add rsvp method imports/api/parties/methods.js
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
  }
}
 
export function rsvp(partyId, rsvp) {
  check(partyId, String);
  check(rsvp, String);
 
  if (!this.userId) {
    throw new Meteor.Error(403, 'You must be logged in to RSVP');
  }
 
  if (!_.contains(['yes', 'no', 'maybe'], rsvp)) {
    throw new Meteor.Error(400, 'Invalid RSVP');
  }
 
  const party = Parties.findOne({
    _id: partyId,
    $or: [{
      // is public
      $and: [{
        public: true
      }, {
        public: {
          $exists: true
        }
      }]
    },{
      // is owner
      $and: [{
        owner: this.userId
      }, {
        owner: {
          $exists: true
        }
      }]
    }, {
      // is invited
      $and: [{
        invited: this.userId
      }, {
        invited: {
          $exists: true
        }
      }]
    }]
  });
 
  if (!party) {
    throw new Meteor.Error(404, 'No such party');
  }
 
  const hasUserRsvp = _.findWhere(party.rsvps, {
    user: this.userId
  });
 
  if (!hasUserRsvp) {
    // add new rsvp entry
    Parties.update(partyId, {
      $push: {
        rsvps: {
          rsvp,
          user: this.userId
        }
      }
    });
  } else {
    // update rsvp entry
    const userId = this.userId;
    Parties.update({
      _id: partyId,
      'rsvps.user': userId
    }, {
      $set: {
        'rsvps.$.rsvp': rsvp
      }
    });
  }
}
 
Meteor.methods({
  invite,
  rsvp
});

The function gets the party's id and the response ('yes', 'maybe' or 'no').

Like the invite method, first we check for all kinds of validations, then we do the wanted logic.

Now let's create the PartyRsvp component with action buttons to call the right rsvp!

14.10 Create PartyRsvp component imports/ui/components/partyRsvp/partyRsvp.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import angular from 'angular';
import angularMeteor from 'angular-meteor';
 
import { Meteor } from 'meteor/meteor';
 
import template from './partyRsvp.html';
 
class PartyRsvp {
  yes() {
    this.answer('yes');
  }
 
  maybe() {
    this.answer('maybe');
  }
 
  no() {
    this.answer('no');
  }
 
  answer(answer) {
    Meteor.call('rsvp', this.party._id, answer, (error) => {
      if (error) {
        console.error('Oops, unable to rsvp!');
      } else {
        console.log('RSVP done!')
      }
    });
  }
}
 
const name = 'partyRsvp';
 
// create a module
export default angular.module(name, [
  angularMeteor
]).component(name, {
  template,
  controllerAs: name,
  bindings: {
    party: '<'
  },
  controller: PartyRsvp
});
1
2
3
<input type="button" value="I'm going!" ng-click="partyRsvp.yes()"/>
<input type="button" value="Maybe" ng-click="partyRsvp.maybe()"/>
<input type="button" value="No" ng-click="partyRsvp.no()"/>

Add this component to the PartiesList:

14.12 Add component to the view imports/ui/components/partiesList/partiesList.html
11
12
13
14
15
16
17
    </a>
    <p>{{party.description}}</p>
    <party-remove party="party"></party-remove>
    <party-rsvp party="party"></party-rsvp>
    <party-creator party="party"></party-creator>
  </li>
</ul>
11
12
13
14
15
16
17
 
64
65
66
67
68
69
70
71
import { name as PartyAdd } from '../partyAdd/partyAdd';
import { name as PartyRemove } from '../partyRemove/partyRemove';
import { name as PartyCreator } from '../partyCreator/partyCreator';
import { name as PartyRsvp } from '../partyRsvp/partyRsvp';
 
class PartiesList {
  constructor($scope, $reactive) {
...some lines skipped...
  PartiesSort,
  PartyAdd,
  PartyRemove,
  PartyCreator,
  PartyRsvp
]).component(name, {
  template,
  controllerAs: name,

Now let's display who is coming for each party.

Create the PartyRsvpsList component:

14.14 Create view for PartyRsvpsList component imports/ui/components/partyRsvpsList/partyRsvpsList.html
1
2
3
4
5
6
Who is coming: Yes -
{{ (partyRsvpsList.rsvps | filter:{rsvp:'yes'}).length }}
Maybe -
{{ (partyRsvpsList.rsvps | filter:{rsvp:'maybe'}).length }}
No -
{{ (partyRsvpsList.rsvps | filter:{rsvp:'no'}).length }}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import angular from 'angular';
import angularMeteor from 'angular-meteor';
 
import template from './partyRsvpsList.html';
 
class PartyRsvpsList { }
 
const name = 'partyRsvpsList';
 
// create a module
export default angular.module(name, [
  angularMeteor
]).component(name, {
  template,
  controllerAs: name,
  bindings: {
    rsvps: '<'
  },
  controller: PartyRsvpsList
});

Take a look at the use of filter with length to find how many people responded with each response type.

We have to add it to PartiesList:

12
13
14
15
16
17
18
    <p>{{party.description}}</p>
    <party-remove party="party"></party-remove>
    <party-rsvp party="party"></party-rsvp>
    <party-rsvps-list rsvps="party.rsvps"></party-rsvps-list>
    <party-creator party="party"></party-creator>
  </li>
</ul>
12
13
14
15
16
17
18
 
66
67
68
69
70
71
72
73
import { name as PartyRemove } from '../partyRemove/partyRemove';
import { name as PartyCreator } from '../partyCreator/partyCreator';
import { name as PartyRsvp } from '../partyRsvp/partyRsvp';
import { name as PartyRsvpsList } from '../partyRsvpsList/partyRsvpsList';
 
class PartiesList {
  constructor($scope, $reactive) {
...some lines skipped...
  PartyAdd,
  PartyRemove,
  PartyCreator,
  PartyRsvp,
  PartyRsvpsList
]).component(name, {
  template,
  controllerAs: name,

And we also want to see list of users. Let's create PartyRsvpUsers:

14.18 Create view for PartyRsvpUsers component imports/ui/components/partyRsvpUsers/partyRsvpUsers.html
1
2
3
4
5
<div ng-repeat="rsvp in partyRsvpUsers.rsvps | filter:{ rsvp: partyRsvpUsers.type }">
  {{ partyRsvpUsers.getUserById(rsvp.user) | displayNameFilter }}
  -
  {{ partyRsvpUsers.type }}
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import angular from 'angular';
import angularMeteor from 'angular-meteor';
 
import { Meteor } from 'meteor/meteor';
 
import template from './partyRsvpUsers.html';
import { name as DisplayNameFilter } from '../../filters/displayNameFilter';
 
class PartyRsvpUsers {
  getUserById(userId) {
    return Meteor.users.findOne(userId);
  }
}
 
const name = 'partyRsvpUsers';
 
// create a module
export default angular.module(name, [
  angularMeteor,
  DisplayNameFilter
]).component(name, {
  template,
  controllerAs: name,
  bindings: {
    rsvps: '<',
    type: '@'
  },
  controller: PartyRsvpUsers
});

Add it to PartyRsvpsList:

14.20 Use recently created component imports/ui/components/partyRsvpsList/partyRsvpsList.html
4
5
6
7
8
9
10
{{ (partyRsvpsList.rsvps | filter:{rsvp:'maybe'}).length }}
No -
{{ (partyRsvpsList.rsvps | filter:{rsvp:'no'}).length }}
 
<party-rsvp-users rsvps="partyRsvpsList.rsvps" type="yes"></party-rsvp-users>
<party-rsvp-users rsvps="partyRsvpsList.rsvps" type="maybe"></party-rsvp-users>
<party-rsvp-users rsvps="partyRsvpsList.rsvps" type="no"></party-rsvp-users>
2
3
4
5
6
7
8
 
10
11
12
13
14
15
16
17
import angularMeteor from 'angular-meteor';
 
import template from './partyRsvpsList.html';
import { name as PartyRsvpUsers } from '../partyRsvpUsers/partyRsvpUsers';
 
class PartyRsvpsList { }
 
...some lines skipped...
 
// create a module
export default angular.module(name, [
  angularMeteor,
  PartyRsvpUsers
]).component(name, {
  template,
  controllerAs: name,

Now let's add a list of the users who haven't responded yet. To do this we will create the PartyUnanswered component:

14.22 Create view for PartyUnanswered imports/ui/components/partyUnanswered/partyUnanswered.html
1
2
3
4
5
<ul>
  <li ng-repeat="invitedUser in partyUnanswered.getUnanswered()">
    {{ partyUnanswered.getUserById(invitedUser) | displayNameFilter }}
  </li>
</ul>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import angular from 'angular';
import angularMeteor from 'angular-meteor';
import _ from 'underscore';
 
import { Meteor } from 'meteor/meteor';
 
import template from './partyUnanswered.html';
import { name as DisplayNameFilter } from '../../filters/displayNameFilter';
 
class PartyUnanswered {
  getUnanswered() {
    if (!this.party || !this.party.invited) {
      return;
    }
 
    return this.party.invited.filter((user) => {
      return !_.findWhere(this.party.rsvps, { user });
    });
  }
 
  getUserById(userId) {
    return Meteor.users.findOne(userId)
  }
}
 
const name = 'partyUnanswered';
 
// create a module
export default angular.module(name, [
  angularMeteor,
  DisplayNameFilter
]).component(name, {
  template,
  controllerAs: name,
  bindings: {
    party: '<'
  },
  controller: PartyUnanswered
});

Here we are using filter method, and underscore's findWhere() to extract the users who are invited to the party but are not exist in the rsvps array.

Add that function inside the PartiesList component:

14.24 Use recently created component imports/ui/components/partiesList/partiesList.html
13
14
15
16
17
18
19
    <party-remove party="party"></party-remove>
    <party-rsvp party="party"></party-rsvp>
    <party-rsvps-list rsvps="party.rsvps"></party-rsvps-list>
    <party-unanswered party="party"></party-unanswered>
    <party-creator party="party"></party-creator>
  </li>
</ul>
13
14
15
16
17
18
19
 
68
69
70
71
72
73
74
75
import { name as PartyCreator } from '../partyCreator/partyCreator';
import { name as PartyRsvp } from '../partyRsvp/partyRsvp';
import { name as PartyRsvpsList } from '../partyRsvpsList/partyRsvpsList';
import { name as PartyUnanswered } from '../partyUnanswered/partyUnanswered';
 
class PartiesList {
  constructor($scope, $reactive) {
...some lines skipped...
  PartyRemove,
  PartyCreator,
  PartyRsvp,
  PartyRsvpsList,
  PartyUnanswered
]).component(name, {
  template,
  controllerAs: name,

Also, we forgot to subscribe!

35
36
37
38
39
40
41
42
      }, this.getReactively('searchText')
    ]);
 
    this.subscribe('users');
 
    this.helpers({
      parties() {
        return Parties.find({}, {

Summary

Run the application.

Looks like we have all the functionality we need but there is a lot of mess in the display. There are stuff that we can hide if the user is not authorized to see or if they are empty.

So in the next chapter we are going to learn about a few simple but very useful Angular 1 directive to help us conditionally add or remove DOM.

Testing

14.27 Tests of invite method imports/api/parties/methods.tests.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
import { invite } from './methods';
import { Parties } from './collection';
 
import { Meteor } from 'meteor/meteor';
 
if (Meteor.isServer) {
  describe('Parties / Methods', () => {
    describe('invite', () => {
      function loggedIn(userId = 'userId') {
        return {
          userId
        };
      }
 
      it('should be called from Method', () => {
        spyOn(invite, 'apply');
 
        try {
          Meteor.call('invite');
        } catch (e) {}
 
        expect(invite.apply).toHaveBeenCalled();
      });
 
      it('should fail on missing partyId', () => {
        expect(() => {
          invite.call({});
        }).toThrowError();
      });
 
      it('should fail on missing userId', () => {
        expect(() => {
          invite.call({}, 'partyId');
        }).toThrowError();
      });
 
      it('should fail on not logged in', () => {
        expect(() => {
          invite.call({}, 'partyId', 'userId');
        }).toThrowError(/logged in/i);
      });
 
      it('should look for a party', () => {
        const partyId = 'partyId';
        spyOn(Parties, 'findOne');
 
        try {
          invite.call(loggedIn(), partyId, 'userId');
        } catch (e) {}
 
        expect(Parties.findOne).toHaveBeenCalledWith(partyId);
      });
 
      it('should fail if party does not exist', () => {
        spyOn(Parties, 'findOne').and.returnValue(undefined);
 
        expect(() => {
          invite.call(loggedIn(), 'partyId', 'userId');
        }).toThrowError(/404/);
      });
 
      it('should fail if logged in user is not the owner', () => {
        spyOn(Parties, 'findOne').and.returnValue({
          owner: 'notUserId'
        });
 
        expect(() => {
          invite.call(loggedIn(), 'partyId', 'userId');
        }).toThrowError(/404/);
      });
 
      it('should fail on public party', () => {
        spyOn(Parties, 'findOne').and.returnValue({
          owner: 'userId',
          public: true
        });
 
        expect(() => {
          invite.call(loggedIn(), 'partyId', 'userId');
        }).toThrowError(/400/);
      });
 
      it('should NOT invite user who is the owner', () => {
        spyOn(Parties, 'findOne').and.returnValue({
          owner: 'userId'
        });
        spyOn(Parties, 'update');
 
        invite.call(loggedIn(), 'partyId', 'userId');
 
        expect(Parties.update).not.toHaveBeenCalled();
      });
 
      it('should NOT invite user who has been already invited', () => {
        spyOn(Parties, 'findOne').and.returnValue({
          owner: 'userId',
          invited: ['invitedId']
        });
        spyOn(Parties, 'update');
 
        invite.call(loggedIn(), 'partyId', 'invitedId');
 
        expect(Parties.update).not.toHaveBeenCalled();
      });
 
      it('should invite user who has not been invited and is not the owner', () => {
        const partyId = 'partyId';
        const userId = 'notInvitedId';
        spyOn(Parties, 'findOne').and.returnValue({
          owner: 'userId',
          invited: ['invitedId']
        });
        spyOn(Parties, 'update');
        spyOn(Meteor.users, 'findOne').and.returnValue({});
 
        invite.call(loggedIn(), partyId, userId);
 
        expect(Parties.update).toHaveBeenCalledWith(partyId, {
          $addToSet: {
            invited: userId
          }
        });
      });
    });
  });
}
14.28 Tests of rsvp Method imports/api/parties/methods.tests.js
1
2
3
4
 
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
import { invite, rsvp } from './methods';
import { Parties } from './collection';
 
import { Meteor } from 'meteor/meteor';
...some lines skipped...
        });
      });
    });
 
    describe('rsvp', () => {
      function loggedIn(userId = 'userId') {
        return {
          userId
        };
      }
 
      it('should be called from Method', () => {
        spyOn(rsvp, 'apply');
 
        try {
          Meteor.call('rsvp');
        } catch (e) {}
 
        expect(rsvp.apply).toHaveBeenCalled();
      });
 
      it('should fail on missing partyId', () => {
        expect(() => {
          rsvp.call({});
        }).toThrowError();
      });
 
      it('should fail on missing rsvp', () => {
        expect(() => {
          rsvp.call({}, 'partyId');
        }).toThrowError();
      });
 
      it('should fail if not logged in', () => {
        expect(() => {
          rsvp.call({}, 'partyId', 'rsvp');
        }).toThrowError(/403/);
      });
 
      it('should fail on wrong answer', () => {
        expect(() => {
          rsvp.call(loggedIn(), 'partyId', 'wrong');
        }).toThrowError(/400/);
      });
 
      ['yes', 'maybe', 'no'].forEach((answer) => {
        it(`should pass on '${answer}'`, () => {
          expect(() => {
            rsvp.call(loggedIn(), 'partyId', answer);
          }).not.toThrowError(/400/);
        });
      });
 
      // TODO: more tests  
    });
  });
}