enumとバルクインサートを使用した通知機能実装

# はじめに
ポートフォリオ制作中です！
今回は通知機能を実装しました！
enumとバルクインサートを活用しポリモーフィック関連付けを
使った実装をしたのでアウトプットとしてまとめます

※旅行やデートのプランを共有するアプリなので、投稿機能はPostではなくPlanを使用しています！
# 完成物
![スクリーンショット 2024-06-15 13.33.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3782860/acf7b7dc-e8a6-ec35-0ce4-22266ca9aec6.png)

レイアウトがまだ整ってなくてすみません💦

#### 通知の条件
1.新規投稿されたときフォロワーへ通知
2.投稿にコメントされたとき投稿者へ通知
3.投稿にいいねされたときに投稿者へ通知
4.フォローされたときにユーザーへ通知
5.メッセージを送信されたときに受信者へ通知

各機能は実装されていることを前提にまとめていきます

# ER図
複雑ですが、通知テーブルから各アクションのリレーションは省略しています

![スクリーンショット 2024-06-15 13.43.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3782860/475a4882-916c-5d8d-7439-65f96877434b.png)

# モデル、マイグレーションファイル作成

#### モデル作成
通知モデルを作成します

```
$ rails g model Notification user:references subject:references{polymorphic} read:boolean action_type:integer
```
カラムを同時作成しています

<details><summary>referencesとは</summary>

:::note info
（直訳：参照、参考)
名前の通り、作成済みのテーブルを参照する場合に使用します
コマンドを実行すると下記カラムがマイグレーションファイルに自動追加されます
```
t.references :user, null: false, foreign_key: true
```
ここで指定しなくとも、後からマイグレーションファイルに追加可能です！
のちにモデルに記載する「belongs_to」を自動で記載もしてくれます
※「has_many」は追加してくれないので注意⚠️
:::

参考URL：https://prograshi.com/framework/rails/references_and_foreign-key/

</details>

・polymorphicは関連付けの一種で複数のモデルと関連付けを行うことができます
・readは既読判定に使うため、booleanを使用(true,false )
・action_typeはenumで管理するため、integerを使用

マイグレーションファイルに後から追記できるため

```
$ rails g model Notification
```
でも問題はありません

#### マイグレーションファイル
マイグレーションファイルを確認し、追記します（defaultなど）
モデル作成時にカラムを作成していない場合は、ここで追加しましょう

```xxxxx_create_notifications.rb
class CreateNotifications < ActiveRecord::Migration[6.1]
  def change
    create_table :notifications do |t|
      t.references :user, null: false, foreign_key: true
      t.references :subject, polymorphic: true, null: false
      t.integer :action_type, null: false
      t.boolean :read, default: false, null: false

      t.timestamps
    end
  end
end
```

:::note info
解説 🌱
・referencesを指定することでindexが自動付与され、高速検索が可能になる
・referencesを指定することで外部キーxx_idが不要になる
・`foreign_key:true`を指定することで外部キーとして設定される
・readは既読の判定のため、defaultにfalseを設定します！
:::

:::note warn
polymorphicとは
ポリモーフィック関連付けを使うと、Notificationモデルが他の複数のモデルに属していることを表現できます。
今回で言うと、`notification.subject`の記述でLike、Comment、Relationshipなどのインスタンスを呼び出すことが可能になります

`action_type`で各モデルを判別します！

[ポリモーフィック関連付けについてはこちら](https://railsguides.jp/association_basics.html#%E3%83%9D%E3%83%AA%E3%83%A2%E3%83%BC%E3%83%95%E3%82%A3%E3%83%83%E3%82%AF%E9%96%A2%E9%80%A3%E4%BB%98%E3%81%91)
:::

マイグレーションファイルの記述が完了したら忘れないうちにmigrateしましょう！

```
$ rails db:migrate
```
マイグレートするとschema.rbに反映されます↓

```schema.rb
ActiveRecord::Schema.define(version: 2024_06_14_012234) do
:
  create_table "notifications", force: :cascade do |t|
    t.integer "user_id", null: false
    t.string "subject_type", null: false
    t.integer "subject_id", null: false
    t.integer "action_type", null: false
    t.boolean "read", default: false, null: false
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
    t.index ["subject_type", "subject_id"], name: "index_notifications_on_subject"
    t.index ["user_id"], name: "index_notifications_on_user_id"
  end
:
end
```

# ルーティング
publicとadminでルーティングを分けているため以下のように記述しています
分けていない場合は`resources :notifications, only: [:index, :destroy]`のみで問題ありません

[scope moduleなどについてはこちら](https://qiita.com/3rarara/items/8726706e5446d108ba41)



```routes.rb
Rails.application.routes.draw do
  scope module: :public do
:
    resources :notifications, only: [:index, :destroy]
  end
:
end
```

# コントローラー

```notifications_controller.rb
class Public::NotificationsController < ApplicationController
  before_action :authenticate_user!

  def index
    @notifications = current_user.notifications.order(created_at: :desc)
    @notifications.where(read: false).each do |notification|
      notification.update(read: true)
    end
  end

  def destroy
    @notifications = current_user.notifications.destroy_all
    redirect_to notifications_path
  end
end
```

`current_user.notifications`

現在ログインしているユーザー（current_user）に関連付けられた全ての通知（notifications）を取得（モデルにアソシエーションを記述することで取得可能になります）

`.order(created_at: :desc)`
上記で取得したすべての通知を降順に並び替え


:::note info
解説 🌱
`@notifications.where(read: false).each do |notification|`
先ほど取得したすべての通知から未読(read: false）分を取得

`notification.update(read: true)`
一覧ページを開いた時点で未読をすべて既読(read: true)に変更します
:::

ここら辺は実装したい通知一覧に合わせてカスタマイズしてください！

`current_user.notifications.destroy_all`
通知一覧を空にするための前削除ボタンを作成するのに使います

# モデル記述(アソシエーション)

userは通常のアソシエーション同様has_manyを記述します
```user.rb
class User < ApplicationRecord

  # アソシエーション
: # 他のアソシエーション
  has_many :notifications, dependent: :destroy
:
end
```

notificationはenumを使用して各モデルを`action_type`で管理します

```notification.rb
class Notification < ApplicationRecord

  belongs_to :user
  belongs_to :subject, polymorphic: true

  enum action_type: { new_plan: 0, commented_to_own_plan: 1, liked_to_own_plan: 2, followed_me: 3, new_message: 4}

end
```


<details open><summary>commentモデル</summary>

```comment.rb
class Comment < ApplicationRecord

  # アソシエーション
  belongs_to :user
  belongs_to :plan
  has_many :notification, as: :subject, dependent: :destroy

  # 通知コールバック
  after_create_commit :create_notifications

  # バリデーション
  validates :comment, presence: true

  private

  # 通知機能
  def create_notifications
    Notification.create(subject: self, user: plan.user, action_type: :commented_to_own_plan)
  end

end
```
</details>

・`has_many :notification`
「commentモデルが複数のNotificationモデルを持っている」という関連性を示します

・`has_many` と `has_one` の使い分け
`has_many`commentモデル
1つの「コメント」に対して通常1つの通知（Notification）が生成されますが、
同じユーザーが複数回コメントする場合や、他のユーザーが同じ投稿に対して複数のコメントをする場合を考慮すると、`has_many :notifications` が適切です

`has_one`likeモデル
 1つの「いいね」に対して通常1つの通知が生成されます
 ユーザーが投稿に対して「いいね」をした場合、その行為に対して1つの通知が生成されるため`has_one :notification` が適切です

・`as: :subject`
ポリモーフィック関連付けを示しており、
Notificationモデルのsubjectがコメントモデルになります

・`after_create_commit`
RailsのActiveRecordにおけるコールバック機能を使用して、
特定のタイミングで自動的に処理を実行します
今回の場合は、「コメントモデルのインスタンスがデータベースに保存された(create)後(after)に」という意味です

[コールバックについてはこちら](https://railsguides.jp/active_record_callbacks.html)

<details><summary> `afrete_create_commit` と `after_create`　の違い</summary>

:::note info

・`after_create`は、レコードがデータベースに保存された直後に実行されます
このタイミングでは、まだトランザクションがコミットされていない（データベースに対する一連の操作が完全に実行が完了していない)可能性があります

・`after_create_commit`は、レコードが確実にデータベースに保存され、
データベースに対する一連の操作が完全に実行が完了した後に実行されます

通知を送信するタイミングが確実にデータベースに保存された後である方が、安全であることが多いため、今回は`after_create_commit`を使用しています

:::
</details>


・ `create_notifications`
コールバックに指定した`create_notifications`の中身を`private`で記述しています
コメントモデルのインスタンスがデータベースに保存された(create)後(after)に「通知(notification)インスタンスを作成する」という意味です
ここで`subject`の中身を指定します

・大体の書き方は他のモデルもコメントモデルと同じです！
通知機能を実装したいモデルを確認してください

<details><summary>likeモデル</summary>

```like.rb
class Like < ApplicationRecord
  # アソシエーション
  belongs_to :user
  belongs_to :plan
  has_one :notification, as: :subject, dependent: :destroy

  # 通知コールバック
  after_create_commit :create_notifications

  private

  # 通知機能
  def create_notifications
    Notification.create(subject: self, user: plan.user, action_type: :liked_to_own_plan)
  end

end
```

</details>



<details><summary>relationshipモデル</summary>

```relationship.rb
class Relationship < ApplicationRecord

  # アソシエーション
  belongs_to :follower, class_name: "User"
  belongs_to :followed, class_name: "User"
  has_one :notification, as: :subject, dependent: :destroy

  # 通知コールバック
  after_create_commit :create_notifications

  private

  # 通知機能
  def create_notifications
    Notification.create(subject: self, user: followed, action_type: :followed_me)
  end

end
```

</details>



<details><summary>chatモデル・roomモデル</summary>

```chat.rb
class Chat < ApplicationRecord

  # アソシエーション
  belongs_to :user
  belongs_to :room
  has_many :notification, as: :subject, dependent: :destroy

  # 通知コールバック
  after_create_commit :create_notifications

  private

  # 通知機能
  def create_notifications
    room = self.room
    recipient_user = room.users.where.not(id: self.user.id).first
    Notification.create(subject: self, user: recipient_user, action_type: :new_message)
  end

end
```

DM機能の通知に関しては、送信元のユーザーIDを取得し`recipient_user`に代入してから、
subjectのuser情報に渡します

```room.rb
class Room < ApplicationRecord

  # アソシエーション
  has_many :user_rooms, dependent: :destroy
  has_many :chats, dependent: :destroy
  has_many :users, through: :user_rooms

end
```

user_roomモデルを介してuser情報をchatモデルに渡すため、
`has_many :users, through: :user_rooms`の記述が必要です

</details>

:::note alert
planモデルはバルクインサートを使用した実装方法のため他とは記述が異なります
:::

<details open><summary>planモデル</summary>

```plan.rb
class Plan < ApplicationRecord

  # アソシエーション
  belongs_to :user
  has_many :comments, dependent: :destroy
  has_many :likes, dependent: :destroy
  has_many :notifications, as: :subject, dependent: :destroy

  # 通知機能
  after_create_commit :notify_followers

  private

  def notify_followers
    follower_ids = user.followers.pluck(:id)
    if follower_ids.any?
      notifications = follower_ids.map do |follower_id|
        {
          subject_type: 'Plan',
          subject_id: self.id,
          user_id: follower_id,
          action_type: 'new_plan',
          created_at: Time.current,
          updated_at: Time.current
        }
      end

      Notification.insert_all(notifications)
    end
  end
end
```

</details>

新規投稿した際に、フォロワー全員に通知する必要があります
フォロワーが1人や2人なら問題ありませんが、何万人といれば一つの投稿をするたびにフォロワーの人数だけインサートをすることになってしまい、投稿処理に時間を要してしまう可能性があります
これをN+1問題といいます
この問題を解決するために、バルクインサートを使用します

<details><summary>バルクインサート</summary>

:::note info
##### 🌱バルクインサートとは
テーブルに複数のレコードを登録する時に、
1行1行インサートすると効率が悪いので、まとめてインサートすること
:::

詳しくはこちらの記事がわかりやすかったです

https://qiita.com/xhnagata/items/79184ded56158ea1b97a




バルクインサートは、以前までgemの追加が必要でしたが、
rails6で公式に追加されたため、insert_allメソッドとして使用できるようになりました

:::note warn
##### バルクインサート使用時の注意点⚠️
`insert_all`を使う場合、`created_at`,`updated_at`は明示的に指定する必要があります！`Time.current`を記述するようにしましょう！
:::

</details>

`follower_ids = user.followers.pluck(:id)`
投稿したユーザーのフォロワーidをpluckで取得し`follower_ids`に代入します
また、pluckを使用すると取得した情報を配列として返してくれます
例えば今回のuser.idだと[1, 2, 3]といった具合です

`if follower_ids.any?`
通知を作成する際にフォロワーがいるか確認します
この記述がないとフォロワーがいないユーザーは投稿するとエラーが起きます！！！

<details><summary>pluckメソッドとは</summary>

:::note info
ActiveRecordのクエリメソッドの一つ
特定のカラムの値を直接データベースから取得し、配列として返します
単一カラム、複数カラム、条件指定での取得全て可能
```
# 単一カラム
names = User.pluck(:name)

# 複数カラム
user_info = User.pluck(:name, :email)

# 条件指定
names = User.where("age >= 30").pluck(:name)

```
:::
</details>

```plan.rb
notifications = follower_ids.map do |follower_id|
  {
    subject_type: 'Plan',
    subject_id: self.id,
    user_id: follower_id,
    action_type: 'new_plan',
    created_at: Time.current,
    updated_at: Time.current
  }
end
```

mapメソッドを使って各フォロワーのIDに対して通知のハッシュを作成しています
具体的には、各フォロワーID（follower_id）に対して、次のようなハッシュを生成しています

```
[
  { subject_type: 'Plan', subject_id: 123, user_id: 1, action_type: 'new_plan', created_at: '2024-06-15 12:00:00', updated_at: '2024-06-15 12:00:00' },
  { subject_type: 'Plan', subject_id: 123, user_id: 2, action_type: 'new_plan', created_at: '2024-06-15 12:00:00', updated_at: '2024-06-15 12:00:00' },
  { subject_type: 'Plan', subject_id: 123, user_id: 3, action_type: 'new_plan', created_at: '2024-06-15 12:00:00', updated_at: '2024-06-15 12:00:00' }
]
```


最後に、生成した通知の配列を一括でデータベースに挿入します
`Notification.create`の代わりに`Notification.insert_all(notifications)`を使用します
これにより、複数の通知を一度に作成でき、パフォーマンスの向上が期待できます

# View作成
```_footer.html.erb
<li class="mx-3">
  <%= link_to notifications_path do %>
  <i class="fa-solid fa-bell" style="color: #FFD43B;">通知</i>
  <% end %>
</li>
```


```index.html.erb
<div class="container">
  <div class="row">
    <div class="col-sm-12 col-md-10 col-lg-8 bg-white my-3 p-4 shadow-lg mx-auto rounded">

      <% if @notifications.present? %>
        <%= link_to "全削除",notification_path(@notifications), method: :delete,class:"btn btn-danger" %>
        <% @notifications.each do |notification| %>
          <%= render "#{notification.action_type}", notification: notification %>
        <% end %>
      <% else %>
        通知はありません
      <% end %>

    </div>
  </div>
</div>
```

<details open><summary>comment</summary>

```notifications/_commented_to_own_plan.html.erb
<%= link_to transition_path(notification) do %>
  <div>
    <%= link_to notification.subject.user.name, user_path(notification.subject.user) %>
    があなたの
    <%= link_to '投稿', plan_path(notification.subject.plan) %>
    にコメントしました
    <%= " (#{time_ago_in_words(notification.created_at)}前)" %>
  </div>
<% end %>
```

</details>

<details><summary>like</summary>

```_liked_to_own_plan.html.erb
<%= link_to transition_path(notification) do %>
  <div>
    <%= link_to notification.subject.user.name, user_path(notification.subject.user) %>
    があなたの
    <%= link_to '投稿', plan_path(notification.subject.plan) %>
    にいいねしました
    <%= " (#{time_ago_in_words(notification.created_at)}前)" %>
  </div>
<% end %>
```

</details>

<details><summary>follower</summary>

```_followed_me.html.erb
<%= link_to transition_path(notification) do %>
  <div>
    <% if notification.subject.follower.guest_user? %>
      ゲストユーザー
    <% else %>
      <%= link_to notification.subject.follower.name, user_path(notification.subject.follower) %>
    <% end %>
    があなたをフォローしました
    <%= " (#{time_ago_in_words(notification.created_at)}前)" %>
  </div>
<% end %>
```

</details>

<details><summary>chat</summary>

```_new_message.html.erb
<%= link_to transition_path(notification) do %>
  <div>
    <%= link_to notification.subject.user.name, user_path(notification.subject.user) %>
    さんから
    <%= link_to 'メッセージ', chat_path(notification.subject.user) %>
    が届いています！
    <%= " (#{time_ago_in_words(notification.created_at)}前)" %>
  </div>
<% end %>
```

</details>

<details><summary>new_plan</summary>

```_new_plan.html.erb
<%= link_to transition_path(notification) do %>
  <div>
    <%= link_to notification.subject.user.name, user_path(notification.subject.user) %>
    が新しい
    <%= link_to '投稿', plan_path(notification.subject) %>
    をしました
    <%= " (#{time_ago_in_words(notification.created_at)}前)" %>
  </div>
<% end %>
```

</details>

#### helper

ヘルパーについてはこちらの記事に詳しく書かれています

https://qiita.com/yukiyoshimura/items/f0763e187008aca46fb4

```notifications_helper.rb
module Public::NotificationsHelper

  def transition_path(notification)
    case notification.action_type.to_sym
    when :new_plan
      plan_path(notification.subject)
    when :commented_to_own_plan
      plan_path(notification.subject, anchor: "comment-#{notification.subject.id}")
    when :liked_to_own_plan
      plan_path(notification.subject.plan)
    when :followed_me
      user_path(notification.subject.follower)
    when :new_message
      user_path(notification.subject.user)
    end
  end

end
```

`transition_path`
通知機能のヘルパーメソッドとして、通知の内容に応じて適切なURLパスを生成するためのメソッドです
このメソッドは、通知をクリックしたときにユーザーをどのページに遷移させるかを決定します
今回の場合は、通知のaction_typeに応じて異なるURLパスを生成しています

`notification.action_type.to_sym`
文字列として格納されている action_type をシンボルに変換するために使用されています
シンボルは、Rubyの内部で効率的に処理されるため、case 文や when 節での比較に使用されます

# さいごに
enumを使用したaction_typeの分岐やバルクインサート、helperなど初めてのことばかりで理解が遅かったですが、実装できてよかったです！

# 参照記事
参考にさせていただきました！
ありがとうございました！

https://zenn.dev/goldsaya/articles/0f387f3e62ff92#%E5%8F%82%E8%80%83%E3%81%AB%E3%81%95%E3%81%9B%E3%81%A6%E3%81%84%E3%81%9F%E3%81%A0%E3%81%84%E3%81%9F%E8%A8%98%E4%BA%8B%F0%9F%8C%B1

https://zenn.dev/tya0116/articles/f5cf48deee50d9#%E5%90%84%E3%83%A2%E3%83%87%E3%83%AB%E3%81%AB%E3%82%A2%E3%82%AF%E3%82%B7%E3%83%A7%E3%83%B3%E3%82%92%E8%BF%BD%E5%8A%A0%E3%81%99%E3%82%8B

https://qiita.com/ham0215/items/d307a242995d440d854f

https://qiita.com/xhnagata/items/79184ded56158ea1b97a

https://qiita.com/mishiwata1015/items/f61a9d73b90ff8e4fb5f