Rails 通知機能の実装

# はじめに
今回はXクローンの実装にて通知機能の実装をしたので、自分自身の備忘録として、また今後通知機能を実装する方のお役に立てればと思いこちらの記事を書くことにしました。

# 通知機能の作成
今回は、`いいね` `リポスト` `コメント` `フォロー`をされた場合に、ウェブ上で通知を確認できる機能と、
メールでお知らせする機能を実装しました。

※ 前提として、投稿機能、いいね機能、リポスト機能、コメント機能、フォロー機能などが既に実装されているものとします。
過去に他の機能は実装しているものもあるので、合わせて参考にしてください。

また、いいね機能だけや、コメント機能だけでも、部分的に実装されている場合でも、引き抜いて実装していただければ問題ありません！！

# 通知モデルの概要
#### テーブルの情報

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


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

 こんな感じでテーブルの情報を作成使用と思います。
 必要のない情報の部分は、`nil`を格納するようにしています。

 #### ER図
![スクリーンショット 2024-03-14 21.39.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3335067/3d5bbc31-bea7-bead-b14f-42fbb38e5521.png)

今回も少しややこしいER図です。（間違ってたらご指摘お願いします。）

フォロー機能の実装の時のように、ユーザーでも`visitor_id`と`visited_id`に分かりやすいようにわけています。


# モデルの作成
```
rails g model Notification
```

```ruby:create_notifications.rb
class CreateNotifications < ActiveRecord::Migration[7.0]
  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

```

値が必ず必要な部分には制約をつけています。
post_idやcomment_idはnilを使用したい時があるので、何も付けていません。

```
rails db:migrate
```

# 関連付けの設定
作成した`notificationモデル`と、`userモデル`、`postモデル`、`commentモデル`を関連付けします。

```ruby:user.rb
# active_notifications：自分からの通知
  has_many :active_notifications, class_name: 'Notification', foreign_key: 'visitor_id', dependent: :destroy
  # passive_notifications：相手からの通知
  has_many :passive_notifications, class_name: 'Notification', foreign_key: 'visited_id', dependent: :destroy
  ```
  
紐付ける名前とクラス名が異なるため、明示的にクラス名とIDを指定して紐付けます。


```ruby:post.rb
has_many :notifications, dependent: :destroy
```

```ruby:comment.rb
has_many :notifications, dependent: :destroy
```

```ruby:natification.rb
default_scope -> { order(created_at: :desc) }
  belongs_to :post, optional: true
  belongs_to :comment, optional: true

  # 通知を送ったユーザー
  belongs_to :visitor, class_name: 'User'
  # 通知を送られたユーザー
  belongs_to :visited, class_name: 'User'
```

`default_scope`では、デフォルトの並び順を「作成日時の降順」で指定しました。
これにより、常に新しい通知からデータを取得することができるようになります。
例えば、`Notification.first`を実行すると、一番古い通知ではなく、一番新しい通知が取得できます。

`post`と`comment`についているoptional: trueは、nilを許可するものです。
belongs_toで紐付ける場合はnilが許可されないのですが、今回はnilを設定したいので、付けています。


# 通知メソッドを作成する
#### いいね通知の作成メソッド
`いいね`ボタンが押されたタイミングで、以下のようなデータを作成します。
| visitor_id | visited_id | post_id | comment_id | action | checked |
|:-:|:-:|:-:|:-:|:-:|:-:|
| いいねした人のid  | いいねされた人のid  | いいねされた投稿のid  | nil  | like  | False  |

```ruby: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'])
    # いいねされていない場合のみ、通知レコードを作成
    return if temp.present?

    notification = current_user.active_notifications.new(
      post_id: id,
      visited_id: user_id,
      action: 'like'
    )
    # 自分の投稿に対するいいねの場合は、通知済みとする
    notification.checked = true if notification.visitor_id == notification.visited_id
    notification.save if notification.valid?
  end
```

ここでのポイントは、「いいねされていない場合のみレコードを作成する」という分岐処理が入っているところです。
`いいね`ボタンを連打した場合、押した数だけ相手に通知が行ってしまうと、うるさくなってしまいます。
そのため、以下の検索処理を事前に行い、レコード存在チェックをします。
```ruby
temp = Notification.where(['visitor_id = ? and visited_id = ? and post_id = ? and action = ? ', 
                            current_user.id,user_id, id, 'like'])
```
これで、いいねを連続でした場合でも、1度しか相手に通知がいかないようになります。

また、自分の投稿にいいねをして、「自分がいいねしました」と通知が来ても悲しくなるので、そうならないように、自分の投稿に対するいいねは、事前に通知済みの設定をしています。
```ruby
notification.checked = true if notification.visitor_id == notification.visited_id
    notification.save if notification.valid?
```

#### リポスト通知の作成メソッド
リポストはいいねとほぼ変わらない感じです。

`リポスト`ボタンが押されたタイミングで、以下のようなデータを作成します。
| visitor_id | visited_id | post_id | comment_id | action | checked |
|:-:|:-:|:-:|:-:|:-:|:-:|
| リポストした人のid  | リポストされた人のid  | リポストされた投稿のid  | nil  | repost  | False  |

```ruby:post.rb
def create_notification_repost!(current_user)
    # すでに「リポスト」されているか検索
    temp_1 = Notification.where(['visitor_id = ? and visited_id = ? and post_id = ? and action = ? ', current_user.id,
                                 user_id, id, 'repost'])
    # リポストされていない場合のみ、通知レコードを作成
    return if temp_1.present?

    notification = current_user.active_notifications.new(
      post_id: id,
      visited_id: user_id,
      action: 'repost'
    )
    # 自分の投稿に対するリポストの場合は、通知済みとする
    notification.checked = true if notification.visitor_id == notification.visited_id
    notification.save if notification.valid?
  end
```

いいね通知の`like`の部分を`repost`に変更すればよいだけです。

#### コメント通知の作成メソッド
`コメント`が投稿されたタイミングで、以下のようなデータを作成します。
| visitor_id | visited_id | post_id | comment_id | action | checked |
|:-:|:-:|:-:|:-:|:-:|:-:|
| コメントした人のid  | コメントされた人のid  | nil  | コメントのid  | comment  | False  |

```ruby: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:,
      visited_id:,
      action: 'comment'
    )
    # 自分の投稿に対するコメントの場合は、通知済みとする
    notification.checked = true if notification.visitor_id == notification.visited_id
    notification.save if notification.valid?
  end
end
```

いいねやリポストの時と少し変わっています。
いいねやリポストの時は、既にいいねされているかどうかを事前にチェックして、されていれば通知の作成はしないような制御をいれていました。

しかしコメントは、**1つの投稿に対して複数回コメントを残す可能性があるので、通知レコードの存在チェックはしていません。**
なので、コメントが付いた回数だけ通知がいくようになっています。

また、投稿者に対してだけではなく、投稿者ではないけど、そのポストにコメントした人にも通知を届けたいため、最初に以下の処理を追加しています。
```ruby
temp_ids = Comment.select(:user_id).where(post_id: id).where.not(user_id: current_user.id).distinct
```

そうすることによって、潔さんのところにこのような通知も来るようになります。
![スクリーンショット 2024-03-14 22.35.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3335067/cca274de-4170-3cb3-7984-fb1a091b8d33.png)


フォロー通知の作成メソッド
`フォロー`ボタンを押されたタイミングで、以下のようなデータを作成します。

| visitor_id | visited_id | post_id | comment_id | action | checked |
|:-:|:-:|:-:|:-:|:-:|:-:|
| フォローした人のid  | フォローされた人のid  | nil  | nil  | follow  | False  |

```ruby:user.rb
def create_notification_follow!(current_user)
    temp = Notification.where(['visitor_id = ? and visited_id = ? and action = ? ', current_user.id, id, 'follow'])
    return if temp.present?

    notification = current_user.active_notifications.new(
      visited_id: id,
      action: 'follow'
    )
    notification.save if notification.valid?
  end
```

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


# 通知メソッドの呼び出し
`いいね`、`リポスト`、`コメント`、`フォロー`のタイミングで通知メソッドの呼び出しをします。

#### いいねを押した時
いいねを押したタイミングで通知レコードを作成するように、先ほど作ったメソッドを`likes_controller`の`create`アクションに追加していきます。

```ruby:likes_controller.rb
def create
    @post = Post.find(params[:post_id])
    @like = current_user.likes.new(post_id: @post.id)
    @like.save
    # ここから
    @post.create_notification_like!(current_user)
    # ここまでを追加
    redirect_to request.referer
  end
```

#### リポストを押した時
リポストを押したタイミングで通知レコードを作成するように、先ほど作ったメソッドを`reposts_controller`の`create`アクションに追加していきます。

```ruby:reposts_controller.rb
def create
    if current_user.reposts.find_by(user_id: current_user.id, post_id: @post.id)
      redirect_to root_path, alert: '既にリポスト済みです'
    else
      @repost = current_user.reposts.create(user_id: current_user.id, post_id: @post.id)
      redirect_to request.referer, notice: 'リポストしました'
      @post = Post.find(params[:post_id])
      #　ここから
      @post.create_notification_repost!(current_user)
      # ここまでを追加
    end
  end
```

#### コメントを投稿したとき
コメントを投稿したタイミングで通知レコードを作成するように、先ほど作成したメソッドを`comments_controller`の`create`アクションに追加していきます。

```ruby:comments_controller.rb
def create
    @post = Post.find(params[:post_id])
    @comment = @post.comments.build(comment_params)
    @comment.user = current_user
    @comment.save
    # ここから
    @post.create_notification_comment!(current_user, @comment.id)
    # ここまで追加
    redirect_to request.referer
  end
```

#### フォローしたとき
フォローボタンを押したタイミングで通知レコードを作成するように、先ほど作成したメソッドを`relationships_controller`の`create`アクションに追加します。

```ruby:relationsips_controller.rb
def create
    @user = User.find(params[:user_id])
    following = current_user.relationships.build(follower_id: params[:user_id])
    following.save
    # ここから
    @user.create_notification_follow!(current_user)
    # ここまで追加
    redirect_to request.referer
  end

```

# notifications_contorollerの作成
```
rails g controller notifications
```

```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_attribute(:checked, true)
    end
  end
end
```

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


# ルーティングの設定
```ruby:routes.rb
resources :notifications, only: :index
```

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

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

```
= render notifications
```
上記の記述で、レンダリングするときに、単数だけでなく複数のインスタンスを渡すことができます。

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

```ruby:_notification.html.slim
- visitor = notification.visitor
- visited = notification.visited
.col-md-12.mx-auto
  .form-inline.ms-3
    span
      = link_to user_path(visitor) do
        = image_tag visitor.icon, class: "icon_img"
        strong
          = visitor.name
      = 'さんが'

      - case notification.action
      - when 'follow' then
        = "あなたをフォローしました"
      - when 'like' then
        = link_to 'あなたの投稿', notification.post, style: "font-weight: bold;"
        = "にいいねしました"
      - when 'repost' 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 notification.post.user.icon, class: "icon_img" 
              strong
                = notification.post.user.name + 'さんの投稿'
        = "にコメントしました"
        p.text-muted.mb-0
          = notification.comment&.comment_content

  .small.text-muted.text-right.ms-3.mt-2
    = time_ago_in_words(notification.created_at).upcase
    | 前
  hr
  ```

  ![スクリーンショット 2024-03-14 23.33.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3335067/816e3a5e-8ffc-ca10-e2c4-dfda5119bf8c.png)

こんな感じで通知をみれるようになりました。


# 最後に
これで通知機能のweb上での通知が完成しました。あとはメールでの通知機能の設定をしたいと思います。
こちらはまた後日記事にして作成したいと思います。

通知機能も初めての実装で手こずることが多かったですが、なんとか実装することができました！

# 参考ページ
https://qiita.com/nekojoker/items/80448944ec9aaae48d0a

https://qiita.com/mmdrdr/items/ea477bae9308e6f42a13