[作ってみた]招待コードを画面上で入力して相互フォロー（夫婦・カップル向けアプリに）

# 概要
個人開発として[「夫婦・カップル専用の家計簿WEBアプリ」](https://www.kyodokoza.com/)をRailsで作成しました。
「その際、”夫婦やカップル（以下、夫婦）の相互フォロー機能”をどのように実装するか？」にかなり悩みました。
参考記事も見つからなかったので、私なりの実装方法を記事として残してみようと思っています。



# 使用技術
フロントエンド
HTML / CSS / Javascript / Bootstrap 5.1.0<br>
バックエンド
Ruby 2.7.4 / Ruby on Rails 6.1.4 / RSpec<br>
インフラ・開発環境
Docker / Docker-compode / CircleCI(CI/CD) / Heroku / SendGrid(mailer)



# 実装の方針
### 前提
- ユーザーの登録にはEmailとパスワードを使います。
- 相互フォローをすることを前提とします。
ツイッターのような非対称なフォロー（「自分がフォローした人」と「自分をフォローした人」とが不一致になりえるような形）は想定していません。
つまり、Relations#new/createのようなアクションを用意し、
このアクションでを実行することで自分とパートナー２人を一つのRelationインスタンスとして保存します。
- ユーザーには「夫婦の機能にフォーカスしてもらう」ため、
パートナー以外とのユーザー同士の交流をすることは考えません。
そのためUsers#indexのようなアクション/ビューは作りません。

以上の前提から、選択肢が２つ考えられました。

##### 選択肢①（招待メールパターン）：
自分のみがまずユーザー登録をする
自分がアプリ上でパートナーのアドレスを入力してパートナーに招待メールを送信
パートナーが招待メールのリンクを踏む
パートナーがそのリンク先からユーザー登録をすることで相互フォローアクションを実行

##### 選択肢②（招待コードパターン）：
自分とパートナーが各々ユーザー登録をする
パートナーがアプリ上で招待コードを発行
自分がアプリ上でパートナーの招待コードを入力
相互フォローアクションを実行

### →今回は選択肢②を採用

（理由）

- 選択肢①の場合、パートナーが誤って「招待メールのリンク以外からユーザー登録をすること」がありうる。その場合、相互フォローが実行されない。2人がユーザー削除をして、再度登録作業からやらないといけない
- 選択肢①の場合、パートナーのメールアドレスを誤って記入する等により、本アプリの名前入りで不特定多数にメールが送られてしまう可能性がある。
- 選択肢②の場合は、上記の懸念が払しょくされる。
- 選択肢②の場合は、ユーザーが相互フォロー機能が実行される瞬間をアプリ上で見ることになるので、リッチなアニメーションなどを用いて、ユーザーのエンゲージメント向上のための工夫を加える余地ができる



#実装の流れ
### ER図
今回は下図の User, User_Relationship, Relationshipの部分が対象です。

![ER.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1766860/dc8f9278-b9b9-b19c-d032-43b189d2a775.png)

### アウトライン
①招待コード作成
②相互フォロー実行

今回関係するrouteは以下の通り

```routes.rb
  get    '/relationships/invitation_code', to: "relationships#invitation_code"
  resources :relationships,       only: [:new, :create] 
```


### ①招待コード作成

招待コードはinvitation_token、招待コードをハッシュ化しでDB保存したものをinvitation_digestとします。
before_actionフィルターを用いて、
"relationships/invitation_code.html"に遷移するごとに、Userモデルのinvitation_digestを更新します。
（招待コードを更新する仕様にすることで、招待コードの流出による被害を防止）

```controller/relationships.rb
  before_action :create_invitation_digest, only: [:invitation_code]

  def invitation_code
  end

  private
    def create_invitation_digest
      current_user.invitation_token = User.new_token
      current_user.attributes = { invitation_digest: User.digest(current_user.invitation_token),
                                  invitation_made_at: Time.zone.now }
      current_user.save(context: :except_password_change)
      # context => modelにて"passwordのpresence: true" となっているバリデーションをskipする
    end
```
<br>
招待コードはreadonlyにし、copyボタンを実装することにより操作性向上。
ワンタップでLINEで共有できる機能を付けるともっとよさそう…。

```relationship/invitation_code.html.erb

    <div class="invitation-code-display">
      <input class="form-control" id="copyTarget" type="text" value="<%= current_user.invitation_token %>" readonly>
      <i class="btn btn-outline-secondary far fa-copy copy-btn" type="button" onclick="copyToClipboard()"></i>
    </div>

    <script>
      function copyToClipboard() {
        var copyTarget = document.getElementById("copyTarget");
        copyTarget.select();
        document.execCommand("Copy");
        alert('コピーしました');
      }
    </script>

    <div class="container">
      <p>こちらの招待コードをパートナーの方の招待コード入力欄に入力してください。</p>
      <p>招待コードはこのURLに訪問したり、再読み込みをするたびに変わります。<br>忘れずにコピーをしてください。</p>
    </div>
```


### ②相互フォロー実行

パートナーのアドレス（ユーザーの識別用）、家族名、招待コードを入力できるようにします。

```relationship/new.html.erb
    <div class="invitation-code-form">

      <%= form_with(model: @relationship, url: {controller: 'relationships', action: 'create' }, local: true) do |f| %>

      <%= f.label :invitation_code, "パートナーの招待コード：", class: "form-label" %>
      <%= f.text_field :invitation_code , class: "form-control"%>

      <%= f.label :email, "パートナーの登録メールアドレス：", class: "form-label" %>
      <%= f.email_field :email , class: "form-control"%>

      <%= f.label :name, "登録する家族の名前：", class: "form-label" %>
      <%= f.text_field :name , class: "form-control"%>
      <p class="sub-info">6文字以内（例：松田家）</p>

      <%= f.submit "登録" ,class: "btn standerd-btn btn-primary"%>
      <% end %>
    </div>
```  

<br>
今回は、自分とパートナーを家族に登録することに加えて、
「共通ユーザー」も作成し、家族に追加しています。
共通ユーザーが不要な方は適宜その点を抜かして読んでください。

```controller/relationships.rb

  def new
    @relationship = Relationship.new
  end


  def create
    to_user = User.find_by(email: params[:relationship][:email])
    @relationship = Relationship.new(name: params[:relationship][:name])

　　# （１）パートナーのメールアドレスがDBに登録されていることを確認
    if to_user.nil?
      flash[:warning] = "そのメールアドレスのユーザーは登録されていません"
      redirect_to new_relationship_path
　　# （２）パートナーがまだ相互フォローしていないことを確認
    elsif !to_user.no_relationship?
      flash[:danger] = "パートナーが既に他の方と家族登録しています"
      redirect_to new_relationship_path
　　# （３）入力した招待コードをハッシュ化したものが、DBに保存されているパートナーのinvitation_digestと一致するかを確認
    elsif BCrypt::Password.new(to_user.invitation_digest).is_password?(params[:relationship][:invitation_code])
      # （３－１）入力した params[:relationship]のバリデーションチェック
      if @relationship.save
        # 共通ユーザーでログインはしないが、バリデーションを通すため、パスワードを設定
        common_user_password = SecureRandom.urlsafe_base64(10)
        # 共通ユーザーの作成
        common_user = User.create(name: "共通", 
                                  email: "common_#{current_user.id}@kyodokoza.com", 
                                  password: common_user_password, 
                                  password_confirmation: common_user_password)
        # @relationとcurrent_user/to_user/common_userをつなげる
        current_user.create_user_relationship(relationship_id: @relationship.id)
        to_user.create_user_relationship(relationship_id: @relationship.id)
        common_user.create_user_relationship(relationship_id: @relationship.id)

        flash[:success] = "家族を登録しました"
        redirect_to user_path(current_user)
      # （３－２）入力した params[:relationship][:name]のバリデーションエラー
      else
        flash[:warning] = "家族の名前の文字数を確認してください"
        redirect_to new_relationship_path    
      end
　　# （４）入力した招待コードをハッシュ化したものが、DBに保存されているパートナーのinvitation_digestと一致しない
    else
      flash[:warning] = "招待コードが間違っています"
      redirect_to new_relationship_path
    end
  end
```

# さいごに
以上です。
質問や、「ほかにもこんな実装方法があるよ！」などコメントいただけると幸いです。
どなたかのためになればうれしいです。