Railsチュートリアルのサンプルアプリを拡張しておすすめ作品共有SNSを作った

##はじめに

[Railsチュートリアル](https://railstutorial.jp/)で作った SAMPLE APP を拡張して、SNSサービスを作ってみました。
詰まった所や解決方法を共有することで、同じくらいの境遇の方の参考になればと思います。

完成したサイトはこのような感じになりました。
https://susumeru.herokuapp.com/

![rapture_20190615213156.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/422212/7364e1b4-90d9-ef51-75af-4422bcc6b7d2.png)

##環境
Railsチュートリアルの環境と全く同じです。新しいgemなどは入れていません。

参考：https://railstutorial.jp/chapters/user_microposts?version=5.1#code-final_gemfile

Rails のバージョンは 5.1.6 、AWS Cloud9 で開発を行いました。

##拡張した機能
- プロフィールの説明欄
- ツイッターリンク
- トップページ
- micropost の拡張（タイトル・カテゴリー・ジャンルなどの追加）
- ミュート
- 投稿の編集
- 検索
- お気に入り
- 通知

この中から特に苦労した5つをピックアップして、実装までの過程を紹介していきたいと思います。なお、コードの内容は SAMPLE APP を完成させていることを前提としています。

### 1. トップページ
![rapture_20190616121950.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/422212/22f77672-5e6d-e6ec-e8d0-cdee962531d7.png)
ログインしていなくても閲覧できるトップページを作ります。
カテゴリーごとに違うページを表示させたいのですが、中身のコードはほとんど同じなので１つのビューファイルとアクションで対応できそうです。
ここではもともとリソースの一覧を表示する役割を持つ、index アクションを利用します。

```erb:/app/views/static_pages/index.html.erb
<% unless logged_in? %>
  <%= link_to "新規登録", signup_path, class: "btn btn-primary" %>
  <%= link_to "ログイン", login_path, class: "btn btn-primary" %>
<% end %>

<%= render 'head' %>

<% if @micropost_items.any? %>
  <ol class="microposts">
    <%= render @micropost_items %>
  </ol>
  <%= will_paginate @micropost_items %>
<% end %>
```

```erb:/app/views/static_pages/_head.html.erb
<h3>みんなのおすすめ作品</h3>

<p> <%= link_to "TOP", "/index" %> |
<%= link_to "漫画", "/index/comic" %> |
<%= link_to "web漫画", "/index/web_comic" %> |
<%= link_to "小説", "/index/novel" %> |
<%= link_to "web小説", "/index/web_novel" %> |
<%= link_to "アニメ", "/index/anime" %> |
<%= link_to "映画", "/index/movie" %> </p>
...
```
このようにリンクを飛ばすことで、URLに情報を埋め込みます。

```rb:/config/routes.rb
Rails.application.routes.draw do
...
get  '/index', to: 'static_pages#index'
get  '/index/:id', to: 'static_pages#index'
...
```
埋め込まれた情報があれば `/:id` でキャッチして、indexアクションに渡します。

```ruby:/app/controllers/static_pages_controller.rb
class StaticPagesController < ApplicationController
...
  def index
    if params[:id]
      @micropost_items =  Micropost.where("category = '#{params[:id]}'")
                            .paginate(page: params[:page], per_page: 10)
    else                    
      @micropost_items = Micropost.all.paginate(page: params[:page], per_page: 10)
    end
  end
...
end
```
`params[:id]`の有無で条件分岐をして、完成です。

実は当初、`params[:id]` の `:id`の中には数字しか埋め込めないと勘違いしていてかなり遠回りをしていました。この通り英字も`params`として渡すことができます。また `/index/:category`としてルーティングして `params[:category]` で受け取ることも可能のようです。

### 2. 検索
単数ワードのみ対応で、micropost との部分一致で結果を返す検索機能を実装します。

まずはビューファイルに検索フォームを設置します。

```erb:/app/views/static_pages/_head.html.erb
...
<%= form_tag(index_path, method: :get) do %>
  <div class="input-group">
    <%= search_field_tag "q", params[:q], class: "form-control", placeholder: "キーワード検索" %>
      <span class="input-group-btn">
        <%= submit_tag "検索", class: "btn btn-primary" %>
      </span>
  </div>
<% end %>
```
検索結果はトップページで表示できそうなので、検索キーワードを `params[:q]` に保存してindexアクションに送っています。

```rb:/app/controllers/static_pages_controller.rb
class StaticPagesController < ApplicationController
...
  def index
    if params[:q]
      @micropost_items = Micropost.search_by_keyword(params[:q])
                          .paginate(page: params[:page], per_page: 10)
    elsif params[:id]
      @micropost_items =  Micropost.where("category = '#{params[:id]}'")
                           .paginate(page: params[:page], per_page: 10)
    else                    
      @micropost_items = Micropost.all.paginate(page: params[:page], per_page: 10)
    end
  end
...
end
```
indexアクションに追記して、条件分岐を追加しました。`params[:q]` を持っている時が最優先されます。
Micropost テーブルから該当要素を探し出すのに `search_by_keyword` メソッドを使用していますが、これは where句などをメソッドとして定義することができる、Rails の scope という機能を用いたものです。

この scope 機能は 各モデルで定義することができます。

```rb:/app/models/micropost.rb
class Micropost < ApplicationRecord
...
  scope :search_by_keyword, -> (keyword) {
    where("microposts.content LIKE :keyword 
           or microposts.title LIKE :keyword
           or microposts.genre LIKE :keyword
           or microposts.genre2 LIKE :keyword
           or microposts.genre3 LIKE :keyword", keyword: "%#{sanitize_sql_like(keyword)}%") if keyword.present?
  }
end
```
ここでは Micropost テーブルから検索ワードがコンテンツ・タイトル・ジャンルに部分一致する要素を探しています。
`sanitize_sql_like` は`\`, `_`, `%` をエスケープしてくれる Rails の便利メソッドです。

これにて簡素ではありますが、検索機能が実装できました。

参考：[RailsTutorial 検索機能拡張を実装してみた](https://qiita.com/enomotodev/items/d652b7466d05cdd0e861)

### 3. お気に入り（Ajax使用）
まず始めに [Progate](https://prog-8.com/languages/rails5) Rails 編のいいね機能を参考に、Ajax無しのお気に入り機能を実装してみます。


```rb:/db/migrate/20190612125509_create_likes.rb
class CreateLikes < ActiveRecord::Migration[5.1]
  def change
    create_table :likes do |t|
      t.references :user, foreign_key: true
      t.references :micropost, foreign_key: true
      t.timestamps
    end
  end
end
```

それぞれの要素にリファレンスをつけて、likeテーブルを作成しました。
このリファレンスをつけることで、各カラムに index 機能が付与されたり、`micropost.likes.count` や `user.likes.count` でお気に入りカウントなどができるようになります。

参考：[Railsの外部キー制約とreference型について](https://qiita.com/ryouzi/items/2682e7e8a86fd2b1ae47)




```rb:/app/controllers/likes_controller.rb
class LikesController < ApplicationController
  before_action :logged_in_user
  
  def create
    @micropost = Micropost.find(params[:micropost_id])
    if Like.find_by(user_id: current_user.id, 
                    micropost_id: params[:micropost_id]) == nil #重複チェック
      @like = Like.new(user_id: current_user.id,
                     micropost_id: params[:micropost_id])
      @like.save
      redirect_to request.referrer || root_url
    end
  end
  
  def destroy
    @micropost = Micropost.find(params[:micropost_id])
    @like = Like.find_by(user_id: current_user.id,
                         micropost_id: params[:micropost_id])
    @like.destroy
    redirect_to request.referrer || root_url 
  end
end
```

```erb:app/views/microposts/_micropost.html.erb
...
<% if @current_user != nil %>
  <% if Like.find_by("micropost_id = #{micropost.id} AND user_id = #{@current_user.id}") %>
    <%= link_to("/likes/#{micropost.id}/destroy", method: :post) do %>
      <i class="fas fa-star fa-fw star"></i>
    <% end %>
  <% else %>
    <%= link_to("/likes/#{micropost.id}/create", method: :post) do %>
      <i class="fas fa-star fa-fw unstar"></i>
    <% end %>
  <% end %>
    <%= micropost.likes.count %>
<% else %>
  <span class="fas fa-star fa-fw unstar"></span> 
  <%= micropost.likes.count %>
<% end %>
...
```
このような感じで、簡単なお気に入り機能を実装しました。
ビューファイルでは未ログインのユーザーがトップページを閲覧するときのことを考慮して、`@current_user`の有無で条件分岐をしています。

当初はこれで完成にする予定だったのですが、お気に入りする度にリダイレクトが挟まるのが予想以上に不便だったので Ajax 機能を利用することにしました。

まずは Ajax で書き換えたいビューの要素を id で指定します。ここはかなり苦戦したのですが、お気に入り機能のコードをパーシャル化して id を割り振ることで何とか形になりました。

```erb:app/views/microposts/_micropost.html.erb
...
<span id="like-of-<%= micropost.id %>">
   <%= render partial: 'microposts/like', locals: {micropost: micropost } %>
</span>
...
```
そして各リンクに `remote: true` を追記、各アクションに`respond_to`メソッドを用意して Ajax に対応させました。

```rb:/app/controllers/likes_controller.rb
class LikesController < ApplicationController
  before_action :logged_in_user
  
  def create
    @micropost = Micropost.find(params[:micropost_id])
    if Like.find_by(user_id: current_user.id, 
                    micropost_id: params[:micropost_id]) == nil #重複チェック
      @like = Like.new(user_id: current_user.id,
                     micropost_id: params[:micropost_id])
      @like.save
      respond_to do |format|
        format.html { redirect_to request.referrer || root_url }
        format.js
      end
    end
  end
... 
```

最後にアクションと同じ名前を持つJavaScript用の埋め込みRuby (.js.erb) ファイルを作成し、完成です。
(destroyアクションも全く同じコードで大丈夫です）

```erb:/app/views/likes/create.js.erb
$("#like-of-<%= @micropost.id %>").html("<%= escape_javascript(render partial: 'microposts/like',
                                         locals: { micropost: @micropost}) %>");
```

これでお気に入りが完了するまでの流れは以下のようになります。

1. お気に入りを押すとリクエストが Ajax で送信される
2. createアクションでデータが更新される。
3. お気に入り部分のパーシャルが `@micropost` データを受け取り、リダイレクト無しに再描画される。

参考：[Railsチュートリアル：14.2.5 [Follow] ボタン (Ajax編)](https://railstutorial.jp/chapters/following_users?version=5.1#sec-a_working_follow_button_with_ajax)

### 4. 通知
ツイッターの通知機能みたいなものを実装します。

まずは Notification テーブルを作ります。

```rb:/db/migrate/20190614115744_create_notifications.rb
class CreateNotifications < ActiveRecord::Migration[5.1]
  def change
    create_table :notifications do |t|
      t.integer :user_id
      t.integer :notified_by_id
      t.integer :micropost_id
      t.integer :like_id
      t.integer :relationship_id  # フォロー時の通知用
      t.boolean :read             # 既読/未読を判断
      t.string  :notified_type    # "お気に入り" or "フォロー"
      t.timestamps
    end
    add_index :notifications, :user_id
    add_index :notifications, :notified_by_id
    add_index :notifications, :like_id
    add_index :notifications, :relationship_id
  end
end
```
モデルのリレーションは以下の通りです。まとめて書いてます。

```rb
class User < ApplicationRecord
  has_many :notifications, dependent: :destroy

class Micropost < ApplicationRecord
  has_many :notifications, dependent: :destroy

class Relationship < ApplicationRecord
  has_many :notifications, dependent: :destroy

class Like < ApplicationRecord
  has_many :notifications, dependent: :destroy


class Notification < ApplicationRecord
  belongs_to :user
  belongs_to :notified_by, class_name: 'User'
  belongs_to :relationship, optional: true
  belongs_to :like, optional: true
  belongs_to :micropost, optional: true
```

ここで注意しないといけないのは、下の３つの要素には `optional: true` を記述する必要があるということです。
例えばフォローの通知をテーブルに保存するとき `like_id` カラムと `micropost_id` カラムは空になりますが、デフォルトの設定では belongs_to 関連の要素が全て揃っていないとデータがテーブルに保存されません。

参考：[Rails5からbelongs_to関連はデフォルトでrequired: trueになる](https://qiita.com/iguchi1124/items/218e35a145f372062ea4)

各コントローラを書き換えて、お気に入り・フォロー時に通知を保存するようにします。

```rb:/app/controllers/likes_controller.rb
class LikesController < ApplicationController
  before_action :logged_in_user
  after_action :create_notifications, only: [:create]

...

  private
    def create_notifications
      return if @micropost.user_id == current_user.id
      Notification.create(user_id: @micropost.user_id,
                          notified_by_id: current_user.id,
                          micropost_id: @micropost.id,
                          notified_type: "お気に入り",
                          read: false,
                          like_id: @like.id)
    end
end
```

ユーザーが自分の投稿にお気に入りをした時はリジェクトするようにしています。フォロー時も同じ要領で書きました。

通知の未読/既読の書き換えについてはこのような感じで実装しました。

1. 通知ページへのリンクを post で送る
2. create アクションで未読を既読に書き換え
3. 通知ページにリダイレクト

```rb:/app/controllers/static_pages_controller.rb
...
  def create
    Notification.where("user_id = #{current_user.id}").update_all "read = 'true'"
    redirect_to "/notification"
  end
  
  def notification
    @notifications = Notification.where("user_id = '#{current_user.id}'")
                        .paginate(page: params[:page], per_page: 20)
  end
...
```

未読/既読書き換えの際には、 `update_all` のメソッドが大変便利でした。
Active record の基礎的な要素を学ぶにはこのページが参考になりますのでおすすめです。
[Ruby on Rails ガイド ： Active Record の基礎](https://railsguides.jp/active_record_basics.html)

最後に、新着通知が来たときにヘッダーに数字が表示されるようにします。
![rapture_20190616174520.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/422212/0f8c29d7-13ab-657b-29be-da350da9aae1.png)

```rb:/app/helpers/sessions_helper.rb
...
def notification_count(user)
    count = Notification.where("user_id = ? AND read = ?", user.id, false).count
    if count == 0
      return
    else 
      return "(#{count})"
    end
  end
...
```
新たにメソッドを定義して実装しました。これにて完成です。


### 5. ミュート

ミュート機能を実装します。ミュートした人の投稿はページに表示されなくなり、その人からの通知も表示されません。

まずは新たにモデルを作ります。

```rb:/db/migrate/20190615055829_create_relationship_mutes.rb
class CreateRelationshipMutes < ActiveRecord::Migration[5.1]
  def change
    create_table :relationship_mutes do |t|
      t.integer :follower_id
      t.integer :followed_id

      t.timestamps
    end
    add_index :relationship_mutes, :follower_id
    add_index :relationship_mutes, :followed_id
    add_index :relationship_mutes, [:follower_id, :followed_id], unique: true
  end
end
```
モデルのリレーションやコントローラの内容はほとんど [Railsチュートリアル14章](https://railstutorial.jp/chapters/following_users?version=5.1#cha-following_users) と同じなので省略します。
ただ１つだけ落とし穴があり、`relationship_mutes` という名前でモデルを生成するとクラス名が `RelationshipMute` になるということには注意せねばなりません。

ミュート中のユーザーの投稿を非表示にさせる部分を実装していきます。

```rb:
mute_ids = "SELECT followed_id FROM relationship_mutes WHERE follower_id = #{current_user.id}"
```
まずはテーブルからミュート中のユーザーIDを収集するコードを書きます。
データベース操作の構文についてはこのページを参考に書きました。
[DBOnline SQLite入門 取得するデータの条件を設定(WHERE句)](https://www.dbonline.jp/sqlite/select/index3.html)

そしてwhere句を用いて `@micropost_items` からミュート中のユーザーの投稿を抜いていきます。

```rb:
@micropost_items =  Micropost.where("category = '#{params[:id]}'").where("NOT user_id IN (#{mute_ids})")
                            .paginate(page: params[:page], per_page: 10)
```

最終的にindexアクションはこのような感じになりました。

```rb:/app/controllers/static_pages_controller.rb
...
def index
    unless logged_in?
      if params[:q]
        @micropost_items = Micropost.search_by_keyword(params[:q])
                            .paginate(page: params[:page], per_page: 10)
      elsif params[:id]
        @micropost_items = Micropost.where("category = '#{params[:id]}'")
                            .paginate(page: params[:page], per_page: 10)
      else                    
        @micropost_items = Micropost.all.paginate(page: params[:page], per_page: 10)
      end
    else
      mute_ids = "SELECT followed_id FROM relationship_mutes WHERE follower_id = #{current_user.id}"
      if params[:q]
        @micropost_items = Micropost.search_by_keyword(params[:q]).where("NOT user_id IN (#{mute_ids})")
                            .paginate(page: params[:page], per_page: 10)
      elsif params[:id]
        @micropost_items =  Micropost.where("category = '#{params[:id]}'").where("NOT user_id IN (#{mute_ids})")
                            .paginate(page: params[:page], per_page: 10)
      else                    
        @micropost_items = Micropost.where("NOT user_id IN (#{mute_ids})")
                            .paginate(page: params[:page], per_page: 10)
      end
    end  
  end
...
```

`logged_in?` で分岐させているのは、未ログインのユーザーがトップページを閲覧した時を考慮してのことです。これについてはもっと上手い回避方法があった気がします。
通知のミュートについても同じように実装しました。

## 課題点

これから追加していきたい機能

- 複数ワード検索
- 同じユーザーからの通知をひとまとめにして表示する機能
- 作品の紹介コメントで改行できるようにする

## 最後に
実際に手を動かしてモノを作るのはやはり楽しいものですが、しばらくは専門書でも読みつつ知識の再整理をしようと思っています。

おすすめ作品共有SNS「susumeru」、よかったら使ってみてください。
https://susumeru.herokuapp.com/