📜 ⬆️ ⬇️

Writing a system of invitations (invitations) for your Meteor application

Hey.

Today I will try to tell you how to write a simple, but quite working email invitation system for your Meteor application.

Why this may be needed? For example, if you are developing an application in which several people have to work in the same group on a project. This could be, for example, a training schedule that can edit several people or a product catalog of the company's online store.
')
The network already has one manual in English and CoffeScript, but the approach described there seemed inconvenient to me and I decided to implement my own system of invites for my project, and then share it with habrazhiteli.

So, our example will be able to:
1) Display the invitations interface including the list of existing invitations and the form for sending a new one;
2) Save all invitations to the database;
3) Follow the status of invitations;
4) Send invitations to email;
5) Follow user roles;
6) Activate the invitation of new users;
7) Use a bunch of third-party modules.

Attention: The example will be considered on the basis of a live project, so just copying and pasting it to yourself will probably not work ...

What do we need? First, you will need to install the following packages:
accounts-base -     accounts-google -      accounts-password -    / accounts-ui -   alanning:roles -    aldeed:autoform -   aldeed:collection2 -       email -    iron:router -    mizzao:bootboxjs -     random -       

In addition, the example interface is written using Material Design for Bootstrap , which will also need to be integrated into your project. Let's get started

First approach


First of all, we will describe a few simple constants that will be responsible for the status of invitations and will be needed in the future.

Lib / constants.js file
 //       INVITE_CREATED = 0; //   Email       -    INVITE_EMAILED = 1; //      INVITE_COMPLETED = 2; 

Also, we will need to create a couple of helper functions.

Lib / helpers.js file
 //    ,       if (Meteor.isClient) { //   .      ,      Template.registerHelper('userCompany', function () { var company = Company.findOne({userId: Meteor.userId()}); if (company == undefined) { if (Meteor.userId() != null) { var user = Meteor.users.findOne({_id: Meteor.userId()}, {fields: {'companyId': 1}}); company = Company.findOne({_id: user.companyId}); } } return company; }); //     Email        Template.registerHelper('validateEmail', function (email) { var re = /\S+@\S+\.\S+/; return re.test(email); }); } 

Suppose we have applications in which we need to jointly edit data (what kind of). To do this, when creating the first user, we create a certain company card (or groups, to whom it is convenient) and provide him with an interface at the invitation of other users.

Data structure and server logic


We describe the data schema for our entities. For this we will use the features of Simple Schema and the aldeed: collection2 package.

First we describe the data scheme of the company. For example, it will include the name of the company, a brief description, the id of the user who created the company, the date the record was created and the date it was last updated.

Please note that in the description of the scheme we can specify such values ​​as:

Lib / collections / company.js file
 Company = new Mongo.Collection('company'); //  if (Meteor.isServer) { Meteor.methods({ //  .        registerAdminUser: function(companyId, userId) { check(companyId, String); check(userId, String); Roles.addUsersToRoles(Meteor.userId(), ["CompanyAdmin"]); } }); } //SimpleSchema.debug = true; // Company.attachSchema(new SimpleSchema({ //  title: { type: String, label: "", min: 3, max: 200 }, //  description: { type: String, label: " ", min: 20, max: 1000, autoform: { rows: 5 } }, //id   .         . userId: { type: String, autoValue: function() { if (this.isInsert) { return Meteor.userId(); } else { this.unset(); } }, label: "", //denyInsert: true, denyUpdate: true, optional: true }, // .   createdAt: { type: Date, autoValue: function() { if (this.isInsert) { return new Date; } else if (this.isUpsert) { return {$setOnInsert: new Date}; } else { this.unset(); } }, denyUpdate: true, optional: true }, //  .   updatedAt: { type: Date, autoValue: function() { if (this.isUpdate) { return new Date(); } }, denyInsert: true, optional: true }, })); 

In the same way we will describe the scheme for our invites. Our collection file will contain three server methods - for sending a new invitation, deleting an existing one and activating the invitation by the user. The collection will contain the email of the invitee, the activation code, the status of the invitation, the field of communication with the user who activated the invitation, the connection with the sending user, and the date of creation / update of the invitation. Moreover, the last value in the field “Date of update” will be equal to the date and time of activation of the invitation, and the field “Date of creation” - the date and time of its sending.

Lib / collections / invite.js file
 Invite = new Mongo.Collection('invite'); //SimpleSchema.debug = true; //  if (Meteor.isServer) { Meteor.methods({ //    email invationSender: function (email) { check(this.userId, String); check(email, String); // .       Email var token = Random.hexString(10); //        . var company = Company.findOne({userId: this.userId}); var companyName = company.title; //        //   -  ,     var inviteId = Invite.insert({email:email,token:token,status:INVITE_CREATED}); //     ,       this.unblock(); //       //    ,       Email.send({ to: email, from: 'info@forsk.ru', subject: '   '+companyName+'       Kellot.ru', html: '!   Kellot.Ru   '+Meteor.user().profile.name+'   ' + '        "'+companyName+'". ' + '<br/><br/>   : '+token+ '<br/><br/>   ,     ' + '<a href="http://p.kellot.ru/company/invite/'+token+'">http://p.kellot.ru/company/invite/'+token+'</a> ' + '    .     .'+ '<br/><br/> ,        Kellot.Ru' }); //    ""  "" Invite.update({_id:inviteId}, {$set: {status: INVITE_EMAILED}}, {}, function(error, count) { console.log('update error', error, count); }); return true; }, //       deleteInvite: function(inviteId) { check(inviteId, String); var invite = Invite.findOne({_id: inviteId}); //     ""    . //   ,         if (invite.status != INVITE_COMPLETED) { Invite.remove({_id: inviteId}); return true; } else { return false; } }, //    activateInviteToken: function (activationToken, userId) { check(this.userId, String); check(activationToken, String); check(userId, String); //   -  ,    var user = Meteor.users.findOne({_id:userId}); var invite = Invite.findOne({token:activationToken}); var company = Company.findOne({_id:invite.companyId}); //    -    if (invite.status == INVITE_COMPLETED) { return false; } //         Meteor.users.update({_id:userId}, { $set: {companyId: company._id } }); //         Invite.update({_id:invite._id}, { $set: {invitedUserId: userId, status: 2 } }); //     Roles.addUsersToRoles(Meteor.userId(), ["CompanyMember"]); return true; } }); } // Invite.attachSchema(new SimpleSchema({ //Email   ,      email: { type: String, label: "  / Email", min: 3, max: 30 }, //    .   token: { type: String, label: " ", min: 10, max: 10 }, //  status: { type: Number, label: " " }, //    invitedUserId: { type: String, label: "  ", optional: true }, //   ? creator: { type: String, label: "", autoValue: function() { if (this.isInsert) { return Meteor.userId(); } else { this.unset(); } }, denyUpdate: true, optional: true }, //    companyId: { type: String, autoValue: function() { if (this.isInsert) { return Company.findOne({userId:Meteor.userId()})._id; } else { this.unset(); } }, label: "", denyUpdate: true, optional: true }, //  createdAt: { type: Date, autoValue: function() { if (this.isInsert) { return new Date; } else if (this.isUpsert) { return {$setOnInsert: new Date}; } else { this.unset(); } }, denyUpdate: true, optional: true }, // . //      updatedAt: { type: Date, autoValue: function() { if (this.isUpdate) { return new Date(); } }, denyInsert: true, optional: true } })); 

That's basically all we need on the server. As you can see, the logic is simple as twice two: the user creates a company (group). automatically becomes its owner and sends invitations to other users. If he changed his mind to send the invitation, while the recipient did not apply it - the admin has the right to delete it and then the recipient will fail. If the invitation is active and the recipient follows the link in the letter, then he can immediately activate the invitation by simply clicking on the “Register” button. But for this to work, we need to teach the router something ...

Immediately it is worth noting that you don’t need to bother to set up sending emails correctly. Meteor out of the box is configured to use Mailgun , which allows you to send up to 200 emails per day or up to 10,000 emails per month. But nothing prevents you from setting it up to work with your own or third-party mail server. To do this, when you start the application, you just need to define the environment variable MAIL_URL.

Something like this: “MAIL_URL”: “smtp: // user: password@domain.ru: 587 /”. After that, the sending of letters will occur through authorization on the server you specified.

Important! Be vigilant if you use meteor-up to deploy your application and store mup.json file in your project, which describes environment variables, then do not put your code in open repositories on github or anywhere else. Otherwise, your mail can be read by all and sundry.

Router and publications


Iron Router will perform the task of processing the link for which the newcomer is transferred from the letter, and will also invoke the server method of inviting activation based on two conditions:
  1. Is the current user tied to one of the companies;
  2. Is there an activation code in the current session?

Lib / router.js file
 Router.map(function () { ... //          this.route('activateInviteToCompany', { trackPageView: true, path: '/company/invite/:activationToken', waitOn: function () { //   ,    ,    Meteor.subscribe("inviteToken", Router.current().params.activationToken); Meteor.subscribe('companyToken', Router.current().params.activationToken); return Meteor.subscribe('userToken', Router.current().params.activationToken); } }); ... Router.onBeforeAction(function (pause) { Alerts.removeSeen(); //          if (Meteor.userId() == null) { if (pause.url != '/index' && pause.url != '/' && pause.url != '/reviews' && pause.url != '/company/invite/'+Router.current().params.activationToken) { Router.go('index'); } } //   ,        , //          //          , //           if (Meteor.isClient && Meteor.userId() != null) { //        ... if (UI._globalHelpers.userCompany() == undefined && (pause.url != '/firstLogin' && pause.url != '/company/register' )) { //...      , ... if (Session.get('activationToken') != undefined) { //       var activationToken = Session.get('activationToken'); Session.set('activationToken', undefined); //     var invite = Invite.findOne({ token: activationToken }); //     Meteor.call('activateInviteToken', activationToken, Meteor.userId(), function (error, result) { //  if (error) { //    ... console.log(error); bootbox.alert("   . ,    ! : " + error.reason); } else { //          ! Meteor.subscribe('company'); Meteor.subscribe('invite'); bootbox.alert("  !"); } }); } else { //        -    // (   ) Router.go('firstLoginForm'); } } } this.next(); }); }); 

As can be seen from the code, if the user follows the link '/ company / invite / <activation code>', then the activateInviteToCompany template will be automatically presented to him, and the client will receive information about the invitation itself, the company and the user who invited the new participant. We will need this data later.

In the onBeforeAction function, we perform several actions.

Firstly, for non-authorized users, we only allow to go to the main page to read about the functions of the application, view the feedback page and go to the invite activation page. In other cases, we always send him to the main page, wherever he tries to go.

Secondly, if the user is still authorized, then we check its assignment to one of the existing companies - as an administrator or member. If this check is successful, let the user go on.

Thirdly, if the user is still not assigned to any company, we are looking for the invitation activation code in the session. If there is one, we activate the user. If not, send it to a special page and suggest creating a new company.

It's pretty simple. But nowhere is visible saving invitation code in the session. How so? We will talk about this a little later.

Remaining publications. Judging by the code in the router on the client, we will need information about the invitation, the company and the user who sent the invitation. In the next section it becomes clear why.

To do this, add the following code in the publication file:
File server / publications.js
 //      function getCompanyByInviteToken(tokenId) { var invite = Invite.findOne({ token: tokenId }); var company = Company.findOne({ _id: invite.companyId }); //console.log('getCompanyByInviteToken', tokenId, invite.companyId, company._id); return company; } ... Meteor.publish('inviteToken', function (tokenId) { check(tokenId, Match.Any); return Invite.find({ token: tokenId }); }); Meteor.publish('companyToken', function (tokenId) { check(tokenId, Match.Any); var company = getCompanyByInviteToken(tokenId); return Company.find({_id:company._id}); }); Meteor.publish('userToken', function (tokenId) { check(tokenId, Match.Any); var company = getCompanyByInviteToken(tokenId); return Meteor.users.find({ _id: company.userId }, {fields: {'services':0, 'roles':0, createdAt:0}}); }); 

So, as shown above, we can not transfer the minimum necessary information to the client. It would not even have been possible to transfer all the fields to the client, but only selected ones, but I also think that this is unnecessary.

Interface


All our logic is nothing without an interface. The whole interface will consist of several templates:

File client / views / invite / invite.html
 <template name="inviteList"> <div class="panel panel-success" style="float: left; margin-right: 20px;"> <div class="panel-heading"> <h3 class="panel-title">  !</h3> </div> <div class="panel-body"> {{#if invitedUsers.count}} <div class="list-group"> {{#each invitedUsers}} <div class="list-group-item"> <div class="row-content"> <div class="least-content">{{inviteTextStatus}} {{#if isInRole 'CompanyAdmin'}} {{#if inviteIsComplete}} {{else}} <a class="deleteInviteBtn" data-id="{{_id}}" href="#">x</a> {{/if}} {{/if}} </div> <p class="list-group-item-text">{{email}}</p> </div> </div> <div class="list-group-separator"></div> {{/each}} </div> {{else}}      ! {{/if}} </div> {{#if isInRole 'CompanyAdmin'}} <div class="panel-footer"> {{> inviteSend}} </div> {{/if}} </div> </template> <template name="inviteSend"> {{#autoForm collection="Invite" id="inviteSend" type="insert"}} {{> inviteFieldset}} <button id="sendInviteBtn" class="btn btn-primary" style="width:100%"></button> {{/autoForm}} </template> <template name="inviteFieldset"> <fieldset> {{> afQuickField name='email'}} </fieldset> </template> <template name="activateInviteToCompany"> {{#if currentUser}} .            . {{ else }} {{#if inviteIsActivated}} ,  <b>{{userActivationCode}}</b>  .      . {{ else }} ! <br/><br/>   ,        -    <b>{{companyNameByInviteCode}}</b>   <b>{{companyUserNameByInviteCode}}</b>. <br/><br/>       <b>{{userActivationCode}}</b>      !<br/><br/> ,     " / "    ,                <b>{{companyNameByInviteCode}}</b>! {{/if}} {{/if}} </template> 

This is how it is.

The inviteList template displays for us the inviteSend sending form and a list of invitations available to the company. Based on the user's role and the status of the invitation, the form for sending the invitation and the Delete button are displayed or hidden.

The activateInviteToCompany template also, based on the status of the invitation (sent / activated) and the state of the user (authorized / unauthorized), displays either an activation invitation or information about the impossibility of using the invitation for one of the reasons.

Moreover, the page inviting the user of the activateInviteToCompany template contains detailed information for the user, who, why, and where he invites. This is very important, since the user referred by link from the letter is not a fact that he wants to accept the invitation is not clear from whom in an incomprehensible service.

This is what our invitation widget looks like:


And so, the user invitation page:


Client-side logic


Fuh ... The third hour of writing the article ...

Left a little. It's time for us to finally hang the logic on our interface and tie all the parts of the system together. For this,

File client / views / invite / invite.js
 Template.inviteSend.events({ 'click #sendInviteBtn': function () { //      Email var email = $('#inviteSend [name=email]').val(); $('#sendInviteBtn').attr("disabled", true); //    Email   var existsInvite = Invite.findOne({email:email}); if ( existsInvite == undefined ) { //      -  Email   if (UI._globalHelpers.validateEmail( email )) { // Email   -       Meteor.call('invationSender', email, function (error, result) { if (error) { //-   .   $('#inviteSend [name=email]').val(""); $('#sendInviteBtn').removeAttr("disabled"); bootbox.alert("   .     ! : " + error.reason); } else { // .  ,    $('#inviteSend [name=email]').val(""); $('#sendInviteBtn').removeAttr("disabled"); Meteor.subscribe('invite', Meteor.userId()); bootbox.alert("     " + email); } }); } else { // Email    -    $('#inviteSend [name=email]').val(""); $('#sendInviteBtn').removeAttr("disabled"); bootbox.alert("Email    email@example.ru!"); } } else { //    Email   -    . $('#inviteSend [name=email]').val(""); $('#sendInviteBtn').removeAttr("disabled"); bootbox.alert("  Email     !"); } } }); Template.inviteList.events({ //      'click .deleteInviteBtn': function () { //            Meteor.call('deleteInvite', this._id, function (error, result) { if (error) { bootbox.alert("   .     ! : " + error.reason); } else { bootbox.alert(" !"); } }); } }); //        Template.inviteList.helpers({ //   invitedUsers: function () { return Invite.find(); }, //        inviteTextStatus: function() { var textStatus = '-'; switch(this.status) { case INVITE_CREATED: textStatus = ''; break; case INVITE_EMAILED: textStatus = ''; break; case INVITE_COMPLETED: textStatus = ''; break; } return textStatus; }, //   inviteIsComplete: function () { if (this.status == INVITE_COMPLETED) { return true; } else { return false; } }, //   inviteIsEmailed: function () { if (this.status == INVITE_EMAILED) { return true; } else { return false; } }, //   inviteIsCreated: function () { if (this.status == INVITE_CREATED) { return true; } else { return false; } } }); if (Meteor.isClient) { //             Template.activateInviteToCompany.rendered = function () { Session.set('activationToken', Router.current().params.activationToken); }; //   Template.activateInviteToCompany.helpers({ //        companyNameByInviteCode: function () { var invite = Invite.findOne({token:Router.current().params.activationToken}); var company = Company.findOne({_id:invite.companyId}); return company.title; }, //        companyUserNameByInviteCode: function () { var invite = Invite.findOne({token:Router.current().params.activationToken}); var company = Company.findOne({_id:invite.companyId}); var user = Meteor.users.findOne({_id:company.userId}); return user.profile.name; }, //      userActivationCode: function () { return Router.current().params.activationToken; }, //      //      inviteIsActivated: function () { var userInviteCode = Router.current().params.activationToken; var invite = Invite.findOne({token: userInviteCode}); if (invite.status == INVITE_COMPLETED) { return true; } else { return false; } } }); } 

. …

« » « ».
« » email , , .

« » . .

, .

.

The most interesting piece of this code is the definition of Template.activateInviteToCompany.rendered . It is this code that is responsible for saving in the session a variable with an activation code.

Outcome and live demo


, , , , , Reformal — « / », , () .

, , , .

That's all. , , ?

p.kellot.ru
, . — Reformal .

Source: https://habr.com/ru/post/257113/


All Articles