Railsで、deviseとdevise_invitableをつかって招待機能を実装する

Deviseをつかってサインアップ、サインインの実装をしていて、招待機能もつけたいよね。と思ったときにやったことを詳しめに残しておく。

Deviseの実装は一通り終わっていてサインインもサインアップもできるようになってることが前提。既存のサービスに招待機能も、という方が対象にちょっとだけ詳しめに書いた。

## 環境

Ruby: 2.2.0
Rails: 4.2.0
devise: 3.5.2
devise_invitable: 1.5.5

## 1. DeviseInvitableをいれる

```
gem 'devise_invitable'
```

```
$ bundle exec rails g devise_invitable:install

  insert  config/initializers/devise.rb
  create  config/locales/devise_invitable.en.yml
```

enしか自動で生成されないので `devise_invitable.ja.yml` をつくっておく。

```ruby:config/locales/devise_invitable.ja.yml
ja:
  devise:
    failure:
      invited: 'アカウントを作成するには、保留中の招待を承認してください。'
    invitations:
      send_instructions: '招待メールが%{email}に送信されました。'
      invitation_token_invalid: '招待コードが不正です。'
      updated: 'パスワードが設定されました。お使いのアカウントでログインできます。'
      updated_not_active: 'パスワードが設定されました。'
      no_invitations_remaining: 'これ以上招待できません。'
      invitation_removed: '招待を取り消しました。'
      new:
        header: '招待する'
        submit_button: '招待メールを送る'
      edit:
        header: 'パスワードを設定する'
        submit_button: 'パスワードを設定する'
    mailer:
      invitation_instructions:
        subject: '招待を承認するには'
        hello: 'こんにちは、%{email}さん'
        someone_invited_you: '%{url}に招待されました。以下のリンクから承認できます。'
        accept: '招待を承認する'
        accept_until: 'この招待は%{due_date}まで有効です。'
        ignore: '招待を承認しない場合は、このメールを無視してください。<br />あなたのアカウントは上記のリンク先にアクセスしパスワードを設定するまでは作成されません。'
  time:
    formats:
      devise:
        mailer:
          invitation_instructions:
            accept_until_format: '%Y年%m月%d日%H時%M分'
```

## 2. 招待用のカラムを追加する

Deviseと紐付いているモデルを指定してmigrationを自動生成する。

```
$ bundle exec rails g devise_invitable User

  insert  app/models/user.rb
  create  db/migrate/20160323045302_devise_invitable_add_to_users.rb
```

usersテーブルにinvitation用のカラムが追加される。`:invitations_count` に何人のユーザーを招待したか（招待されて登録した数ではなく、招待メールを送信した人数）が自動でカウントされたり、`:invited_by_id` にはどのユーザーによって招待されたかといった情報が自動で入ってくれたりして素晴らしい。

```
change_table :users do |t|
  t.string     :invitation_token
  t.datetime   :invitation_created_at
  t.datetime   :invitation_sent_at
  t.datetime   :invitation_accepted_at
  t.integer    :invitation_limit
  t.references :invited_by, polymorphic: true
  t.integer    :invitations_count, default: 0
  t.index      :invitations_count
  t.index      :invitation_token, unique: true # for invitable
  t.index      :invited_by_id
end
```

```
$ bundle exec rake db:migrate  
```

## 3. modelにdevise_invitableのオプションを追記する

`:invitable` と `invite_for: 24.hours` を新たに加えた。このあたり少しハマりどころだったので後述する。

```ruby:app/models/user.rb
class User < ActiveRecord::Base

  devise :invitable, :database_authenticatable, :registerable,　:recoverable,
         :rememberable, :trackable, :validatable, :omniauthable,
         :authentication_keys => [:login], invite_for: 24.hours

end
```

他にもいろいろ指定できるっぽい。

- invitation_limit: 各ユーザーが招待可能な人数のリミットを指定できる。デフォルトではリミットなし。
- invite_key: 招待を送ったユーザを識別するキーを変更できる。デフォルトではメールアドレスが使われる。
- resend_invitation: 招待メールを再送できるかどうかを変更できる。デフォルトは再送ができる。
- validate_on_invite: 招待メールが送信された段階でユーザーの確認なしにユーザーをつくってしまう。

## 4. viewをジェネレートコマンドでつくる

view生成用のジェネレートコマンドが用意されているのでそれを使う。viewファイルは `app/views/devise` 配下にDeviseでつかわれるものと一緒にまとめられる。

```
$ bundle exec rails g devise_invitable:views

  invoke  DeviseInvitable::Generators::MailerViewsGenerator
   exist  app/views/devise/mailer
  create  app/views/devise/mailer/invitation_instructions.html.erb
  invoke  form_for
  create  app/views/devise/invitations
  create  app/views/devise/invitations/edit.html.erb
  create  app/views/devise/invitations/new.html.erb
```

初期状態では招待されたユーザーがアカウントをつくるときに、ユーザー名（:username）を保存できないのでslimで以下のように書き換えた。

```app/views/invitations/new.html.slim
h1 Invitations#new
p Find me in app/views/invitations/new.html.erb

= form_for resource, :as => resource_name, :url => invitation_path(resource_name), :html => {:method => :post} do |f|
  = devise_error_messages!

  = f.label :email
  = f.text_field :email

  = f.submit
```

```app/views/invitations/edit.html.slim
h1 Invitations#edit
p Find me in app/views/invitations/edit.html.erb

= form_for resource, :as => resource_name, :url => invitation_path(resource_name), :html => { :method => :put } do |f|
  = devise_error_messages!
  = f.hidden_field :invitation_token

  # :usernameを新しく追加した
  = f.label :username
  = f.text_field :username

  = f.label :password
  = f.password_field :password

  = f.label :password_confirmation
  = f.password_field :password_confirmation

  = f.submit
```

## 5. controllerをつくる

controllerは自動で生成されないのでつくる。デフォルトの処理から変更したいときはoverrideするだけ。

```ruby:users/invitations_controller.rb
class Users::InvitationsController < Devise::InvitationsController
  def new
    super
  end

  def create
    super
  end

  def edit
    super
  end

  def update
    super
  end

  def destroy
    super
  end
end
```

## 6. :usernameを許可するためにStrong Parameterに変更を加える

application_controllerでStrong Parameterが有効になっている場合にはパーミッションの設定を変更する必要があるので２行追加する。`for(:invite)` は招待メールの送信のため、`for(:accept_invitation)` は招待されたユーザーがアカウントをつくるために必要。

```ruby:app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_filter :configure_permitted_parameters, if: :devise_controller?

  protected
    
    def configure_permitted_parameters
      devise_parameter_sanitizer.for(:sign_up) ...
      devise_parameter_sanitizer.for(:sign_in) ...
      devise_parameter_sanitizer.for(:account_update) ...

      # :inviteと:accept_invitationに:usernameを許可する
      devise_parameter_sanitizer.for(:invite) { |u| u.permit(:email, :username) }
      devise_parameter_sanitizer.for(:accept_invitation) { |u| u.permit(:password, :password_confirmation, :invitation_token, :username) }

      # こういう書き方もできるっぽい
      devise_parameter_sanitizer.for(:invite) << :username
      devise_parameter_sanitizer.for(:accept_invitation) << :username
    end

end
```

## 7. routingを設定する

```ruby:config/routes.rb
Rails.application.routes.draw do

  # devise/users
  devise_for :users, controllers: {
    sessions: 'users/sessions',
    registrations: 'users/registrations',
    passwords: 'users/passwords',

    # 以下を追記
    invitations: 'users/invitations'
  }

end
```

サインインした状態で `/users/invitation/new` から招待メールを送信、シークレットウィンドウとかでメールに記載のリンクから招待ユーザーとしてサインアップができるようになってるはず（Deviseで用意されている通常のサインアップとは別フローになる）。

## 8. ハマったところ

### :invite_forを省略した場合は現在時刻が入る

省略したり書き忘れたりした場合は現在時刻（送信時刻）がメール内に入る模様。07時30分に招待メールを送信しているのに、`この招待は2016年03月25日07時30分まで有効です。` みたいな感じになってしまう。

### :invite_forで指定する時刻はUTC換算なので9時間前が入る

invite_forに指定した期限はUTCで時間換算されてるのでJSTの感覚で書くとだめ。9時間分の時差も含めて指定しないといけない。他に良い方法があれば教えてください...

### :invitableと:invite_forは順番変えると動かない

最後までわからなかったのだけれど、この２つは順番が変わると動かなかった。詳しい人がいれば教えてください...

```ruby:app/models/user.rb
class User < ActiveRecord::Base

  # これは大丈夫
  devise :invitable, :database_authenticatable, :registerable,　:recoverable,
         :rememberable, :trackable, :validatable, :omniauthable,
         :authentication_keys => [:login], invite_for: 24.hours

  # これは大丈夫じゃない
  devise :invitable, invite_for: 24.hours, :database_authenticatable, :registerable,　:recoverable,
         :rememberable, :trackable, :validatable, :omniauthable,
         :authentication_keys => [:login]

  # これは大丈夫じゃない
  devise :database_authenticatable, :registerable,　:recoverable,
         :rememberable, :trackable, :validatable, :omniauthable,
         :authentication_keys => [:login],
         :invitable, invite_for: 24.hours

end
```

### :invitations_countがカウントアップされない

ユーザーAがユーザーBを招待したとき（ユーザーBがユーザー登録をしたタイミングではなく、招待メールが送信されたとき）、usersテーブルのinvitations_countカラムの値が自動でカウントアップされる仕様になっている。デフォルトでは無効になってるぽいのでdeviseの設定ファイルから以下の行を探して有効にする。

```ruby:config/initializers/devise.rb
config.invited_by_counter_cache = :invitations_count
```

ドキュメントも充実しているのでここからいろいろカスタマイズしたい人はこちら。

[scambra/devise_invitable: An invitation strategy for devise](https://github.com/scambra/devise_invitable)