【Rails7】超簡単、招待リンク生成機能を作ってみた

# はじめに
こんにちは、こんばんは、初めまして。
プログラミングスクールRUNTEQで学習し、現在就活フェイズに入った[massan](https://qiita.com/massan-E/questions)です。

ひょんなことからRUNTEQ内でアウトプットリレーという企画に巻き込まれてしまい、毎週月曜日に何かしら記事を書くことになってしまいました。

今週は技術記事ということなので、今回は自分がRUNTEQの卒業制作として個人開発した[Music Hour](https://musichour.massan-e.com/)で実装した、「招待リンク生成機能」についてアウトプットさせていただこうと思います。

# Music Hourとは？
「Music Hour」はラジオ番組へのお便りをイメージしたツールです。リスナーからのお便りを募集し、配信などで公開するためのお手伝いをします。

具体的には、配信者が番組と呼ばれるページを作成し、リスナーにそのページに対してお便りを投稿してもらうといった、簡単に言えば掲示板のようなものです。

投稿されたお便りは一部を除いて配信者しか見ることができず、配信者は投稿されたお便りをランダムに表示したり、既読、非表示などの状態を設定してお便りを管理したりすることができるようになります。

その他の機能としては配信者が番組ページを管理運営するために、番組の表示、非表示機能や、お便り箱の管理機能なども実装しています。

現在はプロトタイプとしてMusic Hourを運営していますが、ゆくゆくは配信者がリスナーを獲得し、維持するためのお手伝いができるような新たなプラットフォームとして提供できるようにしていきたいと考えています。

https://musichour.massan-e.com/

https://github.com/massan-E/MusicHour



# 対象読者
- MVCやRailsの基本的な動作が理解できる
- 認証機能がある程度理解できる
- ユーザーの招待や各種機能へのユーザーの招待機能を実装してみたい
- Music Hourの招待リンク生成機能について知りたい

# 環境
- Windows11
- Ubuntu 24.04 LTS (WSL2)
- Docker
- Ruby 3.3.6
- Rails 7.2.1

# 前提
DBの関係する部分は以下のような感じ
なんのことはないDeviseを使って登録したユーザーがタイトルと本文、その他カラムを持った掲示板と関連付けされているという感じ

[![Image from Gyazo](https://i.gyazo.com/abe38c08910fdb02ed85635520116aba.png)](https://gyazo.com/abe38c08910fdb02ed85635520116aba)


今回やりたかったことはuserがprogramを複数人で編集可能にするために、他のuserに対してprogramの編集権限を付与できるようにすることです
つまり、programの編集（管理運営）に招待することができるようにしたかったんです。

よくあるのは、自分のアプリ内でユーザーを検索　→　programに対して招待したいユーザーを選択　→　招待を送信　→　招待されたユーザーはアプリ内の通知から参加

または招待の送信と通知の部分をメールに代替してもらう

みたいな感じだと思います。

しかしこの方法だと私は以下の点が気に入りませんでした。

- 複数人招待したい場合、必ず「ユーザーを検索し、追加する」といった作業が、複数回必要になる（操作がめんどくさそう）
- 「アプリの通知？どこ？」「メール来てないけど？」のようなやり取りが発生する場合がある
- 通知機能作るのがめんどくさい（ここ重要。）

というわけでDiscordなどのサービスに使用されているような「招待リンク制」にし、リンクを踏んだらprogramの管理運営に参加できるという方法をとることにしました。

# 招待リンクの考え方
招待リンクについて考えてみましょう。
リンクを踏めば参加確認ページに遷移する。といった感じにするのならば
ルーティング設定、コントローラーの設定を行い、そのprogram専用の参加ページを作成すればよさそうですね

しかし単純にページを作成するだけでは、リンクが予想できてしまった場合に招待したくないuserまで参加できてしまったり、リンクがバレてしまった場合に対応方法がないといったことになってしまいます。

この状況、ユーザーのパスワードリセットの時とよく似ていませんか？
1. ユーザーはパスワードを忘れてしまっているためログインができない。（つまりそのユーザー専用のページにアクセスできない）
1. emailを入力してもらい、そのemailに対してパスワード再設定用ページのリンクを送信
1. ユーザーパスワードリセットページからパスワードを再設定

パスワードリセット機能はどのサービスでもこのような感じですよね？

パスワード再設定のリンクにはそれぞれがユニークでランダムな文字列である「トークン」が
ストロングパラメーターとして含まれており、これがパスワードのような役割を果たしています

さらにこのトークンに有効期限を設定することで、万が一リンクが外部に漏れてしまった場合にもその状態が続いてしまうということを防いでいます。

これを応用すればセキュアな招待リンクの生成が可能そうですね！

Music Hourでは初期にRails tutorialを参考に認証機能を実装していたので、Rails tutorial 9章、12章を参考にこの機能を実装していきます。

# 今回の要件
前置きが長くなりましたが以上を踏まえて今回実装に必要なものを箇条書きにしてみます
- **招待リンクの生成**
    - 掲示板を作成したユーザーが、特定の他のユーザーを招待するためのユニークな招待リンクを生成できる
- **リンクの有効期限設定**
    - 招待リンクには有効期限を設定できる（今回は３日）
- **リンクの再生成機能**
    - 既存の招待リンクを無効にし、新たにリンクを生成できる
- **招待されたユーザーの登録**
    - 招待リンクをクリックしたユーザーが番組制作に参加できる
- **招待状況の確認**
    - 番組制作に参加しているユーザー一覧の表示

### 実装イメージとしてはこんな感じ
#### 招待リンクの生成
[![Image from Gyazo](https://i.gyazo.com/1e4b77ccc29b74e8524726feecfbbb0d.gif)](https://gyazo.com/1e4b77ccc29b74e8524726feecfbbb0d)

#### 招待リンクからの参加
[![Image from Gyazo](https://i.gyazo.com/1e6a731c0b0928a34b6dc462fc95a19e.gif)](https://gyazo.com/1e6a731c0b0928a34b6dc462fc95a19e)

# 実装

## 必要なテーブルとカラムの追加
結論から言うとこんな感じになります

`user_participations`テーブルを追加し
programテーブルに`inbitation_digest`と`send_invitation_at`カラムを追加しました

[![Image from Gyazo](https://i.gyazo.com/6eda58dd2a58d655e38a9e146b4585f3.png)](https://gyazo.com/6eda58dd2a58d655e38a9e146b4585f3)

### 追加されたものの説明
- **user_participationsテーブル**
    - programを複数のuserで管理するので当然中間テーブルが必要になります
- **programテーブル**
    - **inbitation_digest**
        - リンクに含めるトークンをハッシュ化したものを保存しておきます。このカラムに保存されているものとリンクに含まれているトークンをハッシュ化したものを比べることで正しいリンクかどうかを判断します。
    - **send_invitation_at**
        - トークンを生成した時間を記録しておきます。この時間を用いてリンクが有効期間内かどうかを確認します。

今回はすでにuserとprogramの関連付けがされていたので、テーブルとカラムを追加しただけで元の関連付けを削除していません。
これは最初にそのprogramを作成したuser（host）を把握するためにそのままにしてあります。
programを管理できるuserの中でhostとそれ以外で使える機能を分けておいたほうがトラブルになりにくいと思ったのでこういう形にしました。（例えばhost以外がprogramを削除してしまうなど）

しかし、こういったものは`user_participations`中間テーブルにroleカラムを追加して管理したほうが後々便利だと思うので、今回の実装はあまりおすすめできません。
今回はすでにレコードが複数あり、修正が面倒だったので暫定的にこうなっています。

```ruby
class CreateUserParticipations < ActiveRecord::Migration[7.2]
  def change
    create_table :user_participations do |t|
      t.references :user, foreign_key: true
      t.references :program, foreign_key: true

      t.timestamps
    end
    add_index :user_participations, [ :user_id, :program_id ], unique: true
  end
end
```

```ruby
class AddInvitationToPrograms < ActiveRecord::Migration[7.2]
  def change
    add_column :programs, :invitation_digest, :string
    add_column :programs, :send_invitation_at, :datetime
  end
end
```

## モデル関連の設定とロジックの追加

次にモデルへ必要な設定を追加し、リンク生成のためのロジックも追加していきます。

### UserParticipationモデル
```ruby:app/models/user_participation.rb
class UserParticipation < ApplicationRecord
  belongs_to :user
  belongs_to :program

  validates :user_id, uniqueness: { scope: :program_id }
end
```

いつも通りの中間テーブル用のアソシエーション追加ですね。
<br>

### Userモデル
```ruby:app/models/user.rb
class User < ApplicationRecord

  # 今回関係のない部分は省略

  has_many :programs, dependent: :destroy
  has_many :user_participations, dependent: :destroy
  has_many :joined_programs, through: :user_participations, source: :program

  # ランダムなトークンを生成
  def self.new_token
    SecureRandom.urlsafe_base64
  end
  
  # 渡された文字列のハッシュ値を返す
  def self.digest(string)
    cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                                  BCrypt::Engine.cost
    BCrypt::Password.create(string, cost: cost)
  end
end
```
アソシエーションの追加とトークン生成のためのメソッドを二つ追加しておきます。
今回の場合は初期にgemなしで認証機能を実装していた時の名残で`self.digest`、`self.new_token`が残っていたのでこちらを流用してます。
user関連で使わないならこれはprogramに移動したほうがよさげだと思います。

#### - self.new_token
Ruby標準ライブラリ「SecureRandom」の`urlsafe_base64`メソッドを用いてランダムな文字列を生成します。

:::note info
64種類の文字からなる長さ22の文字列なので、2つのトークンがたまたま完全に一致する（衝突する）ことはまずありません。

urlsafe_base64メソッドは、元々はURLを安全にエスケープするために用いられるメソッドらしいです。
:::

#### - self.digest(string)
Railsをインストールすると最初から導入されている「BCrypt」というgemを用いて渡された文字列をハッシュ化し、返します

<br>

### Programモデル
```ruby:app/models/program.rb
class Program < ApplicationRecord
  attr_accessor :invitation_token   # トークンを一時的に保存しておくための仮想的な属性

  belongs_to :user
  has_many :user_participations, dependent: :destroy
  has_many :participants, through: :user_participations, source: :user

  # その他今回関係のない部分は省略

  # トークンを生成、ハッシュ化して保存。その時の時間も保存する
  def create_invitation_digest
    self.invitation_token = User.new_token
    update_attribute(:invitation_digest,  User.digest(invitation_token))
    update_attribute(:send_invitation_at, Time.zone.now)
  end

  # トークンが正しいものか確認するメソッド
  def authenticated?(invitation_token)
    return false if invitation_digest.nil?
    BCrypt::Password.new(invitation_digest).is_password?(invitation_token)
  end
  
  # トークンが有効期限内かどうか確認するメソッド
  def invitation_expired?
    send_invitation_at < 3.days.ago
  end

  # viewで招待リンクの有効期限を表示するためのフォーマット調整
  def expiration_time
    (send_invitation_at + 3.days).strftime("%Y年%m月%d日 %H:%M")
  end
end
```
こちらもアソシエーションの追加とリンクに含まれるトークン関連のロジックを追加しています。


#### - create_invitation_digest
トークンを生成し実際にはDBに保存はしませんが、仮想的な属性の`invitation_token`にいったん保存してクラスのインスタンスに関連づいているものとして簡単に呼び出せるようにします。（`program.invitation_token`）
そしてそのトークンを`User.digest(invitation_token)`でハッシュ化したものをDBに実際に保存します。
同時に、有効期限を設定するためにトークンを生成した時間もDBに記録しておきます。

:::note info
このメソッドで保存まで行ってしまうので、新しいものが生成されるたびに古いものが上書きされて無効になってきます。
招待リンクが外部に漏れてしまった場合もリンクを新たに生成すれば即座に漏れたリンクを無効にできます。
:::

#### - authenticated?(invitation_token)
gem 「BCrypt」の内部実装を参考にしたもので、`invitation_token`をハッシュ化したものと`invitation_digest`に保存されているものを比べて同一のものであれば`true`を返します。

## ルーティング、コントローラー、ビューの追加

### ルーティングの追加
まずはルーティングの追加です。
programsのリソースにネストさせてinvitationsのリソースを定義します。
`/programs/:program_id/invitations/new`のような感じのURLになり、`params[:program_id]`で簡単にprogramのidを取得することができます。

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

  # 関係ない部分は省略

  resources :programs do
    resources :invitations, only: %i[ show new create edit update ]
  end
  
  # 省略

end
```

### Invitationsコントローラーの追加

```ruby:app/controllers/invitations_controller.rb
class InvitationsController < ApplicationController

  # 一部省略してあります。認可機能を追加してください。

  before_action :set_program, only: %i[ show new create edit update]
  before_action :valid_user, only: %i[ edit update ]
  
  # gem deviseのメソッドでログインしているかを確認
  before_action :authenticate_user!, only: %i[ show new create edit update ]
  
  before_action :check_expiration, only: %i[ update ]

  def show
    @expiration_time = @program.expiration_time
  end

  def new; end

  def create
    @program.create_invitation_digest
    flash[:notice]= "招待リンクを作成しました"
    redirect_to program_invitation_path(@program, @program.invitation_token)
  end

  def edit; end

  def update
    participation = UserParticipation.new(user: current_user, program: @program)
    if participation.save
      flash[:notice]= "#{@program.title}の制作に参加しました"
      redirect_to @program
    else
      redirect_to @program, danger: "既に制作に参加しています"
    end
  end

  private
    def set_program
      @program = Program.find(params[:program_id])
    end

    def check_expiration
      if @program.invitation_expired?
        flash[:danger] = "招待リンクが期限切れです"
        redirect_to root_path
      end
    end

    def valid_user
      unless @program && @program.authenticated?(params[:id])
        redirect_to root_url
      end
    end
end
```

先ほどモデルに追加したメソッドを使って
1. 招待リンク生成用のページに遷移（new）
1. 招待リンクを生成しそれを表示（create →　show）
1. 招待されたユーザーは招待リンクにアクセスすると参加確認ページへ（edit）
1. 参加確認ページの「参加する」ボタンを押すと実際にDBにprogramと現在のユーザーの関連付けが保存される（update）

という感じで実装しています。

`before_action :valid_user`や`before_action :check_expiration`でアクションの前に招待リンクが有効なものか確認しています。

:::note warn
ここで設定されているもの以外にも gem pundit などを用いて適切な認可機能を追加し、許可されたユーザー以外からアクセスされないようにしてください
:::


### 招待機能関連のビューの追加
```erb:app/views/invitations/new.html.erb
<% content_for :title, "番組制作招待" %>

<div>
  <%= render 'shared/flash_message' %>
  <div>
    <h2>
      <%= @program.title %>
    </h2>
    
    <h3>この番組への招待リンクを生成しますか？</h3>
    
    <%= button_to program_invitations_path do %>
      招待リンクを生成する
    <% end %>
  </div>
</div>
```


```erb:app/views/invitations/show.html.erb
<% content_for :title, "番組制作招待" %>

<div>
  <%= render 'shared/flash_message' %>
  <div>
    <h2>
      招待リンク
    </h2>

    <div>
      <%= "招待リンクは#{@expiration_time}に期限切れになります" %>
    </div>

    <div>
      <div>
        <h3>注意事項</h3>
        <ul>
          <li>期限切れになった場合は再度招待リンクを生成してください</li>
          <li>招待リンクは3日が経過するか再度リンクを生成すると古いものが期限切れになります</li>
          <li>招待したい相手以外には伝えないように気を付けてください</li>
        </ul>
      </div>
    </div>
  </div>
</div>
```

```erb:app/views/invitations/edit.html.erb
<% content_for :title, "番組制作参加" %>

<div>
  <%= render 'shared/flash_message' %>
  <div>
    <h1>
      <%= @program.title %>
    </h1>
    
    <h2>この番組の制作に参加しますか？</h2>
    
    <%= button_to program_invitation_path, method: :patch do %>
      参加する
    <% end %>
  </div>
</div>
```

:::note warn
erbは不要なclass、ボタン等を省略してあります。
:::

## 完成！
ここまで設定し終わると以下のような感じで招待リンクを生成
そのリンクからの参加が可能になります

### 招待リンク生成画面
[![Image from Gyazo](https://i.gyazo.com/1e4b77ccc29b74e8524726feecfbbb0d.gif)](https://gyazo.com/1e4b77ccc29b74e8524726feecfbbb0d)

### 招待リンクからの参加
[![Image from Gyazo](https://i.gyazo.com/1e6a731c0b0928a34b6dc462fc95a19e.gif)](https://gyazo.com/1e6a731c0b0928a34b6dc462fc95a19e)

必要に応じて参加したユーザーが確認できるように一覧等も実装しておくといいですね！
[![Image from Gyazo](https://i.gyazo.com/6ff3e68b4d49c5bca11d53568602e4e5.png)](https://gyazo.com/6ff3e68b4d49c5bca11d53568602e4e5)

# まとめ
- 招待リンクはパスワードリセット機能の応用で簡単に実装できる
- 「SecureRandom」を用いてランダムなトークンを作成、「BCrypt」でそのハッシュ化と有効性の確認を行う

今回この招待機能を自分で実装してみたことによってパスワードリセット機能の復習とgemなどの深堀りをすることができてとても勉強になりました。

ただ実装するだけではなく気になったところを深堀りしてまとめておくと、こんな感じでほかの部分で応用したりといったことができるようになるのでおすすめです！

ちなみに、メールでの招待機能を実装するのであれば gem devise_invitable ってのもあるみたいです。

# 参考資料
Rails tutorial 9章、12章

https://railstutorial.jp/

https://qiita.com/Kawanji01/items/f2d02280041a0f6406e5