Пишем систему инвайтов (приглашений) для своего Meteor-приложения

  • Tutorial
Привет.

Сегодня я попробую рассказать, как написать простенькую, но вполне рабочую систему Email-приглашений для своего Meteor приложения.

Зачем это может понадобиться? Например — если вы разрабатываете приложение, в котором несколько людей должны работать в рамках одной группы над каким-либо проектом. Это может быть к примеру учебное расписание, которое может редактировать несколько людей или каталог продукции Интернет-магазина компании.

В сети уже есть одно руководство на английском и CoffeScript, но мне описанный там подход показался неудобным и я решил реализовать собственную систему инвайтов для своего проекта, а затем поделиться ею с хабражителями.

Итак, наш пример будет уметь:
1) Отображать интерфейс приглашений включая список существующих приглашений и форму для отправки нового;
2) Сохранять все приглашения в базе данных;
3) Следить за статусами приглашений;
4) Отправлять приглашения на email;
5) Следить за ролями пользователей;
6) Активировать приглашения новых пользователей;
7) Использовать кучу сторонних модулей.

Внимание: Пример будет рассмотрен на основании живого проекта, так что просто скопировать и вставить его к себе, вероятно, не получится…

Что же нам понадобится? Во-первых, нужно будет установить следующие пакеты:
accounts-base - базовые функции управления аккаунтом
accounts-google - возможность авторизации через сервисы гугла
accounts-password - возможность авторизации по  логину/паролю
accounts-ui - интерфейс  авторизации
alanning:roles - управление ролями пользователей
aldeed:autoform - генерация форм
aldeed:collection2 - включает возможность описания схем таблиц БД
email - позволяет отправлять письма
iron:router - самый популярный роутер
mizzao:bootboxjs - позволяет выводить простые уведомления
random - этой штукой будем генерировать коды приглашений

Кроме того, интерфейс примера написан с использованием Material Design for Bootstrap, который также необходимо будет интегрировать в свой проект. Приступим.

Первое приближение


Первым делом опишем несколько простых констант, которые будут отвечать за статусы приглашений и будут необходимы нам в дальнейшем.

Файл lib/constants.js
//Приглашение создано в БД и ожидает отправки
INVITE_CREATED = 0;
//Приглашение отправлено на Email пользователя и если его пропустил антиспам - пользователь его увидит
INVITE_EMAILED = 1;
//Приглашение принято пользователем и он зарегистрировался
INVITE_COMPLETED = 2;

Также, нам будет необходимо создать пару функций хелперов.

Файл lib/helpers.js
//эти хелперы бессмысленны на сервере, поэтому показываем их только на клиенте
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);
    });
}

Допустим, у нас есть приложений, в котором необходимо совместно редактировать данные (пофигу какие). Для этого при создании первого пользователя мы создаём некую карточку компании (или группы, кому как удобно) и предоставляем ему интерфейс по приглашению других пользователей.

Структура данных и серверная логика


Опишем схемы данных для наших сущностей. Для этого будем использовать возможности Simple Schema и пакета aldeed:collection2.

Первой опишем схему данных компании. Для примера она будет включать наименование компании, краткое описание, id пользователя, создавшего компанию, дату создания записи и дату её последнего обновления.

Обратите внимание, что в описании схемы мы можем задавать такие значения как:
  • type — условный тип поля, используемый в будущем при генерации формы
  • min, max — минимальное и максимальное количество символов
  • autoValue — автоматические значение для поля
  • denyInsert, denyUpdate — запрет на запись в поле при вставке/обновлении записи
  • optional — по умолчанию поле обязательно, но мы можем это исправить

Файл lib/collections/company.js
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
  },
}));

Точно так же опишем схему для наших инвайтов. Наш файл коллекции будет содержать три серверных метода — для отправки нового приглашения, удаления существующего и активации приглашения пользователем. Коллекция будет хранить email приглашаемого, код активации, статус приглашения, поле связи с активировавшим данное приглашение пользователем, связь с выславшим приглашение пользователем и даты создания/обновления приглашения. Причём, последнее значение в поле «Дата обновления» будет равно дате и времени активации приглашения, а поле «Дата создания» — дате и времени его отправки.

Файл lib/collections/invite.js
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
    }
}));

Вот в принципе и всё, что нам нужно на сервере. Как видим, логика проста как дважды два: пользователь создаёт компанию (группу). автоматически становится её владельцем и рассылает приглашения другим пользователям. Если он передумал отправлять приглашение, то пока получатель его не применил — админ вправе его удалить и тогда у получателя ничего не получится. Если же приглашение активно и получатель переходит по ссылке в письме — то он сразу же может активировать приглашение, просто нажав кнопку «Зарегистрироваться». Но чтобы это заработало, нам нужно научить роутер кое-чему…

Сразу стоит заметить, что Вам вообще не нужно заморачиваться о том, чтобы корректно настроить отправку email. Meteor из коробки настроен на использование сервиса Mailgun, позволяющего отправить до 200 писем в день или до 10 000 писем в месяц. Но ничто не мешает Вам настроить его на работу с собственным или сторонним почтовым сервером. Для этого при запуска приложения просто нужно определить переменную окружения MAIL_URL.

Примерно так: «MAIL_URL»: «smtp://user:password@domain.ru:587/». После этого отправка писем будет происходит через авторизацию на указанном Вами сервере.

Важно! Будьте бдительны, если вы используете для развёртывания своего приложения meteor-up и храните в своём проекте файл mup.json, в котором описываются переменные окружения, то не выкладывайте свой код в открытых репозиториях на github или ещё где-то. Иначе Вашу почту смогут читать все кому не лень.

Роутер и публикации


Iron Router будет выполнять задачу обработки ссылки по которой переход новичок из письма, а также будет вызывать серверный метод активации приглашения основываясь на двух условиях:
  1. Привязан ли текущий пользователь к одной из компаний;
  2. Есть ли в текущей сессии код активации.

Файл lib/router.js
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();
});
});

Как видно из кода, если пользователь перейдёт по ссылке '/company/invite/<код активации>', то для него будет автоматически отрендерен шаблон activateInviteToCompany, а на клиенте появятся данные о самом приглашении, компании и пользователе, пригласившем нового участника. Эти данные нам понадобятся чуть позже.

В функции же onBeforeAction мы выполняем несколько действий.

Во-первых, не авторизованным пользователям мы позволяем только заходить на главную страницу чтобы почитать о функциях приложения, просмотреть страницу отзывов и перейти на страницу активации инвайта. В остальных случаях — всегда отправляем его на главную страницу, куда бы он не попробовал попасть.

Во-вторых, если пользователь таки авторизован, то мы проверяем его закрепление к одной из существующих компаний — в качестве администратора или участника. Если эта проверка проходит успешно — пускаем пользователя дальше.

В третьих, если пользователь всё же не закреплён ни за одной компанией, мы ищем в сессии код активации приглашения. Если таковой находится — активируем пользователя. Если нет — отправляем его на специальную страничку и предлагаем создать новую компанию.

Всё довольно просто. Но нигде не видно сохранения кода приглашения в сессии. Как так? Об этом поговорим чуть позже.

Остаются публикации. Судя по коду в роутере на клиенте нам понадобится информация о приглашении, компании и пользователе который отправил приглашение. В следующем разделе станет понятно зачем.

Для этого в файле публикаций добавим такой код:
Файл 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}});
});

Так, как показано выше, мы можем передать не клиента минимум необходимой информации. Можно было бы даже не передавать на клиент все поля, а только избранные, но думаю и это лишнее.

Интерфейс


Вся наша логика ничто без интерфейса. Весь интерфейс будет состоять у нас из нескольких шаблонов:
  • inviteList — панель с перечнем приглашений и формочкой отправки нового приглашения;
  • inviteSend и inviteSend — форма приглашения с автоматически генерируемым набором полей (у нас всего одно);
  • activateInviteToCompany — несколько вариантов отображения информации приглашаемому пользователю об активируемом этим пользователем приглашении.

Файл 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>

Вот так вот.

Шаблон inviteList отображает для нас форму отправки inviteSend и перечень имеющихся у компании приглашении. на основании роли пользователя и статуса приглашения отображается либо скрывается форма отправки приглашения и кнопка «Удалить».

Шаблон activateInviteToCompany также на основании статуса приглашения (отправлено / активировано) и состояния пользователя (авторизован / не авторизован) отображает либо приглашение к активации, либо информацию о невозможности использования приглашения по одной из причин.

Причём, страница приглашения пользователя шаблона activateInviteToCompany содержит подробную информацию для пользователя том кто, зачем и куда его приглашает. Это очень важно, так как пользователь перешедший по ссылке из письма не факт что захочет принимать приглашение непонятно от кого в непонятный сервис.

Вот так выглядит наш виджет приглашений:


А вот так, страница приглашения пользователя:


Логика на стороне клиента


Фух… Третий час написания статьи…

Осталось немного. Нам пора наконец-то повесить логику на наш интерфейс и связать все части системы воедино. Для этого,

Файл 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 на дублирование и корректность, после чего либо выводится сообщение об ошибке, либо вызывается серверный метод отправки приглашения.

По нажатию кнопки «Удалить приглашение» происходит вызов серверного метода удаления приглашения. Его мы видели выше.

Чуть ниже расположено несколько простых хелперов для панели со списком приглашений, управляющих отображением каждого приглашения.

В самом конце представлено также несколько хелперов используемых на странице активации приглашения.

Самый интересный кусок данного кода, это определение Template.activateInviteToCompany.rendered. Именно данный код отвечает за сохранение в сессии переменной с кодом активации.

Итог и живая демонстрация


Итоговая логика заключается в том, что пользователь может зайти на наш сервис по ссылке из письма, в сессии сохранится информация об его коде активации приглашения, он может попереключаться между доступными страницами приложения, почитать описание на главной странице, почитать отзывы из Reformal и если ему станет интересно — он просто может нажать кнопку «Войти / Зарегистрироваться», выбрать подходящий способ аутентификации, войти в систему и автоматически стать участником компании (группы) и сразу включиться в работу.

Либо же пользователь может просто зайти на сайт, увидеть что он хорош, зарегистрироваться, создать новую компанию и пригласить своих коллег к совместной работе.

Вот, собственно, и всё. Думаю, получилось очень неплохо, а вы что скажете?

Живую демонстрацию проекта можно посмотреть по адресу p.kellot.ru
Полноценного тестирования с прогоном автотестов я не делал, но несколько раз вручную проделал все операции перед публикацией кода. Если вдруг что пойдёт не так во время ознакомления с демонстрацией — прошу сообщить об этом в Reformal.

Only registered users can participate in poll. Log in, please.

Стоит ли и дальше писать примеры и туториалы по MeteorJS?

Share post

Comments 3

    0
    Где тут ReactJS?
      0
      Убрал, не глянул хабы перед публикацией а по умолчанию накидал первое что пришло в голову. Пасиб.
      0
      Указанный Вами синтаксис iron-router перестанет поддерживаться в ближайших релизах. Думаю, лучше будет в следующих, искренне надеюсь, туториалах, использовать новый синтаксис, он не сильно отличается.
      Ещё не понял проверку Meteor.isClient в роутере…

      Only users with full accounts can post comments. Log in, please.