Fork me on GitHub
Note: The Socially 2 tutorial is no longer maintained. Instead, we have a new, better and comprehensive tutorial, integrated with our WhatsApp clone tutorial: WhatsApp clone tutorial with Ionic 2, Angular 2 and Meteor (we just moved all features and steps, and implemented them better!)

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 to implement server side logic of the party invitation feature.

A capital "M" will be used with Meteor "M"ethods to avoid confusion with Javascript function methods

Meteor Methods are a more secure and reliable way to implement complex logic on the server side in comparison to the direct manipulations of Mongo collections. Also, we'll touch briefly on Meteor's UI latency compensation mechanism that comes with these Methods. This is one of the great Meteor concepts that allows for rapid UI changes.

Invitation Method

Let's create a new file both/methods/parties.methods.ts, and add the following invite Meteor Method:

15.1 Add a party invitation method both/methods/parties.methods.ts
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
import {Parties} from '../collections/parties.collection';
import {Email} from 'meteor/email';
import {check} from 'meteor/check';
import {Meteor} from 'meteor/meteor';
 
function getContactEmail(user:Meteor.User):string {
  if (user.emails && user.emails.length)
    return user.emails[0].address;
 
  return null;
}
 
Meteor.methods({
  invite: function (partyId:string, userId:string) {
    check(partyId, String);
    check(userId, String);
 
    let party = Parties.collection.findOne(partyId);
 
    if (!party)
      throw new Meteor.Error('404', 'No such party!');
 
    if (party.public)
      throw new Meteor.Error('400', 'That party is public. No need to invite people.');
 
    if (party.owner !== this.userId)
      throw new Meteor.Error('403', 'No permissions!');
 
    if (userId !== party.owner && (party.invited || []).indexOf(userId) == -1) {
      Parties.collection.update(partyId, {$addToSet: {invited: userId}});
 
      let from = getContactEmail(Meteor.users.findOne(this.userId));
      let to = getContactEmail(Meteor.users.findOne(userId));
 
      if (Meteor.isServer && to) {
        Email.send({
          from: '[email protected]',
          to: to,
          replyTo: from || undefined,
          subject: 'PARTY: ' + party.name,
          text: `Hi, I just invited you to ${party.name} on Socially.
                        \n\nCome check it out: ${Meteor.absoluteUrl()}\n`
        });
      }
    }
  }
});

We used a special API method Meteor.methods to register a new Meteor Method. Again, don't forget to import your created parties.methods.ts module in the server's main.ts module to have the Methods defined properly:

15.2 Import methods on the server side server/main.ts
3
4
5
6
7
8
9
10
import { loadParties } from './imports/fixtures/parties';
 
import './imports/publications/parties';
import './imports/publications/users';
import '../both/methods/parties.methods';
 
Meteor.startup(() => {
  loadParties();

Latency Compensation

UI Latency compensation is one of the features that makes Meteor stand out amongst most other Web frameworks, thanks again to the isomorphic environment and Meteor Methods. In short, visual changes are applied immediately as a response to some user action, even before the server responds to anything. If you want to read up more about how the view can securely be updated even before the server is contacted proceed to an Introduction to Latency Compensation written by Arunoda.

But to make it happen, we need to define our Methods on the client side as well. Let's import our Methods in client/main.ts:

15.3 Import methods on the client side client/main.ts
4
5
6
7
8
9
10
 
import { AppModule } from './imports/app/app.module';
 
import '../both/methods/parties.methods';
 
const platform = platformBrowserDynamic();
platform.bootstrapModule(AppModule);

Validating Methods with Check

As you can see, we've also done a lot of checks to verify that all arguments passed down to the method are valid.

First the validity of the arguments' types are checked, and then the business logic associated with them is checked.

Type validation checks, which are essential for the JavaScript methods dealing with the storage's data, are done with the help of a handy Meteor's package called "check".

meteor add check

Then, if everything is valid, we send an invitation email. Here we are using another handy Meteor's package titled "email".

meteor add email

At this point, we are ready to add a call to the new Method from the client.

Let's add a new button right after each username or email in that list of users to invite in the PartyDetails's template:

16
17
18
19
20
21
<ul>
  <li *ngFor="let user of users | async">
    <div>{{user | displayName}}</div>
    <button (click)="invite(user)">Invite</button>
  </li>
</ul>

And then, change the component to handle the click event and invite a user:

15.6 Add the click handler in the Component client/imports/app/parties/party-details.component.ts
73
74
75
76
77
78
79
80
81
82
83
84
85
86
    });
  }
 
  invite(user: Meteor.User) {
    MeteorObservable.call('invite', this.party._id, user._id).subscribe(() => {
      alert('User successfully invited.');
    }, (error) => {
      alert(`Failed to invite due to ${error}`);
    });
  }
 
  ngOnDestroy() {
    this.paramsSub.unsubscribe();
    this.partySub.unsubscribe();

We used MeteorObservable.call which triggers a Meteor server method, which triggers next callback when the server returns a response, and error when the server returns an error.

Updating Invited Users Reactively

One more thing before we are done with the party owner's invitation logic. We, of course, would like to make this list of users change reactively, i.e. each user disappears from the list when the invitation has been sent successfully.

It's worth mentioning that each party should change appropriately when we invite a user — the party invited array should update in the local Mongo storage. If we wrap the line where we get the new party with the MeteorObservable.autorun method, this code should re-run reactively:

41
42
43
44
45
46
47
48
49
        }
 
        this.partySub = MeteorObservable.subscribe('party', this.partyId).subscribe(() => {
          MeteorObservable.autorun().subscribe(() => {
            this.party = Parties.findOne(this.partyId);
          });
        });
 
        if (this.uninvitedSub) {

Now each time the Party object changes, we will re-fetch it from the collection and assign it to the Component property. Our view known to update itself's because we used zone() operator in order to connect between Meteor data and Angular change detection.

Now its time to update our users list. We'll move the line that gets the users list into a separate method, provided with the list of IDs of already invited users; and call it whenever we need: right in the above MeteorObservable.autorun method after the party assignment and in the subscription, like that:

15.8 Update the users list reactively client/imports/app/parties/party-details.component.ts
43
44
45
46
47
48
49
 
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
        this.partySub = MeteorObservable.subscribe('party', this.partyId).subscribe(() => {
          MeteorObservable.autorun().subscribe(() => {
            this.party = Parties.findOne(this.partyId);
            this.getUsers(this.party);
          });
        });
 
...some lines skipped...
        }
 
        this.uninvitedSub = MeteorObservable.subscribe('uninvited', this.partyId).subscribe(() => {
          this.getUsers(this.party);
        });
      });
  }
 
  getUsers(party: Party) {
    if (party) {
      this.users = Users.find({
        _id: {
          $nin: party.invited || [],
          $ne: Meteor.userId()
        }
      }).zone();
    }
  }
 
  saveParty() {
    if (!Meteor.userId()) {
      alert('Please log in to change this party');

Here comes test time. Let's add a couple of new users. Then login as an old user and add a new party. Go to the party: you should see a list of all users including newly created ones. Invite several of them — each item in the list should disappear after a successful invitation.

What's important to notice here is that each user item in the users list disappears right after the click, even before the message about the invitation was successfully sent. That's the latency compensation at work!

User Reply

Here we are going to implement the user reply to the party invitation request.

First of all, let's make parties list a bit more secure, which means two things: showing private parties to those who have been invited or to owners, and elaborate routing activation defense for the party details view:

15.9 Show private parties to the invited and owners only server/imports/publications/parties.ts
36
37
38
39
40
41
42
43
44
45
46
47
          $exists: true
        }
      }]
    },
    {
      $and: [
        { invited: this.userId },
        { invited: { $exists: true } }
      ]
    }]
  };
 

The next thing is a party invitee response to the invitation itself. Here, as usual, we'll need to update the server side and UI. For the server, let's add a new reply Meteor Method:

15.10 Add a reply method both/methods/parties.methods.ts
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
        });
      }
    }
  },
  reply: function(partyId: string, rsvp: string) {
    check(partyId, String);
    check(rsvp, String);
 
    if (!this.userId)
      throw new Meteor.Error('403', 'You must be logged-in to reply');
 
    if (['yes', 'no', 'maybe'].indexOf(rsvp) === -1)
      throw new Meteor.Error('400', 'Invalid RSVP');
 
    let party = Parties.findOne({ _id: partyId });
 
    if (!party)
      throw new Meteor.Error('404', 'No such party');
 
    if (party.owner === this.userId)
      throw new Meteor.Error('500', 'You are the owner!');
 
    if (!party.public && (!party.invited || party.invited.indexOf(this.userId) == -1))
      throw new Meteor.Error('403', 'No such party'); // its private, but let's not tell this to the user
 
    let rsvpIndex = party.rsvps ? party.rsvps.findIndex((rsvp) => rsvp.userId === this.userId) : -1;
 
    if (rsvpIndex !== -1) {
      // update existing rsvp entry
      if (Meteor.isServer) {
        // update the appropriate rsvp entry with $
        Parties.update(
          { _id: partyId, 'rsvps.userId': this.userId },
          { $set: { 'rsvps.$.response': rsvp } });
      } else {
        // minimongo doesn't yet support $ in modifier. as a temporary
        // workaround, make a modifier that uses an index. this is
        // safe on the client since there's only one thread.
        let modifier = { $set: {} };
        modifier.$set['rsvps.' + rsvpIndex + '.response'] = rsvp;
 
        Parties.update(partyId, modifier);
      }
    } else {
      // add new rsvp entry
      Parties.update(partyId,
        { $push: { rsvps: { userId: this.userId, response: rsvp } } });
    }
  }
});

As you can see, a new property, called "rsvp", was added above to collect user responses of this particular party. One more thing. Let's update the party declaration file to make TypeScript resolve and compile with no warnings:

15.11 Add RSVP interface both/models/party.model.ts
7
8
9
10
11
12
13
14
15
16
  owner?: string;
  public: boolean;
  invited?: string[];
  rsvps?: RSVP[];
}
 
interface RSVP {
  userId: string;
  response: string;
}

For the UI, let's add three new buttons onto the party details view. These will be "yes", "no", "maybe" buttons and users responses accordingly:

19
20
21
22
23
24
25
26
27
28
    <button (click)="invite(user)">Invite</button>
  </li>
</ul>
 
<div>
  <h2>Reply to the invitation</h2>
  <input type="button" value="I'm going!" (click)="reply('yes')">
  <input type="button" value="Maybe" (click)="reply('maybe')">
  <input type="button" value="No" (click)="reply('no')">
</div>

Then, handle click events in the PartyDetails component:

15.13 Add reply method to PartyDetails component client/imports/app/parties/party-details.component.ts
91
92
93
94
95
96
97
98
99
100
101
102
103
104
    });
  }
 
  reply(rsvp: string) {
    MeteorObservable.call('reply', this.party._id, rsvp).subscribe(() => {
      alert('You successfully replied.');
    }, (error) => {
      alert(`Failed to reply due to ${error}`);
    });
  }
 
  ngOnDestroy() {
    this.paramsSub.unsubscribe();
    this.partySub.unsubscribe();

Rsvp Pipe

Last, but not the least, let's show statistics of the invitation responses for the party owner. Let's imagine that any party owner would like to know the total number of those who declined, accepted, or remain tentative. This is a perfect use case to add a new stateful pipe, which takes as an input a party and a one of the RSVP responses, and calculates the total number of responses associated with this, provided as a parameter we'll call "response".

Add a new pipe to the client/imports/app/shared/rsvp.pipe.ts as follows:

15.14 Add a new response counting pipe client/imports/app/shared/rsvp.pipe.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import {Pipe, PipeTransform} from '@angular/core';
import {Party} from "../../../../both/models/party.model";
import {Parties} from "../../../../both/collections/parties.collection";
 
@Pipe({
  name: 'rsvp'
})
export class RsvpPipe implements PipeTransform {
  transform(party: Party, type: string): number {
    if (!type) {
      return 0;
    }
 
    let total = 0;
    const found = Parties.findOne(party._id);
 
    if (found)
      total = found.rsvps ? found.rsvps.filter(rsvp => rsvp.response === type).length : 0;
 
    return total;
  }
}

The RSVP Pipe fetches the party and returns the count of rsvps Array, due the fact that we binded the change detection of Angular 2 and the Meteor data change, each time the data changes, Angular 2 renders the view again, and the RSVP Pipe will run again and update the view with the new number.

It's also worth mentioning that the arguments of a Pipe implementation inside a template are passed to the transform method in the same form. Only difference is that the first argument of transform is a value to be transformed. In our case, passed only the RSVP response, hence, we are taking the first value in the list.

An example:

// usage: text | subStr:20:50
@Pipe({name: 'subStr'})
class SubStrPipe implements PipeTransform {
  transform(text: string, starts: number, ends: number) {
    return text.substring(starts, ends);
  }
}

Let's make use of this pipe in the PartiesList component:

15.15 Display response statistics on the list client/imports/app/parties/parties-list.component.html
20
21
22
23
24
25
26
27
28
29
30
31
      <p>{{party.description}}</p>
      <p>{{party.location}}</p>
      <button (click)="removeParty(party)">X</button>
      <div>
        Who is coming:
        Yes - {{party | rsvp:'yes'}}
        Maybe - {{party | rsvp:'maybe'}}
        No - {{party | rsvp:'no'}}
      </div>
    </li>
  </ul>
 

And let's add the new Pipe to the shared declarations file:

15.16 Import RsvpPipe client/imports/app/shared/index.ts
1
2
3
4
5
6
7
import { DisplayNamePipe } from './display-name.pipe';
import {RsvpPipe} from "./rsvp.pipe";
 
export const SHARED_DECLARATIONS: any[] = [
  DisplayNamePipe,
  RsvpPipe
];

Now it's testing time! Check that an invited user is able to reply to an invitation, and also verify that the party's statistics update properly and reactively. Login as an existing user. Add a new party, go to the party and invite some other users. Then, open a new browser window in the anonymous mode along with the current window, and login as the invited user there. Go to the party details page, and reply, say, "no"; the party's statistics on the first page with the parties list should duly update.

Challenge

There is one important thing that we missed. Besides the party invitation statistics, each user would like to know if she has already responded, in case she forgot, to a particular invitation. This step's challenge will be to add this status information onto the PartyDetails's view and make it update reactively.

Hint: In order to make it reactive, you'll need to add one more handler into the party MeteorObservable.autorun, like the getUsers method in the this step above.

Summary

We've just finished the invitation feature in this step, having added bunch of new stuff. Socially is looking much more mature with Meteor Methods on board. We can give ourselves a big thumbs-up for that!

Though, some places in the app can certainly be improved. For example, we still show some private information to all invited users, which should be designated only for the party owner. We'll fix this in the next step.