誰でもできる!! 通知機能実装(Rails)

## はじめに
こんにちは！！今回はRailsを使用して``DM、フォロー、いいね、コメント``をされた時に通知が表示されるようにしていきます。通知機能があるだけで一気にSNSっぽくなりますよね。
必ずしも全ての機能がなくてもコメントだけ、いいねだけにすることもできますのでぜひ一緒にやってみましょう！！

<追加>
非同期通信の削除機能

## 完成イメージ
![A991EA31-FE9F-4194-B7A1-AA61E3119D53_4_5005_c.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333273/4372046e-88ed-c8eb-7be6-940fe06ef540.jpeg)
![F6DBDE9D-3F34-4C06-BAB4-67F5839DB596.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333273/e0096373-69ad-4e00-aa1e-98179dd11942.jpeg)






## 開発環境、前提条件
* AWS Cloud9
* Ruby 3.1.2p20 (2022-04-12 revision 4491bb740a) [x86_64-linux]
* Rails 6.1.7.3
* Deviseのインストールが完了していて、ユーザーの新規登録やログインができる状態
* BootStrapがインストール済み
* FontAwesomeがインストール済み
* 投稿機能を実装済み（この記事では、Postモデル）
* コメント機能を実装済み（この記事では、Commentモデル）
* いいね機能を実装済み（この記事では、Likeモデル）
* フォロー機能を実装済み（この記事では、Relationshipモデル）
* メッセージ機能を実装ずみ(この記事では、Chat,Roomモデル)

## 通知モデルの概要

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

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 : 通知の種類（フォロー、いいね、コメント、DM）
- checked : 通知を送られたユーザーが通知を確認したかどうか

では、やっていきましょう！！

## 通知モデル作成

```ruby:ec2-user:~/environment
$ rails g model Notification
```

出来上がったマイグレーションファイルを以下のように編集しましょう。

```ruby:20230510105204_create_notifications.rb
class CreateNotifications < ActiveRecord::Migration[6.1]
  def change
    create_table :notifications do |t|

      t.integer :visitor_id, null: false
      t.integer :visited_id, null: false
      t.integer :comment_id
      t.integer :room_id
      t.integer :chat_id
      t.integer :post_id
      t.string  :action,     default: '', null: false
      t.boolean :checked,    default: false, null: false
      t.timestamps
    end

      #検索の高速化の為の記述(add_index)
      add_index :notifications, :visitor_id
      add_index :notifications, :visited_id
      add_index :notifications, :post_id
      add_index :notifications, :comment_id
      add_index :notifications, :room_id
      add_index :notifications, :chat_id

  end
end
```

DBミスがあると修正が大変なのでよく確認しましょう。私は :comment_id にnull: falseを間違えて設定してしまい想定通り動かなく時間を消費してしまいました、、、。

では、いつも通り
```ruby:ec2-user:~/environment
$ rails g db:migrate
```

## モデルの関連付け
では作成した通知モデルを``User``、``Comment``、``Post``、`Like`、`Chat`と紐付けの作業を行います。

### User <-> Notification

ユーザーと通知モデルの紐付け
```ruby:model/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を指定して紐付けます。

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

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

コメントと通知モデルの紐付けをしていきます。
### Comment<->Notifications
```ruby:models/user.rb
  has_many :notifications, dependent: :destroy
```

メッセージと通知モデルの紐付けをしていきます。
### Chat -> Notifications
```ruby:models/chat.rb
  has_many   :notifications, dependent: :destroy
```

### Notifications->User,Post,Comment,Chat

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

```ruby:models/notification.rb
  class Notification < ApplicationRecord

  default_scope -> { order(created_at: :desc) }
                       #{optional: true}はnilを許可する
  belongs_to :post,    optional: true
  belongs_to :comment, optional: true
  belongs_to :room,    optional: true
  belongs_to :chat,    optional: true

  belongs_to :visitor, class_name: 'User', foreign_key: 'visitor_id', optional: true
  belongs_to :visited, class_name: 'User', foreign_key: 'visitor_id', optional: true

end
```

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

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


`optional => true` の部分は、このアソシエーションが必須ではないことを示しています。つまり、このモデルのインスタンス（レコード）をデータベースに保存するときに、それが実際に `post` や `comment` などに属していなくても良いということを示しています。

デフォルトでは、Railsの `belongs_to` **アソシエーションは必須です。**
つまり、関連付けられたレコードが存在しなければ、そのレコードは有効とは認められません。これは、データの整合性を保つための重要な特徴です。しかし、全てのシチュエーションでそのような制約が必要なわけではありません。なので、`optional: true`を使ってその制約を外すことができます。

この設定がないと、データベースにこのモデルのレコードを保存する前に、それが `post`、`comment`、`room`、`chat` のいずれかに属していることを確認するバリデーションが自動的に適用されます。これは、関連するレコードがまだ存在しない場合や、その関連が必須ではない場合には不便です。そのため、そのようなシチュエーションでこの `optional: true` を設定すると便利です。

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

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

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
        if notification.valid?
          notification.save
          puts "Notification has been created." # ログ出力
        else
          puts "Notification is invalid. Error messages: #{notification.errors.full_messages.join(', ')}" # ログ出力
        end
      else
        puts "Notification already exists." # ログ出力
      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
```

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

### メッセージ通知の作成メソッド
```ruby:app/controllrts/chats_controller.rb
  def create
  @chat = current_user.chats.new(chat_params)
  @room = @chat.room
  @chats = @room.chats
  render :validater unless @chat.save

  if @chat.save
    another_entry = UserRoom.where(room_id: @room.id).where.not(user_id: current_user.id).first
    visited_id = another_entry.user_id

    notification = current_user.active_notifications.new(
      room_id: @room.id,
      chat_id: @chat.id,
      visited_id: visited_id,
      visitor_id: current_user.id,
      action: 'dm'
    )

    if notification.visitor_id == notification.visited_id
      notification.checked = true
    end

    notification.save if notification.valid?

    # redirect_to room_path(@chat.room)
  else
    redirect_back(fallback_location: root_path)
  end
  end
```

以下はコードの解説です、
@chat = current_user.chats.new(chat_params): current_user（現在ログインしているユーザー）のチャットメッセージの新しいインスタンスを作成します。chat_paramsは、通常、メッセージの本文やその他のチャットに関するパラメータを含むプライベートメソッドです。

`@room = @chat.room`: チャットメッセージが属するルーム（チャットルーム）を取得します。

`@chats = @room.chats`: そのルームに存在するすべてのチャットメッセージを取得します。

`render :validater unless @chat.save`: チャットメッセージをデータベースに保存しようとします。保存できない場合（例えば、バリデーションエラーが発生した場合）、validaterというビューが表示されます。

`if @chat.save`: 既に試みた保存が成功した場合、次のブロックのコードが実行されます。

`another_entry = UserRoom.where(room_id: @room.id).where.not(user_id: current_user.id).first`: 現在のユーザー以外の、そのルームに参加しているユーザーを取得します。これは、通知を送るべきユーザーを見つけるためです。

`visited_id = another_entry.user_id`: 通知を受け取るべきユーザー（通知の訪問者）のIDを取得します。

`notification = current_user.active_notifications.new`( ... )から`notification.save if notification.valid?`: 新しい通知を作成し、現在のユーザーから別のユーザーへDM（ダイレクトメッセージ）として送ります。通知が自分自身へのものである場合、`checked = true`に設定します。これは、自分自身に送られる通知は自動的に「既読」状態にするためです。そして、作成した通知が有効であれば（バリデーションをパスすれば）それを保存します。

## 通知画面一覧の作成

通知コントローラを作っていきましょう
```ruby:ec2-user:~/environment
$ rails g controller notifications idnex update
```

ルーティング設定
```ruby:config/routes.rb
  resources :notifications, only:[:index, :update, :destroy]
```

`index`と`update` `destroy`アクションを記述しましょう

```ruby:controllers/notifications_controller.rb
  class NotificationsController < ApplicationController
  before_action :authenticate_user!

  def index
    @notifications = current_user.passive_notifications.includes(:visitor, :comment, :post).page(params[:page]).per(20)
    @notification = Notification.find_by(id: params[:id])
    @notification = @notification.decorate if @notification.present?
      @notifications.update(checked: true)
  end

  def update
    notification = Notification.find(params[:id])
    if notification.update(checked: true)
      redirect_to notifications_path
    end
  end

  def destroy
    @notification = Notification.find(paras[:id])
    @notification.destroy
    
    respond_to do |format|
      format.js
  end
  end
end
```

ややこしいと思うので以下にコードの内容を記載します。
`@notifications = current_user.passive_notifications.includes(:visitor, :comment, :post).page(params[:page]).per(20)`: 現在のユーザーが受け取ったすべての通知を取得します。`includes(:visitor, :comment, :post)`は、Eager Loadingを用いて、`visitor`、`comment`、`post`の情報も同時に読み込みます。これにより、N+1問題を解消します。最後の`.page(params[:page]).per(20)`は、kaminari gemによるページネーションの設定で、一ページに20件の通知を表示します。

`@notification = Notification.find_by(id: params[:id])`と`@notification = @notification.decorate if @notification.present?`: `params[:id]`に基づいて特定の通知を見つけ、存在する場合にはデコレート（表示用にデータを整形）します。

`@notifications.update(checked: true)`: 現在のユーザーが受け取ったすべての通知を「既読」`（checked: true）`に更新します。

`update`アクションを定義します。このアクションは、通常、リソースの更新を行うために使われます。

`notification = Notification.find(params[:id])`: `params[:id]`に基づいて特定の通知を見つけます。

`if notification.update(checked: true)`と`redirect_to notifications_path`: その通知を「既読」`（checked: true）`に更新し、成功した場合には通知一覧ページにリダイレクトします。

このコントローラーは、ユーザーの通知の一覧表示と更新（既読状態への変更）を行う機能を提供します。indexアクションは、ログインユーザーの通知を一覧表示し、それら全てを既読状態に更新します。updateアクションは、特定の通知を既読状態に更新し、その後通知一覧ページにリダイレクトします。

以上の動作により、ユーザーは通知一覧ページを訪れるときに、新たに受け取った通知をすぐに確認でき、また、特定の通知を既読にすることができます。これにより、ユーザーは自分が受け取った通知を効率的に管理することができます。

## 通知一覧画面を作ろう

ビューを記述していきましょう。

```ruby:views/notigications.html.erb
    #自分の投稿に対するいいね、コメントは通知に表示しない
    <% notifications = @notifications.where.not(visitor_id: current_user.id) %>
    <% if notifications.exists? %>
      <%= render notifications %>
      <%= paginate notifications.all %>
    <% else %>
      <p>通知はありません</p>
    <% end %>
```

レンダリングする部分テンプレートも作っていきましょう。

```ruby:views/notigications/_notification.html.erb
<div class="col-md-6 mx-auto">
  <% visitor = notification.visitor %>
  <% visited = notification.visited %>

  <div class="card mb-1" id="notification_<%= notification.id%>" >
    <div class="card-body">
      <div class="d-flex justify-content-between align-items-center">
        <div>
          <%= link_to user_path(visitor) do %>
            <% if visitor.profile_picture.attached? %>
              <%= image_tag url_for(visitor.profile_picture), size: "30x30", class: 'rounded-circle mr-2' %>
            <% else %>
              <%= image_tag 'default_profile_picture.png', size: "30x30", class: 'img-fluid rounded-circle mr-2' %>
            <% end %>
            <strong class="text-dark"><%= visitor.username %></strong>
          <% end %>
          <span class="ml-2">さんが</span>
        </div>
        <div class="text-muted small">
          <%= notification.decorate.how_long_ago %>
        </div>
      </div>
      <div class="mt-2">
        <% case notification.action %>
        <% when 'follow' %>
          <i class="fas fa-user-plus mr-1"></i>あなたをフォローしました
        <% when 'dm' %>
          <i class="fas fa-envelope mr-1"></i>あなたに
          <%= link_to 'メッセージ', chat_path(notification.visitor_id), class: 'text-dark' %>
          を送りました
        <% when 'like' %>
          <i class="fas fa-heart mr-1"></i>
          <%= link_to 'あなたの投稿', notification.post, class: "font-weight-bold text-dark" %>
          にいいねしました
        <% when 'comment' %>
          <i class="fas fa-comments mr-1"></i>
          <% if notification.post.user_id == visited.id %>
            <%= link_to "あなたの投稿", notification.post, class: "font-weight-bold text-dark" %>
          <% else %>
            <%= link_to post_path(notification.post), class: 'text-dark' do %>
              <%#= image_tag avatar_url(notification.post.user).to_s, class: "icon_mini" %>
              <strong><%= notification.post.user.username + 'さんの投稿' %></strong>
            <% end %>
          <% end %>
          にコメントしました
          <p class="text-muted mb-0">
            <%#= Comment.find_by(id: notification.comment_id)&.comment %>
          </p>
        <% end %>
      </div>
      <small class="mt-1">
        <%= form_with url: notification_path(notification), method: :delete, local: false do |f| %>
          <%= f.submit "削除" %>
        <% end %>
      </small>
    </div>
  </div>
</div>

```

以下に簡単にコードの説明をします。

`<% visitor = notification.visitor %>`と`<% visited = notification.visited %>`: 通知の発行者（visitor）と受信者（visited）をそれぞれローカル変数に格納します。

`<% case notification.action %>から<% end %>`: `notification.action`の値によって、表示するメッセージを変更します。`follow`なら「あなたをフォローしました」、`dm`なら「あなたにメッセージを送りました」、`like`なら「あなたの投稿にいいねしました」、`comment`なら「あなたの投稿にコメントしました」または「他のユーザーの投稿にコメントしました」を表示します。

`<div class="small text-muted text-right">`から
`<%= notification.decorate.how_long_ago %>`: 通知が作成されてからどのくらいの時間が経過したかを表示します。

## destroy.js.erbを作成
app/views/notificationsに`destroy.js.erb`を新規作成
```ruby:views/notigications/destroy.js.erb
$("#notification_<%= @notification.id %>").remove();
```



以上で実装の完了です。これからは実際にアプリ上で試してみましょう！！

長丁場お付き合い頂きありがとうございます！
コメント、いいねお待ちしてます！！