【Rails】devise_invitableを使用した招待機能

# はじめに
こんにちは！オンラインスクールでRuby on Railsを中心に学習中のくりと申します🐣

個人開発で旅行のしおりを投稿するアプリを作成しており、
その際にGemの**devise_invitable**のカスタマイズに挑戦したので、その実装について備忘録としてまとめます。

:::note warn
初学者のアウトプット記事です。間違いやより良い方法がありましたら、コメント等で教えていただけますと幸いです🙇‍♀️
:::

# 概要
開発中の旅行のしおりを投稿するサービスでは、しおりを特定のメンバー間で共有できるようにするため、`User` と `TravelBook` の間に多対多のリレーションを組んでいます。しおりの所有者はメンバーを招待できるようにするため、**devise_invitable**をカスタマイズし招待機能を実装しました。

### 前提
本記事では、deviseとdevise_invitableの導入は完了している状態を前提として進めます。
deviseとdevise_invitableの導入方法については、ページの最後で紹介します。
また、実装にあたり、以下の記事を参考にさせていただきました。非常に分かりやすく解説されているので、ぜひご覧ください！

https://qiita.com/asumi0848/items/b60c47bd5ca0e6deb535

### 環境
- macOS（Apple Silicon）
- Docker
- Ruby 3.2.3
- Rails 7.2.1
- PostgreSQL

### 実装手順
1. Usersテーブルに招待するしおりのidを一時保存するカラムを追加
2. invitations/new.html.erbの編集
3. InvitationsController#createをカスタマイズ
4. InvitationsController#updateをカスタマイズ


# 1. Usersテーブルに招待するしおりのidを一時保存するカラムを追加

後述するInvitationsControllerで招待するしおりのidを取得するために、
Usersテーブルにidを一時保存用のカラムを追加します。
私はこの一時保存用のカラムとして、「invited_by_travel_book_id」を追加しました。

**マイグレーションの作成**
```
$ rails g migration AddInvitedByTravelBookIdToUsers
```
```ruby
class AddInvitedByTravelBookIdToUsers < ActiveRecord::Migration[7.2]
  def change
    add_column :users, :invited_by_travel_book_id, :string
  end
end
```
**マイグレーションの適用**
```
$ rails db:migrate
```


# 2. invitations/new.html.erbの編集
招待メールを送信するために、メールアドレスを入力するビューを編集します。
```diff_erb:invitations/new.html.erb
- <%= form_for(resource, as: resource_name, url: invitation_path(resource_name), html: { method: :post }) do |f| %>
+ <%= form_for(@user, as: resource_name, url: invitation_path(resource_name), html: { method: :post }) do |f| %>

  <%= render "devise/shared/error_messages", resource: resource %>

  <% resource.class.invite_key_fields.each do |field| -%>
      <%= f.label :email %>
-      <%= f.text_field field %>
+      <%= f.email_field field %>

+      <%= f.hidden_field :travel_book_uuid, value: params[:travel_book_uuid] || travel_book_uuid %>
  <% end -%>

  <div class="actions">
    <%= f.submit t("devise.invitations.new.submit_button") %>
  </div>
<% end %>
```

## 編集内容のポイント
### formのヘルパーを編集
デフォルトで生成されるフォームがtext_fieldだったため、email_fieldへ変更しました。

### 招待するしおりのidを渡す
hidden_fieldを追加し、招待メール送信時に招待したいしおりのidを渡しています。
しおりのidは最初params経由で受け取ります。
メールアドレスが空白の状態で招待メール送信ボタンが押されるとバリデーションエラーが発生しますが、その際は後述のコントローラーでlocal変数「travel_book_uuid」経由でしおりのidを受け取ります。

### form_forのモデル属性を書き換え
form_forのresourceを`@user`に書き換えています。
createアクションのカスタマイズの項目にも後述しますが、createアクションをオーバーライドしているためか、resourceのままだとバリデーションエラー(メールアドレスが空白の場合、バリデーションエラーを表示させたい)を発生させることができませんでした。

# 3. InvitationsController#createをカスタマイズ
createアクションは招待メール送信ボタンクリック時に呼び出されるアクションです。
```diff_ruby:app/controllers/users/invitations_controller.rb 
def create
  @user = User.new
  user_email = params[:user][:email]
  travel_book_uuid = params[:user][:travel_book_uuid]
  user = User.find_by(email: user_email)

  if user.present?
    # 既存のユーザーに招待を送信
    user.invite!(current_user)
    user.update(invited_by_travel_book_id: travel_book_uuid)
    redirect_to travel_book_path(travel_book_uuid), notice: "招待メールが #{user_email} に送信されました"
  else
    # 存在しないユーザーに招待を送信
    user = User.invite!({ email: user_email }, current_user)
    user.update(invited_by_travel_book_id: travel_book_uuid)
    if user.valid?
      redirect_to travel_book_path(travel_book_uuid), notice: "招待メールが #{user_email} に送信されました"
    else
      flash.now[:alert] = "メールアドレスを正しく入力してください"
      render :new, status: :unprocessable_entity, locals: { travel_book_uuid: travel_book_uuid }
    end
  end
end
```

## 編集内容のポイント
### ■`@user`について
`@user`にすることでdeviseのresourceを書き換えています。
createアクションをオーバーライドしているためか、resourceのままだとバリデーションエラー(メールアドレスが空白の場合、バリデーションエラーを表示させたい)を発生させることができませんでした。
こちらについては理解が曖昧なので、またわかったことがあれば更新したいと思います。

### ■既存ユーザーと新規ユーザーで処理を分ける
paramsからメールアドレスを取得します。
存在しないユーザーの場合は、`User.invite!`でユーザーが作成され、招待メールが送信されます。
すでに存在するユーザーには`user = user.invite!`のようにすることで、ユーザーを新規作成せず招待メールを送信できます。

**参考資料**

https://github.com/scambra/devise_invitable?tab=readme-ov-file#send-an-invitation

### ■招待するしおりのidを渡す
paramsからメールアドレス同様にしおりのidを取得します。
先ほどusersテーブルに追加したinvited_by_travel_book_idカラムに、params経由で取得したしおりのidを一時保存します。
これによって招待されたユーザーが招待を承認した時(updateアクション)で、招待されているしおりのidを取得してしおりに参加することができます。
最初は、カラムを追加せずにinviteメソッド経由で変数を渡せないか検討してみたのですが、実現することができずusersテーブルのカラム経由で渡すことにしました。
こちらについて良い方法があればぜひとも教えていただきたいです🙇‍♀️

### ■招待メール送信後のリダイレクト先について
公式にて、デフォルトではcreateアクションの後はsigned_in_root_pathにリダイレクトされると記載されていました。
> After an invitation is created and sent, the inviter will be redirected to after_invite_path_for(inviter, invitee), which is the same path as signed_in_root_path by default.

招待メール送信後のリダイレクト先を指定したかったため、`user.valid?`によって、バリデーションエラーがなければ指定のパスにリダイレクトするように設定しました。

### ■バリデーションエラーがある場合について
バリデーションエラーがある場合は、newアクションをレンダリングしますが、この時ローカル変数で招待するしおりのidを渡すことで値を取得できるようにしました。


# 4. InvitationsController#updateをカスタマイズ
updateアクションは招待されたユーザーがパスワードを入力して招待を承認した時に呼び出されるアクションです。
全体として以下のように変更しました。
```diff_ruby:app/controllers/users/invitations_controller.rb 
def update
  super

  resource = self.resource

  if resource.errors.empty? && resource.invited_by_travel_book_id.present?
    # 中間テーブルに招待ユーザーと対象のしおりのidを保存
    user_travel_book = UserTravelBook.new(
      user_id: resource.id,
      travel_book_uuid: resource.invited_by_travel_book_id
    )
    if user_travel_book.save
      flash[:notice] = "しおりのメンバーに追加されました"
    else
      flash[:alert] = "しおりのメンバーに追加できませんでした"
    end
  end
end

protected

# ユーザーが招待を承認したあとに招待されたしおりの詳細ページにリダイレクト
def after_accept_path_for(resource)
  travel_book_id = resource.invited_by_travel_book_id
  if travel_book_id.present?
    travel_book_path(travel_book_id)
  else
    root_path
  end
end
```

## 編集内容のポイント

### ■superについて
superでdeviseのデフォルトのupdate処理を実行するようにしました。
superをつけない場合、updateでdeviseがデフォルトで行うサインインやバリデーションエラーの表示も行われず、特にバリデーションエラーの表示に苦戦したからです。
最初つけずに実装していた際に、承認画面でパスワードを空白にしても承認できてしまい、それを回避するためにこのようなコードになりました。

### ■中間テーブルへの保存について
ユーザーとしおりは多対多リレーションであり、user_travel_bookという中間テーブルを使用しています。
中間テーブルにユーザーとしおりのidを保存することでしおりのメンバーに追加します。


### ■招待承認後のリダイレクト先について
承認後のリダイレクト先もsigned_in_root_pathですが、`after_accept_path_for`によって、承認後のリダイレクト先をオーバーライドできます。
invited_by_travel_book_idカラムからしおりのidを取得することで、招待されたしおりの詳細ページへリダイレクトするようにしました。

https://github.com/scambra/devise_invitable?tab=readme-ov-file#integration-in-a-rails-application


# 最後に
初めてGemのオーバーライドを行い、動作をカスタマイズしました。
devise・devise_invitableともにドキュメントは充実しているものの、実現したい動作をさせるためのカスタマイズに苦戦してしまいました。
現在も、曖昧な理解や実現方法がわからない箇所が複数あるので、引き続き開発しながら本記事もアップデートしていければと思います！

最後までご覧いただきありがとうございました🌷

# 参考・引用資料
### 引用
https://github.com/scambra/devise_invitable

### 参考
**deviseの導入**

https://qiita.com/mmmasuke/items/7d5c47b4a40f6912adf2

**devise_invitableの導入**

https://qiita.com/himatani/items/907900f1f0d0e4f379b5

**devise_invitableのカスタマイズ**

https://qiita.com/asumi0848/items/b60c47bd5ca0e6deb535

https://qiita.com/tak8_al/items/08274dd9228363642555