Criando um Sistema de Convite com Escopo para Rails

Contexto

Supondo que tenhamos um aplicativo com alguns modelos semelhantes a este:

class User < ActiveRecord::Association
has_many
:memberships
has_many
:user_groups, through: :memberships
end

class UserGroup < ActiveRecord::Association #(Could be a company, club, circle, etc.)
has_many
:memberships
has_many
:users, through: :memberships
end

class Membership < ActiveRecord::Association #(Pass through model)
belongs_to
:user
belongs_to
:user_group
end

Critério

  • Um usuário pode convidar alguém para participar de um grupo de usuários, fornecendo um e-mail
  • Se o usuário existir, ele será adicionado como membro do grupo de usuários
  • Se o usuário não existir, o aplicativo enviará um e-mail com um link para se inscrever e criará automaticamente uma associação para o novo usuário, dando-lhe acesso ao grupo de usuários
  • O convite concede ao usuário convidado acesso apenas ao grupo de usuários para o qual foram convidados

Pré-requisitos

  • Algum tipo de sistema de autenticação com um modelo de usuário. (Devise, Sorcery)
  • Modelos configurados da forma acima mencionada. Os modelos de usuário e grupo de usuários devem ser associados de muitos para muitos. O exemplo acima usa , mas não precisa.has_many :through
  • As permissões configuradas permitem que os usuários vejam os grupos de usuários apenas se forem membros desse grupo. CanCan torna isso maravilhosamente simples.

Começando

Há muitas informações a serem associadas ao convite, então precisamos de um modelo para ele.

Modelos

class Invite < ActiveRecord::Base
belongs_to
:user_group
belongs_to
:sender, :class_name => 'User'
belongs_to
:recipient, :class_name => 'User'
end

class User < AciveRecord::Base
has_many
:invitations, :class_name => "Invite", :foreign_key => 'recipient_id'
has_many
:sent_invites, :class_name => "Invite", :foreign_key => 'sender_id'
end

class UserGroup < ActiveRecord:Base
has_many
:invites
end

Migração

class CreateInvites < ActiveRecord::Migration
def change
create_table
:invites do |t|
t
.string :email
t
.integer :user_group_id
t
.integer :sender_id
t
.integer :recipient_id
t
.string :token
t
.timestamps
end
end
end

Rotas

resources :invites

Agora temos uma ótima maneira de controlar os convites e, se precisarmos adicionar recursos como limites de convite ou tempo de expiração, podemos fazer isso facilmente.

Vamos criar um formulário rápido para um usuário existente enviar um convite. Coloquei este formulário na visualização de edição do Grupo de usuários, mas ele pode ir a qualquer lugar.

Enviar formulário de convite

<%= form_for @invite , :url => invites_path do |f| %>
<%= f.hidden_field :user_group_id, :value => @invite.user_group_id %>
<%= f.label :email %>
<%= f.email_field :email %>
<%= f.submit 'Send' %>
<% end %>

O formulário possui apenas uma entrada, o e-mail do convidado. Há também um campo oculto que especifica o grupo de usuários ao qual a pessoa está sendo convidada a ter acesso, que é o grupo de usuários atual, já que estou colocando-o na visualização.user_group#edit

Também precisaremos de um Mailer para enviar o e-mail. O mailer do convite é muito básico, então não vou entrar em muitos detalhes aqui, mas ele vai enviar para o do convite recém-criado e incluir uma URL de convite que construiremos mais tarde. O mailer terá 2 métodos, um para enviar o e-mail de convite para novos usuários e outro para enviar um e-mail de notificação para usuários existentes.:email

Fazendo um novo convite

Quando um usuário envia o formulário para fazer um novo convite, não precisamos apenas enviar o convite por email, mas também precisamos gerar um token. O token é usado na URL do convite para (mais) identificar com segurança o convite quando o novo usuário clica para se registrar.

Para gerar um token antes que o convite seja salvo, vamos adicionar um before_createfiltro ao nosso modelo de convite.

before_create :generate_token

def generate_token
self.token = Digest::SHA1.hexdigest([self.user_group_id, Time.now, rand].join)
end

Aqui, estou usando o e a hora atual mais um número aleatório para gerar um token aleatório. Você pode usar o que quiser e, quanto mais complexo for o token, (possivelmente) mais seguros serão os convites.:user_group_id

Portanto, agora, quando criarmos um novo convite, ele gerará o token automaticamente. Agora, em nossa createação, precisamos disparar um e-mail de convite (controlado por nosso Mailer), mas SOMENTE se o convite for salvo com sucesso.

 def create
@invite = Invite.new(invite_params) # Make a new Invite
@invite.sender_id = current_user.id # set the sender to the current user
if @invite.save
InviteMailer.new_user_invite(@invite, new_user_registration_path(:invite_token => @invite.token)).deliver #send the invite data to our mailer to deliver the email
else
# oh no, creating an new invitation failed
end
end

Aqui, o InviteMailerleva 2 parâmetros, o convite e o URL do convite, que é constituído da seguinte forma:

new_user_registration_path(:invite_token => @invite.token) #new_user_registration_path is a Devise path. Use the correct registration route for your app
#outputs -> http://yourapp.com/users/sign_up?invite_token=075eeb1ac0165950f9af3e523f207d0204a9efef

Agora, se preenchermos nosso formulário de convite, podemos olhar em nosso log do servidor para ver se um e-mail foi gerado com uma url construída semelhante à acima. Para realmente fazer com que o e-mail seja enviado, você provavelmente precisa configurar um serviço de e-mail de terceiros, como Postmark ou Mandrill .

Registro de usuário recém-convidado

Agora, quando alguém clica no link de convite, é direcionado para a página de registro do seu aplicativo. No entanto, registrar um usuário convidado será um pouco diferente do que registrar um novo usuário. Precisamos anexar este usuário convidado ao grupo de usuários para o qual ele foi convidado durante o registro. É por isso que precisamos do parâmetro token na url, porque agora temos uma maneira de identificar e anexar o usuário ao grupo de usuários correto.

Primeiro, precisamos modificar nosso controlador de registro do usuário para ler o parâmetro do url na newação:

def new
@token = params[:invite_token] #<-- pulls the value from the url query string
end

Em seguida, precisamos modificar nossa visão para colocar esse parâmetro em um campo oculto que é enviado quando o usuário envia o formulário de registro. Podemos usar uma instrução condicional na nova visualização de registro para gerar esse campo quando um parâmetro estiver presente na url.:invite_token

<% if @token != nil %>
<%= hidden_field_tag :invite_token, @token %>
<% end %>

Em seguida, precisamos modificar a createação do usuário para aceitar este parâmetro não mapeado .:invite_token

def create
@newUser = build_user(user_params)
@newUser.save
@token = params[:invite_token]
if @token != nil
org
= Invite.find_by_token(@token).user_group #find the user group attached to the invite
@newUser.user_groups.push(org) #add this user to the new user group as a member
else
# do normal registration things #
end
end

Agora, quando o usuário se registrar, ele automaticamente terá acesso ao grupo de usuários para o qual foi convidado, conforme o esperado.

E se o email já for um usuário registrado?

Não queremos enviar o mesmo e-mail de convite que enviaríamos para um usuário não existente. Este usuário não precisa se registrar novamente, ele já está usando nosso aplicativo, só queremos dar-lhe acesso a outra parte dele. Precisamos adicionar um cheque ao nosso modelo Invite por meio de um before_savefiltro:

before_save :check_user_existence

def check_user_existence
recipient
= User.find_by_email(email)
if recipient
self.recipient_id = recipient.id
end
end

Este método irá procurar um usuário com o e-mail enviado e, se for encontrado, irá anexar a ID desse usuário ao convite, já que isso por si só não faz muito. Precisamos modificar nosso controlador Invite para fazer algo diferente se o usuário já existir::recipient_id

def create
@invite = Invite.new(invite_params)
@invite.sender_id = current_user.id
if @invite.save

#if the user already exists
if @invite.recipient != nil

#send a notification email
InviteMailer.existing_user_invite(@invite).deliver

#Add the user to the user group
@invite.recipient.user_groups.push(@invite.user_group)
else
InviteMailer.new_user_invite(@invite, new_user_registration_path(:invite_token => @invite.token)).deliver
end
else
# oh no, creating an new invitation failed
end
end

Agora, se o usuário existir, ele se tornará automaticamente um membro do grupo de usuários.

Indo além

Essa foi apenas uma implementação básica, e se você estiver usando isso na produção, você pode adicionar algumas coisas adicionais para proteger melhor o sistema e melhorar a experiência do usuário. Felizmente, como temos um modelo de convite separado, podemos fazer essas coisas com bastante facilidade.

  • Adicione uma verificação adicional à ação criar usuário para garantir que o e-mail enviado com o registro corresponda ao do convite do token.
  • Adicione um booleano à tabela Invites e permita aos usuários existentes a capacidade de aceitar ou negar um convite, em vez de receber automaticamente acesso ao grupo de usuários.:accepted
  • Dê aos usuários existentes um número limitado de convites que eles podem enviar e verifique o parâmetro que adicionamos no modelo de usuário. (Ajustes adicionais necessários para ter um limite por grupo de usuários):sent_invites
  • Defina um prazo de validade para os convites e destrua automaticamente os convites após um determinado período de tempo, tornando-os inutilizáveis.

Atualizar

Enviando vários convites de uma vez

Algumas pessoas perguntaram sobre o envio de vários convites de uma vez. Implementei isso no aplicativo atual em que estou trabalhando fazendo o seguinte:

# UserGroupsController
def invite_to
emails
= params[:invite_emails].split(', ')
emails
.each do |email|
invite
= Invite.new(:sender_id => current_user.id, :email => email, user_group_id => @user_group.id)
if invite.save
if invite.recipient != nil
InviteMailer.existing_user_invite(invite).deliver
else
InviteMailer.new_user_invite(invite, new_user_registration_path(:invite_token => @invite.token))
end
end
end