【Rails】通知機能を誰でも実装できるように解説する【いいね、コメント、フォロー】

こんにちは！
ねこじょーかー（[@nekojoker1234](https://twitter.com/nekojoker1234)）と申します。

先日、ゼロから独学で勉強して、Webサービス「[Lookmine](https://lookmine.herokuapp.com/)」を立ち上げました。

通知機能があればいいなと思い実装したところ、思いのほか大変だったので、他の人が同じことで大変な思いをしないように記事にしてみました。

## 完成形のイメージ
通知一覧を確認する画面は、以下のようになります。

<img width="330" alt="通知一覧" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/487218/ae12a7ca-165b-f026-da28-99a91f9575d8.png">


また、通知があった場合は画面上にマークを付けておくようにします。

<img width="298" alt="通知マーク" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/487218/21fd9590-ec7f-28bf-6894-023f2c3db61c.png">

## 前提

- 投稿機能を実装済み（この記事では、Postモデル）
- コメント機能を実装済み（この記事では、Commentモデル）
- いいね機能を実装済み（この記事では、Likeモデル）
- フォロー機能を実装済み（この記事では、Relationshipモデル）

「コメント」「いいね」「フォロー」はすべて実装されている必要はなく、最低限どれか一つだけでも実装されていれば問題ありません。

## 参考にした記事
以下の記事を参考にしました。
[【学習アウトプット4】通知機能の作り方](https://qiita.com/Gachi/items/bdb8fbcf4ce3258b2d41)

一応実装はできたのですが、ところどころ省略されている箇所でつまづいたので、もう少し細かいところも解説していこうと思います。

## 通知モデルの概要

テーブルの情報としては、以下のようになります。

visitor_id| visited_id| post_id | comment_id | action | checked|
|--:|--:|:--|:--|:--|:--|
| 1 | 2 | nil | nil | follow | false |
| 1 | 2 | 3 | nil | like | false |
| 1 | 2 | nil | 4 | comment | false |

- visitor_id : 通知を送ったユーザーのid
- visited_id : 通知を送られたユーザーのid
- post_id : いいねされた投稿のid
- comment_id : 投稿へのコメントのid
- action : 通知の種類（フォロー、いいね、コメント）
- checked : 通知を送られたユーザーが通知を確認したかどうか

必要な情報以外は、`nil`を格納するようにしています。
たとえば、フォローの場合は「投稿」「コメント」は関係ないので、`nil`を格納します。

コメントの場合、`post_id`に`nil`を格納していますが、必要であれば正しい投稿IDを格納してもOKです。
今回は特に使用しないので`nil`にしています。

## 通知モデルの作成

まずは、コマンドプロンプトから通知モデルを作成します。

```
$ rails g model Notification
```

出来上がったマイグレーションファイルを開き、以下のように変更します。


```ruby:20190819104020_create_notifications.rb
class CreateNotifications < ActiveRecord::Migration[5.2]
  def change
    create_table :notifications do |t|
      t.integer :visitor_id, null: false
      t.integer :visited_id, null: false
      t.integer :post_id
      t.integer :comment_id
      t.string :action, default: '', null: false
      t.boolean :checked, default: false, null: false

      t.timestamps
    end

    add_index :notifications, :visitor_id
    add_index :notifications, :visited_id
    add_index :notifications, :post_id
    add_index :notifications, :comment_id
  end
end

```

idのところには、検索パフォーマンスを考えてインデックスを張っています。
また、必ず値が設定される列には、`nil`を設定できないよう制約を追加します。
通知を確認したかどうかの初期値は、`false`（通知未確認）にしておきましょう。

マイグレーションをして、DBにテーブルを作成します。

```
$ rails db:migrate
```

これで、通知テーブルの作成が完了しました。

## モデル関連付け

次に作成した通知モデルを、`User`、`Post`、`Comment`と紐付けする作業をしていきます。

### User->Notifications
ユーザーと通知モデルの紐付けをしていきます。

```ruby:app/models/user.rb
  has_many :active_notifications, class_name: 'Notification', foreign_key: 'visitor_id', dependent: :destroy
  has_many :passive_notifications, class_name: 'Notification', foreign_key: 'visited_id', dependent: :destroy
```

- active_notifications：自分からの通知
- passive_notifications：相手からの通知

紐付ける名前とクラス名が異なるため、明示的にクラス名とIDを指定して紐付けます。
また、ユーザーを削除したとき、同時に通知も削除したいので、 `dependent: :destroy`を追加します。

### Post->Notifications
投稿と通知モデルの紐付けをしていきます。

```ruby:app/models/post.rb
  has_many :notifications, dependent: :destroy
```

今度は、紐付ける名前とクラス名が一致しているため、明示的に指定する必要はありません。

### Comment->Notifications
コメントと通知モデルの紐付けをしていきます。
こちらは、Postと同じで問題ありません。

```ruby:app/models/comment.rb
  has_many :notifications, dependent: :destroy
```

### Notifications->User,Post,Comment
ここまでは、ユーザー、投稿、コメントから通知モデルへの関連付けを行いました。
今度は、逆方向の紐付けを行います。

```ruby:app/models/notification.rb
  default_scope -> { order(created_at: :desc) }
  belongs_to :post, optional: true
  belongs_to :comment, optional: true
  	
  belongs_to :visitor, class_name: 'User', foreign_key: 'visitor_id', optional: true
  belongs_to :visited, class_name: 'User', foreign_key: 'visited_id', optional: true
```

`default_scope`では、**デフォルトの並び順を「作成日時の降順」で指定**しています。
つまり、**常に新しい通知からデータを取得することができる**ということです。

たとえば、`Notification.first`を実行すると、一番古い通知ではなく、一番新しい通知が取得できます。

`post`と`comment`についている`optional: true`は、`nil`を許可するものです。
`belongs_to`で紐付ける場合は`nil`が許可されないのですが、今回は`nil`を設定したいので、付けています。
なぜ`nil`を設定するのか忘れてしまった人は、記事上部の「通知モデル概要」を確認してください。

## 通知作成メソッドを作る

### いいね通知の作成メソッド
「いいね」が押されたタイミングで、以下のようなデータを作成します。

visitor_id| visited_id| post_id | comment_id | action | checked|
|--:|--:|:--|:--|:--|:--|
| いいねした人のid | いいねされた人のid | いいねされた投稿のid | nil | like | false |

`visitor_id`については、先ほどユーザーモデルで関連付けをしたため、ここでの紐付けは不要です。
したがって、`post_id `、`visited_id `、`action`の３つを設定してあげましょう。

メソッド名に「!」を付けているのは、「メソッド内でデータ登録もやっているので、呼び出す際は気をつけてね」というのをわかりやすくするためです。（つけなくても動作します）

```ruby:app/models/post.rb
  def create_notification_like!(current_user)
    # すでに「いいね」されているか検索
    temp = Notification.where(["visitor_id = ? and visited_id = ? and post_id = ? and action = ? ", current_user.id, user_id, id, 'like'])
    # いいねされていない場合のみ、通知レコードを作成
    if temp.blank?
      notification = current_user.active_notifications.new(
        post_id: id,
        visited_id: user_id,
        action: 'like'
      )
      # 自分の投稿に対するいいねの場合は、通知済みとする
      if notification.visitor_id == notification.visited_id
        notification.checked = true
      end
      notification.save if notification.valid?
    end
  end
```

**ここでポイントとなるのが、「いいねされていない場合のみレコードを作成する」という分岐処理が入っているところです。**

例えば、いいねボタンを連打した場合、押した数だけ相手に通知がいってしまいます。
これでは、悪意を持ったユーザーがひたすらいいねを連打して迷惑行為をするかもしれません。

そこで、以下の検索処理を事前に行い、レコード存在チェックをします。

```ruby
    # すでに「いいね」されているか検索
    temp = Notification.where(["visitor_id = ? and visited_id = ? and post_id = ? and action = ? ", current_user.id, user_id, id, 'like'])
```

これで、いいねを連続でした場合でも、1度しか相手に通知がいかないようになります。

ちなみに、「?」は「**プレースホルダ**」と言って、「?」を指定した値で置き換えることができるものです。
**SQLインジェクションを防ぐためにも、必須のセキュリティ対策になります。**

```ruby
      # 自分の投稿に対するいいねの場合は、通知済みとする
      if notification.visitor_id == notification.visited_id
        notification.checked = true
      end
```

自分の投稿にいいねをして、「自分がいいねといっています」と通知が来ても虚しいですよね。
虚しい気持ちにならないよう、自分の投稿に対するいいねは、事前に通知済みとしておきます。

### コメント通知の作成メソッド
同様に、コメント投稿時にも処理を追加していきます。

```ruby:app/models/post.rb
  def create_notification_comment!(current_user, comment_id)
    # 自分以外にコメントしている人をすべて取得し、全員に通知を送る
    temp_ids = Comment.select(:user_id).where(post_id: id).where.not(user_id: current_user.id).distinct
    temp_ids.each do |temp_id|
      save_notification_comment!(current_user, comment_id, temp_id['user_id'])
    end
    # まだ誰もコメントしていない場合は、投稿者に通知を送る
    save_notification_comment!(current_user, comment_id, user_id) if temp_ids.blank?
  end

  def save_notification_comment!(current_user, comment_id, visited_id)
    # コメントは複数回することが考えられるため、１つの投稿に複数回通知する
    notification = current_user.active_notifications.new(
      post_id: id,
      comment_id: comment_id,
      visited_id: visited_id,
      action: 'comment'
    )
    # 自分の投稿に対するコメントの場合は、通知済みとする
    if notification.visitor_id == notification.visited_id
      notification.checked = true
    end
    notification.save if notification.valid?
  end
```

いいねの時とは少し違うことに気付きましたか？

いいねのときは、すでにいいねされているかどうかを事前にチェックして、
いいねされていれば、通知レコードを作成しないような制御を入れていました。

**コメントはというと、１つの投稿に対して複数回コメントを残す場合があるため、通知レコードの存在チェックはしません。**
なので、コメントの度に相手に通知がいくようになります。

また、投稿者に対してだけではなく、他にコメントをしている人がいれば、その人にも通知を届けないといけないため、最初に以下の処理を追加しています。

```ruby
temp_ids = Comment.select(:user_id).where(post_id: id).where.not(user_id: current_user.id).distinct
```

この処理を箇条書きで説明すると、以下のようになります。

- 投稿にコメントしているユーザーIDのリストを取得する
- 自分のコメントは除外する
- 重複した場合は削除する

なぜ重複した場合に削除するのかというと、取得したユーザーIDの分だけ通知を作成するとき、同じ通知が複数回登録されてしまうことを防ぐためです。

IDが複数取得されるケースというのは、同じ人が同じ投稿に複数回コメントした場合ですね。

また、なぜ自分のコメントを除外するのかというと、「コメントを登録→通知を登録」という順番のため、たった今登録したコメントの通知レコードが、自分に対して作成されてしまうからです。

### フォロー通知の作成メソッド

次に、フォロー時の処理を書いていきます。
まずは、通知レコード作成メソッドを作ります。

```ruby:app/models/user.rb
  def create_notification_follow!(current_user)
    temp = Notification.where(["visitor_id = ? and visited_id = ? and action = ? ",current_user.id, id, 'follow'])
    if temp.blank?
      notification = current_user.active_notifications.new(
        visited_id: id,
        action: 'follow'
      )
      notification.save if notification.valid?
    end
  end
```

フォローの場合もいいねのときと同じで、「連続でフォローボタンを押す」ことに備えて、
同じ通知レコードが存在しないときだけ、レコードを作成するようにします。

また、フォローの場合は以下の処理を入れていません。

```ruby
    # 自分の投稿に対するコメントの場合は、通知済みとする
    if notification.visitor_id == notification.visited_id
      notification.checked = true
    end
```

これは、「自分が自分をフォローすること」はありえないためです。



## 通知作成メソッドの呼び出し
次に、「いいね」「コメント」「フォロー」のタイミングで、通知作成メソッドの呼び出しをします。

### いいねを押したとき
いいねを押したタイミングで通知レコードを作成するように、先ほど追加したメソッドを`likes_controller`の`create`に追加します。
（`create`メソッドの中身は、みなさんと違うかもしれません）

```ruby:app/controllers/likes_controller.rb
  def create
    @like = current_user.likes.build(like_params)
    @post = @like.post
    @like.save
    post = Post.find(params[:post_id])
    # ここから
    post.create_notification_like!(current_user)
    # ここまで
    respond_to :js
  end
```

### コメントを投稿したとき

次に、コメントをしたタイミングで通知レコードを作成するように、先ほど追加したメソッドを`comments_controller`の`create`に追加します。
（`create`メソッドの中身は、みなさんと違うかもしれません）

```ruby:app/controllers/comments_controller.rb
  def create
    @comment = Comment.new(comment_params)
    @post = @comment.post
    if @comment.save
      # ここから
      @post.create_notification_comment!(current_user, @comment.id)
      # ここまで
      respond_to :js
    else
      render 'posts/show'
    end
  end
```

### フォローしたとき
最後に、フォローをしたタイミングで通知レコードを作成するように、先ほど追加したメソッドを`relationships_controller`の`create`に追加します。
（`create`メソッドの中身は、みなさんと違うかもしれません）

```ruby:app/controllers/relationships_controller.rb
  def create
    @user = User.find(params[:relationship][:following_id])
    current_user.follow!(@user)
    # ここから
    @user.create_notification_follow!(current_user)
    # ここまで
    respond_to do |format|
      format.html { redirect_to @user }
      format.js
    end
  end
```

## 通知一覧画面の作成

あとは、通知一覧の画面を作っていきましょう。
まずは、通知コントローラーの作成からです。

```
$ rails g controller notifications
```

通知一覧の画面は`index`で作るので、ルーティングを追加しておきましょう。
他のメソッドは不要なので、`only`を追加しています。

```ruby:config/routes.rb
  resources :notifications, only: :index
```

`index`アクションを実装します。

```ruby:app/controllers/notifications_controller.rb
class NotificationsController < ApplicationController
  def index
    @notifications = current_user.passive_notifications.page(params[:page]).per(20)
    @notifications.where(checked: false).each do |notification|
      notification.update_attributes(checked: true)
    end
  end
end
```

未確認の通知レコードだけ取り出したあと、「未確認→確認済」になるように更新をしています。

ちなみに以下の処理は[kaminari](https://github.com/kaminari/kaminari)というgemを利用しています。

```ruby
.page(params[:page]).per(20)
```

Googleの検索結果では、以下のようにページで分かれて表示されますよね。
<img width="339" alt="スクリーンショット 2019-08-30 21.47.43.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/487218/4c0a2f49-2f8f-9c86-fd37-c2b0d65e9cf9.png">
これは「**ページネーション**」と呼ばれていて、Rubyでも同じことができるようにしたgemが[kaminari](https://github.com/kaminari/kaminari)です。
使わなくても問題ありませんが、データ量が多いときは使うと便利ですよ。



通知一覧の画面を作っていきましょう。
`erb`じゃなくて`slim`です。`erb`で書いている人はごめんなさい。
雰囲気は伝わると思うので、脳内置換してください。

```slim:app/views/notifications/index.html.slim
/ 自分の投稿に対するいいね、コメントは通知に表示しない
- notifications = @notifications.where.not(visitor_id: current_user.id)
- if notifications.exists?
  = render notifications
  = paginate notifications
- else
  p
    | 通知はありません
```

`= paginate notifications`は、`kaminari`を使っていない人は削除してください。

まず、以下の記述についてです。

```slim
/ 自分の投稿に対するいいね、コメントは通知に表示しない
- notifications = @notifications.where.not(visitor_id: current_user.id)
```
自分の投稿に対する「いいね」と「コメント」は、通知済みとしてレコードを登録しました。
**しかし、そのままでは通知一覧に「自分の投稿に対する通知」も表示されてしまいます。**
そこで、「自分以外」という条件をここで追加しています。

```slim
  = render notifications
```

レンダリングするときに、単数だけでなく複数のインスタンスを渡すことができます。
この場合は、以下の動きになります。

- インスタンスの数だけ、部分テンプレート`notifications/_notification.html.slim`が呼び出される
- 個別のインスタンスを、`notification`として部分テンプレートで使用できる

```slim:app/views/notifications/_notification.html.slim
- visitor = notification.visitor
- visited = notification.visited
.col-md-6.mx-auto
  .form-inline
    span
      = link_to user_path(visitor) do
        = image_tag avatar_url(visitor).to_s, class: "icon_mini"
        strong
          = visitor.name
      = 'さんが'
      
      - case notification.action
      - when 'follow' then
        = "あなたをフォローしました"
      - when 'like' then
        = link_to 'あなたの投稿', notification.post, style: "font-weight: bold;"
        = "にいいねしました"
      - when 'comment' then
        - if notification.post.user_id == visited.id
          = link_to "あなたの投稿", notification.post, style: "font-weight: bold;"
        - else
          span
            = link_to post_path(notification.post) do
              = image_tag avatar_url(notification.post.user).to_s, class: "icon_mini" 
              strong
                = notification.post.user.name + 'さんの投稿'
        = "にコメントしました"
        p.text-muted.mb-0
          = Comment.find_by(id: notification.comment_id)&.comment

  .small.text-muted.text-right
    = time_ago_in_words(notification.created_at).upcase
  hr
```

これは一つひとつ説明するよりも画像を見たほうが早いと思うので、実際に動かしたイメージを以下に載せておきます。

<img width="330" alt="通知一覧" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/487218/ae12a7ca-165b-f026-da28-99a91f9575d8.png">

いい感じですね。

最後に、通知があった場合は画面上にマークを付けておくと、わかりやすくなります。

<img width="298" alt="通知マーク" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/487218/21fd9590-ec7f-28bf-6894-023f2c3db61c.png">

今回使用したのは、みんな大好き「[fontawesome](https://fontawesome.com)」です。
もし通知があれば、ベルのアイコンにオレンジの円を重ねて表示しています。

（参考）アイコンを重ねて表示する方法
https://fontawesome.com/how-to-use/on-the-web/styling/stacking-icons


```slim:app/views/partial/_circle.html.slim
= link_to(notifications_path) do
  - if unchecked_notifications.any?
    span.fa-stack
      i.far.fa-bell.fa-lg.fa-stack-2x style="font-size: 1.5em;" 
      i.fas.fa-circle.n-circle.fa-stack-1x
  - else 
    i.far.fa-bell.fa-lg style="font-size: 1.5em;" 
```

次に、`notifications_helper`に「未確認の通知を検索するメソッド」を追加しておきます。

```ruby:app/helpers/notifications_helper.rb
module NotificationsHelper
  def unchecked_notifications
    @notifications = current_user.passive_notifications.where(checked: false)
  end
end
```

円マークには`n-circle`というクラスをつけて、CSSで微調整しています。
CSSは、自分が作成しているアプリに合わせて変更してください。

```css:app/assets/stylesheets/notifications.scss
.n-circle {
  position: absolute;
  padding-left: 1rem;
  padding-top: 0rem;
  color: #efa04c;
}
```

これで、ひと通り完成です！
長丁場お疲れさまでした。

自分が送った通知を「アクティビティ」として確認する機能の実装も以下の記事で解説しているので、ぜひチャレンジしてみてください。
https://qiita.com/nekojoker/items/e45b3c8e9b600cb16d92

通知機能の実装の記録は、[Lookmine](https://lookmine.herokuapp.com/)に登録しておきましょう！

##あわせて読みたい

- [HTMLもわからない初心者が、独学で「投稿型SNSサービス」を作ったって本当？【193日間の死闘】](https://qiita.com/nekojoker/items/2497b4f81ffd1cd7025c)
- [Webプログラミング「経験０」の私が、「193日間の独学」でWebサービスを立ち上げた話](https://note.mu/nekojoker/n/nabe32d6d961f)（外部リンク）

運営している PlayFab 専用ブログ
https://playfab-master.com