概要
個人開発として「夫婦・カップル専用の家計簿WEBアプリ」をRailsで作成しました。
「その際、”夫婦やカップル(以下、夫婦)の相互フォロー機能”をどのように実装するか?」にかなり悩みました。
参考記事も見つからなかったので、私なりの実装方法を記事として残してみようと思っています。
使用技術
フロントエンド
HTML / CSS / Javascript / Bootstrap 5.1.0
バックエンド
Ruby 2.7.4 / Ruby on Rails 6.1.4 / RSpec
インフラ・開発環境
Docker / Docker-compode / CircleCI(CI/CD) / Heroku / SendGrid(mailer)
実装の方針
前提
- ユーザーの登録にはEmailとパスワードを使います。
- 相互フォローをすることを前提とします。
ツイッターのような非対称なフォロー(「自分がフォローした人」と「自分をフォローした人」とが不一致になりえるような形)は想定していません。
つまり、Relations#new/createのようなアクションを用意し、
このアクションでを実行することで自分とパートナー2人を一つのRelationインスタンスとして保存します。 - ユーザーには「夫婦の機能にフォーカスしてもらう」ため、
パートナー以外とのユーザー同士の交流をすることは考えません。
そのためUsers#indexのようなアクション/ビューは作りません。
以上の前提から、選択肢が2つ考えられました。
選択肢①(招待メールパターン):
自分のみがまずユーザー登録をする
自分がアプリ上でパートナーのアドレスを入力してパートナーに招待メールを送信
パートナーが招待メールのリンクを踏む
パートナーがそのリンク先からユーザー登録をすることで相互フォローアクションを実行
選択肢②(招待コードパターン):
自分とパートナーが各々ユーザー登録をする
パートナーがアプリ上で招待コードを発行
自分がアプリ上でパートナーの招待コードを入力
相互フォローアクションを実行
→今回は選択肢②を採用
(理由)
- 選択肢①の場合、パートナーが誤って「招待メールのリンク以外からユーザー登録をすること」がありうる。その場合、相互フォローが実行されない。2人がユーザー削除をして、再度登録作業からやらないといけない
- 選択肢①の場合、パートナーのメールアドレスを誤って記入する等により、本アプリの名前入りで不特定多数にメールが送られてしまう可能性がある。
- 選択肢②の場合は、上記の懸念が払しょくされる。
- 選択肢②の場合は、ユーザーが相互フォロー機能が実行される瞬間をアプリ上で見ることになるので、リッチなアニメーションなどを用いて、ユーザーのエンゲージメント向上のための工夫を加える余地ができる
#実装の流れ
ER図
今回は下図の User, User_Relationship, Relationshipの部分が対象です。
アウトライン
①招待コード作成
②相互フォロー実行
今回関係するrouteは以下の通り
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を更新します。
(招待コードを更新する仕様にすることで、招待コードの流出による被害を防止)
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
招待コードはreadonlyにし、copyボタンを実装することにより操作性向上。 ワンタップでLINEで共有できる機能を付けるともっとよさそう…。
<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>
②相互フォロー実行
パートナーのアドレス(ユーザーの識別用)、家族名、招待コードを入力できるようにします。
<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>
今回は、自分とパートナーを家族に登録することに加えて、 「共通ユーザー」も作成し、家族に追加しています。 共通ユーザーが不要な方は適宜その点を抜かして読んでください。
def new
@relationship = Relationship.new
end
def create
to_user = User.find_by(email: params[:relationship][:email])
@relationship = Relationship.new(name: params[:relationship][:name])
# (1)パートナーのメールアドレスがDBに登録されていることを確認
if to_user.nil?
flash[:warning] = "そのメールアドレスのユーザーは登録されていません"
redirect_to new_relationship_path
# (2)パートナーがまだ相互フォローしていないことを確認
elsif !to_user.no_relationship?
flash[:danger] = "パートナーが既に他の方と家族登録しています"
redirect_to new_relationship_path
# (3)入力した招待コードをハッシュ化したものが、DBに保存されているパートナーのinvitation_digestと一致するかを確認
elsif BCrypt::Password.new(to_user.invitation_digest).is_password?(params[:relationship][:invitation_code])
# (3-1)入力した 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)
# (3-2)入力した params[:relationship][:name]のバリデーションエラー
else
flash[:warning] = "家族の名前の文字数を確認してください"
redirect_to new_relationship_path
end
# (4)入力した招待コードをハッシュ化したものが、DBに保存されているパートナーのinvitation_digestと一致しない
else
flash[:warning] = "招待コードが間違っています"
redirect_to new_relationship_path
end
end
さいごに
以上です。
質問や、「ほかにもこんな実装方法があるよ!」などコメントいただけると幸いです。
どなたかのためになればうれしいです。