👻

Rails 通知機能

に公開

はじめに

ポートフォリオ制作中のプログラミング初学者です🔰
今日は通知機能を実装しました!
私は投稿機能が複数に分かれているため、ポリモーフィック関連付けを使ったやり方で実装してみました。

完成イメージ

  • まだレイアウトは適当ですが、こんな感じです😣
  • 名前も投稿もリンクにしていて飛べます。
  • 全削除で全部の通知を消すことができます。

  • 未読の通知があれば、ヘッターのアイコンが黄色くなります。既読したら元の色に戻ります。

通知が来る条件

  • フォローされた時
  • 自分の投稿にいいねがあった時
  • 自分の投稿にコメントがあった時

前提
Post_workoutについての

  • コメント機能(WorkoutComment)
  • いいね機能(WorkoutLike)
  • フォロー機能(Relationship)

が実装されている。

実装

モデル作成

通知モデルを作成!

$ rails g model Notification

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

xxxxxxxx_create_notification.rb
class CreateNotifications < ActiveRecord::Migration[6.1]
  def change
    create_table :notifications do |t|
      t.references :subject, polymorphic: true
      t.references :end_user, foreign_key: true
      t.integer :action_type, null: false
      t.boolean :checked
      
      t.timestamps
    end
  end
end
schema
  create_table "notifications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
    t.string "subject_type"
    t.bigint "subject_id"
    t.bigint "end_user_id", null: false
    t.integer "action_type", null: false
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
    t.boolean "checked", default: false, null: false
    t.integer "visitor_id"
    t.integer "visited_id"
    t.index ["end_user_id"], name: "index_notifications_on_end_user_id"
    t.index ["subject_type", "subject_id"], name: "index_notifications_on_subject"
  end

:subject, polymorphic: true

  • ポリモーフィック関連付けを使うと、ある1つのモデルが他の複数のモデルに属していることを、1つの関連付けだけで表現できます。
    relationship_idcomment_idlike_idsubjectとして一つのカラムにまとめます。
    こうすることで、notification.subjectの形でLike, Comment, Relationshipのインスタンスが呼び出せるようになるみたいです。

:action_type

  • enum で使うので integer 型。
  • いいね・コメント・フォローが押された時、起こったアクションごとに表示する HTML を変えたい。(一つのファイルに複数の表示処理を書くよりスッキリするから)
    そのため、action_typeカラムというものを設置して、そこに Notification インスタンスの状態が、いいね・ コメント・フォロー のどの状態なのかを保存しておく。

:checked

  • 通知の既読の判断に使います。初期値は、false(通知未確認)にしておきましょう。
rails db:migrate

忘れずに!

routing

私はpublic配下に作りたかったので下記のように記述しています。

config/routes.rb
scope module: :public do
  resources :notifications, only: [:index]
end

できたURL

notifications GET    /notifications(.:format)     public/notifications#index

controller

app/controllers/public/notifications_controller.rb
class Public::NotificationsController < ApplicationController
  def index
    @notifications = current_end_user.notifications.order(created_at: :desc).page(params[:page]).per(20)
    @notifications.where(checked: false).each do |notification|
      notification.update(checked: true)
    end
  end

  def destroy
    @notifications = current_end_user.notifications.destroy_all
    redirect_to notifications_path
  end
end

current_end_user.notifications
現在ログインしているユーザー(current_end_user)に関連付けられた全ての通知(notifications)を取得しています。(EndUserモデルに記述されたhas_many :notificationsという関連付けにより可能になっています。)

.order(created_at: :desc)
これにより、取得した通知を作成日時(created_at)の降順でソート(並び替え)します。

既読させる

@notifications.where(checked: false).each
ここで取得した通知の中から、まだ確認されていない(checked: false)通知を取り出しています。
そして、それぞれの未確認通知に対して、以下の操作を行います。

notification.update(checked: true)
未確認の通知を確認済み(checked: true)に更新します。

(indexページに入った瞬間、取得された全ての通知のchecked値はtrue(つまり確認済み)になります。この設計は、ユーザが通知ページを開いたときにすべての通知を「読んだ」ものとして扱うためのものです。)

model

app/models/workout_like.rb
class WorkoutLike < ApplicationRecord
  belongs_to :end_user
  belongs_to :post_workout

  has_one :notification, as: :subject, dependent: :destroy

  after_create_commit :create_notifications

  private
  def create_notifications
    Notification.create(subject: self, end_user: self.post_workout.end_user, action_type: :liked_to_own_post)
  end
end

has_oneメソッド

  • 「このモデルは他のモデルを1つだけ持っている」という関連性を示します。
  • この場合、WorkoutLikeモデルの各インスタンスはNotificationモデルのインスタンスを1つだけ持つことができます。

as: :subject

  • ポリモーフィック関連付けを示しており、NotificationモデルのsubjectがWorkoutLikeモデルになります。

※ after_create_commit :create_notificationsというコールバック

  • after_create_commitはRailsのActiveRecordにおけるコールバックメソッドの一つ。
  • WorkoutLikeモデルのインスタンスがデータベースに保存された(create)後(after)に、create_notificationsメソッドを実行します。
  • このメソッドでは新しいNotificationインスタンスが作成され、そのsubjectはself(WorkoutLikeのインスタンス)、end_userはself.post_workout.end_user、action_typeは:liked_to_own_postとなります。

コールバックとは
とは、オブジェクトのcreate, update, destroyなどの間(ライフサイクル期間)における特定の瞬間に呼び出されるメソッドのこと。コールバックを利用することで、Active Recordオブジェクトが作成/保存/更新/削除/検証/データベースからの読み込み、などのイベント発生時に常に実行されるメソッドを作れる。
https://wa3.i-3-i.info/word143.html

workout_comment、relationshipも同様に記述していきます。

app/models/workout_comment.rb
class WorkoutComment < ApplicationRecord
  belongs_to :end_user
  belongs_to :post_workout

  has_one :notification, as: :subject, dependent: :destroy

  after_create_commit :create_notifications

  private
  def create_notifications
    Notification.create(subject: self, end_user: post_workout.end_user, action_type: :commented_to_own_post)
  end
end
app/models/relationship.rb
class Relationship < ApplicationRecord
  belongs_to :follower, class_name: "EndUser"
  belongs_to :followed, class_name: "EndUser"

  has_one :notification, as: :subject, dependent: :destroy

  after_create_commit :create_notifications

  private
  def create_notifications
    Notification.create(subject: self, end_user: followed, action_type: :followed_me)
  end
end
app/models/post_workout.rb
class PostWorkout < ApplicationRecord
  has_one :notification, as: :subject, dependent: :destroy
end

post_workoutにも記述。
(PostWorkout インスタンスが削除されたときに関連する Notification インスタンスも一緒に削除するため)

app/models/end_user.rb
class EndUser < ApplicationRecord
  has_many :notifications, dependent: :destroy
end

Notification

app/models/notification.rb
class Notification < ApplicationRecord

  belongs_to :subject, polymorphic: true
  belongs_to :end_user
  
  enum action_type: { commented_to_own_post: 0, liked_to_own_post: 1, followed_me: 3}
end

enumについて

view

  • 通知一覧を表示するためのリンク
app/views/layouts/_header.html.erb
      <li class="nav-item">
        <%= link_to notifications_path, class: "nav-link" do %>
        <i class="fa-sharp fa-solid fa-bell"></i>
        通知一覧
        <% end %>
      </li>
  • 通知一覧の表示
app/views/public/notifications/index.html.erb
  <h4>通知</h4>
  <% if @notifications.present? %>
    <%= link_to "全削除",notification_path(@notifications), method: :delete,class:"btn btn-light" %>
    <% @notifications.each do |notification| %>
        <%= render "#{notification.action_type}", notification: notification %>
    <% end %>
  <% else %>
    通知はありません
  <% end %>
app/views/public/notifications/_liked_to_own_post.html.erb
<%= link_to transition_path(notification) do %>
  <div>
    <%= link_to notification.subject.end_user.first_name, end_user_path(notification.subject.end_user) %>
    があなたの
    <%= link_to '投稿', post_workout_path(notification.subject.post_workout) %>
    にいいねしました
    <%= " (#{time_ago_in_words(notification.created_at)}前)" %>
  </div>
<% end %>
app/views/public/notifications/_followed_me.html.erb
<%= link_to transition_path(notification) do %>
  <div>
    <% if notification.subject.follower.guest? %>
      ゲストユーザー
    <% else %>
      <%= link_to notification.subject.follower.first_name, end_user_path(notification.subject.follower) %>
    <% end %>
    があなたをフォローしました
    <%= " (#{time_ago_in_words(notification.created_at)}前)" %>
  </div>
<% end %>
app/views/public/notifications/_commented_to_own_post.html.erb
<%= link_to transition_path(notification) do %>
  <div>
    <%= link_to notification.subject.end_user.first_name, end_user_path(notification.subject.end_user) %>
    があなたの
    <%= link_to '投稿', post_workout_path(notification.subject.post_workout) %>
    にコメントしました
    <%= " (#{time_ago_in_words(notification.created_at)}前)" %>
  </div>
<% end %>

時間の表示について

app/helpers/public/notifications_helper.rb
module Public::NotificationsHelper
  def transition_path(notification)
    case notification.action_type.to_sym
    when :commented_to_own_post
      post_workout_path(notification.subject.post_workout, anchor: "comment-#{notification.subject.id}")
    when :liked_to_own_post
      post_workout_path(notification.subject.post_workout)
    when :followed_me
      end_user_path(notification.subject.follower)
    end
  end
end

notification.action_type.to_sym

  • 通知のaction_typeがシンボルに変換され、それによって行われるアクションを判断します。これはcase文を使用して行われます。

:commented_to_own_postの場合

  • へルパーメソッドは通知が関連づけられた投稿へのパスを返します。その際、アンカーリンク(anchor:)を指定して、ユーザーがクリックしたときにコメント部分までスクロールするようにします。

:liked_to_own_postの場合

  • ヘルパーメソッドは通知が関連づけられた投稿へのパスを返します。

:followed_meの場合

  • フォロワーのプロフィールページへのパスを返します。

helperファイルを初めて使ってみましたが、
使う理由としてはviewをシンプルにしてコードを見やすくする為のようです。

ビューで条件分岐を書く必要がなくなる&同じロジックを繰り返し書く必要がなくなります!

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

通知が来た時にアイコンを変える

view

app/views/layouts/_header.html.erb
      <li class="nav-item">
        <%= link_to notifications_path, class: "nav-link" do %>
          <% if current_end_user.notifications.where(checked: false).exists? %>
            <i class="fa-sharp fa-solid fa-bell fa-beat" style="color: #f2ee7d;"></i>
          <% else %>
            <i class="fa-sharp fa-solid fa-bell"></i>
          <% end %>
          通知一覧
        <% end %>
      </li>

<% if current_end_user.notifications.where(checked: false).exists? %>

  • 現在ログインしているユーザー(current_end_user)の未チェックの通知が存在するかをチェックします。checked: falseの通知があるなら、条件式はtrueを返し、黄色いアイコンの方が表示されます。

controller

ユーザーが通知一覧ページを表示するときに、すべての未チェック通知をチェック済みに更新する記述を足します。

app/controllers/public/notifications_controller.rb
class Public::NotificationsController < ApplicationController
  def index
    @notifications = current_end_user.notifications.order(created_at: :desc).page(params[:page]).per(20)
    @notifications.where(checked: false).each do |notification|
      notification.update(checked: true)
    end
  end
end

@notifications.where(checked: false).each do |notification|

  • まだチェックされていない(checked: false)すべての通知に対してループを実行します。

notification.update(checked: true)

  • 各未チェック通知を「チェック済み」に更新します。の更新はデータベースに保存され、その結果、その通知は次回以降のこのループでは処理されません。

参考にさせていただいた記事🌱

https://o6ga2wa8.hateblo.jp/entry/2021/10/15/150927

https://qiita.com/nekojoker/items/80448944ec9aaae48d0a

https://qiita.com/ki_87/items/6feb324140bf5b0349ff

さいごに

盛りだくさんで、説明不足なところが多々あると思いますが、このような流れで実装できました!

はじめてポリモーフィック関連付けやヘルパーファイルなどに触れて、時間はかかってしまいましたがとてもいい勉強になりました。

ただ、私はいいねとコメントを非同期で実装しているので、リロードしないとヘッターのアイコンが変わらない点が心残りです。時間が余ればどうにかできないか方法を見つけたいです。

この他にももっといい記述法はたくさんあると思いますので、少しでも参考になれば幸いです。
間違いなどあればぜひ教えてください!

Discussion