Zenn
🦓

[Rails]ポリモーフィック関連付け③:通知機能

に公開

はじめに

通知機能を実装していきます。

Railsにおける通知機能は、一般的にメール、プッシュ通知、またはウェブ上の通知バナーとして表示されることがあります。Railsでは、通知機能を実装するためにいくつかの方法があります。Action Mailerを使用したメール通知、webプッシュ通知、データベースを使用した通知の保存、ActiveJobを使用した非同期通知、Action Cableによるリアルタイムの通知などさまざまな手法があります。

今回では通知をすぐに送信するのではなく、データベースに通知情報を保存しておき、ユーザーが次にアプリをアクセスした際に通知を表示します。このような場合、ユーザーに関連する通知を保持するための通知テーブルを作成することが一般的です。

データベースに通知を保存させることによって、アプリケーション内で通知を管理したり、未読・既読状態を追跡したりするのに便利です。

また、通知の送信方法として、Active Jobのdeliver_laterメソッドと併用し通知を非同期に処理することができます。

環境

Rails 7.0.4
ruby 3.2.2

Notificationモデルを作成する

sender:通知を送ったユーザー
recipient:通知を受け取ったユーザー
unread:未読・既読の二つしかないのでbooleanにします。デフォルトでtrue(未読)にします。

rails generate migration CreateNotifications sender:references recipient:references notifiable:references{polymorphic} unread:boolean
      invoke  active_record
      create    db/migrate/20230725141927_create_notifications.rb
db/migrate/xxx_create_notifications.rb
class CreateNotifications < ActiveRecord::Migration[7.0]
  def change
    create_table :notifications do |t|
      t.references :sender, null: false, foreign_key: { to_table: :users }
      t.references :recipient, null: false, foreign_key: { to_table: :users }
      t.references :notifiable, polymorphic: true, null: false
      t.boolean :unread, default: true

      t.timestamps
    end
  end
end
bin/rails db:migrate
== 20230725141927 CreateNotifications: migrating ==============================
-- create_table(:notifications)
   -> 0.0038s
== 20230725141927 CreateNotifications: migrated (0.0039s) =====================

通知用URLを追加する

通知一覧と未読・既読の切り替え用のアクションを追加します。
全ての通知を既読するというボタンも追加したいので定義します。

config/routes.rb
# config/routes.rb
resources :notifications, only: %i[index update] do
  collection do
    delete :mark_all_as_read
  end
end
rails routes -g notification
                        Prefix Verb   URI Pattern                               Controller#Action
mark_all_as_read_notifications DELETE /notifications/mark_all_as_read(.:format) notifications#mark_all_as_read
                 notifications GET    /notifications(.:format)                  notifications#index
                  notification PATCH  /notifications/:id(.:format)              notifications#update
                               PUT    /notifications/:id(.:format)              notifications#update

モデルの関連付けを設定する

app/models/notification.rb
class Notification < ApplicationRecord
  belongs_to :sender, class_name: 'User', foreign_key: 'sender_id'
  belongs_to :recipient, class_name: 'User', foreign_key: 'recipient_id'
  belongs_to :notifiable, polymorphic: true
  
  scope :unread, -> { where(unread: true) }
end

senderrecipientUserモデルに対する関連付けを定義しています。これらの関連付けにより、通知に送信者 (sender) と受信者 (recipient) の情報を紐付けることができます。foreign_key: 'sender_id'foreign_key: 'recipient_id'は、Userモデルが持つ外部キーに対応していることを指定しています。

app/models/user.rb
class User < ApplicationRecord
...
    has_many :sent_notifications, class_name: 'Notification', foreign_key: 'sender_id', dependent: :destroy
    has_many :received_notifications, class_name: 'Notification', foreign_key: 'recipient_id', dependent: :destroy
    has_many :notifications, as: :notifiable, dependent: :destroy
end

これらの関連付けにより、ユーザーは自分が送信した通知と受け取った通知を簡単に取得できるようになります。
また、ポリモーフィックな関連付けを使うことで、通知が他のモデルに対して行われた場合にもユーザーがそれらの通知を関連付けられるため、柔軟な通知システムを構築できます。
dependent: :destroyオプションを使用することで、ユーザーが削除された際にそれに関連する通知も自動的に削除されるため、データベースの整合性が保たれます。

notificationコントローラーを作成する

indexアクションは通知一覧を取得します。
updateアクションは通知を未読(unread: true)から既読(unread: false)にします。
mark_all_as_readアクションは全ての通知を削除します。
has_many :received_notificationsの関連付けで通知コレクションを取得し、さらに定義したunreadスコープで未読通知だけを取得することができます。

app/controllers/notifications_controller.rb
class NotificationsController < ApplicationController
    before_action :authenticate_user!
  
    def index
      @notifications = current_user.received_notifications.unread.order(created_at: :desc)
      
      respond_to do |format|
        format.turbo_stream do
            render turbo_stream: [
                turbo_stream.append('notifications', partial: 'notifications/notification', collection: @notifications, as: :notification ),
                turbo_stream.replace('notification_count', partial: 'notifications/notification_count', locals: { notifications: @notifications })
            ]
        end
        format.html
      end
    end
  
    def update
      @notification = current_user.received_notifications.find(params[:id])
      @notification.update(unread: false)
      if @notification
        respond_to do |format|
          format.turbo_stream do
            render turbo_stream: [
                turbo_stream.remove(@notification),
                turbo_stream.replace('notification_count', partial: 'notifications/notification_count', locals: { notifications: current_user.notifications.unread })
            ]
          end
          format.html
        end
      else
        respond_to do |format|
          format.turbo_stream do
            render turbo_stream: [
                turbo_stream.replace('notifications', '')
            ]
          end
        end
      end
    end
  
    def mark_all_as_read
      @notifications = current_user.received_notifications.unread
      @notifications.destroy_all
      respond_to do |format|
        format.turbo_stream do
          render turbo_stream: [
            turbo_stream.remove('notifications'),
            turbo_stream.replace('notification_count', partial: 'notifications/notification_count', locals: { notifications: current_user.notifications.unread }),
            turbo_stream.prepend('no_notification', partial: 'notifications/no_notification')
          ]
        end
        format.html
      end
    end
end

通知送信用コールバックメソッドを作成する

新規のコメントといいね!がある時に通知を送りたいのでメソッドを作成します。

app/models/comment.rb
class Comment < ApplicationRecord
...
    after_create_commit :create_comment_notification

    private
  
    def create_comment_notification
    return if self.user_id == self.commentable.user_id
      if self.commentable
        Notification.create(
          sender_id: self.user_id,
          recipient_id: self.commentable.user_id,
          notifiable: self
        )
      end
     end
end

return if self.user_id == self.commentable.user_id自分投稿などにいいね!とコメントした場合通知を送らないようにします。

app/models/like.rb
class Like < ApplicationRecord
...
  after_create_commit :create_like_notification

  private

  def create_like_notification
    return if self.user_id == self.likeable.user_id
    if self.likeable
      Notification.create(
        sender_id: self.user_id,
        recipient_id: self.likeable.user_id,
        notifiable: self
      )
    end
  end
end

after_create_commit は、Railsで提供されるコールバックの1つであり、Active Recordオブジェクトが作成された後に非同期的な処理を実行する際に使用されます。このコールバックは、トランザクションが完了した後に実行されるため、データベースに正常にオブジェクトが保存された後にアクションを実行したい場合に役立ちます。例として、通知やメールの送信、ジョブキューにジョブの追加などの非同期タスクを処理することが考えられます。

https://railsguides.jp/active_record_callbacks.html#トランザクションのコールバック

Applicationコントローラーにヘルパーを読み込む

通知変数をグローバル変数にします。

app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
    before_action :set_notification_object
    include NotificationsHelper
    
    private
    
    def set_notification_object
        @notifications = current_user.received_notifications.unread.order(created_at: :desc) if current_user
    end
end

通知の本文を作成する

ヘルパーメソッドを作成します。
いいね!の場合、投稿やアイデアはtitleがあるためタイトルを表示させます。
コメントの場合titleではなくbodyになるためif文で分岐します。

app/helpers/notifications_helper.rb
module NotificationsHelper
    def generate_notification_message(notification)
	return unless notification
	
        case notification.notifiable_type
        when 'Comment'
            "#{notification.sender.user_name} が <strong>#{t("like.likeable_type.#{notification.notifiable.commentable_type}")}</strong> - <strong>#{notifiable_name(notification)}</strong> にコメントしました".html_safe
        when 'Like'
            "#{notification.sender.user_name} が <strong><#{t("like.likeable_type.#{notification.notifiable.likeable_type}")}></strong> - <strong>#{notifiable_name(notification)}</strong> にいいね!しました".html_safe
        else
            '新規通知がありました'
        end
    end

    private

    def notifiable_name(notification)
        return unless notification && notification.notifiable
	
        case notification.notifiable_type
        when 'Comment'
            "#{notification.notifiable.commentable.title}"
        when 'Article'
            "#{notification.notifiable.title}"
        when 'Idea'
            "#{notification.notifiable.title}"
        when 'Like'
            if notification.notifiable.likeable_type == 'Comment'
                "#{notification.notifiable.likeable.body}"
            else
                "#{notification.notifiable.likeable.title}"  
            end
        else
            '新規通知がありました'
        end
    end
end

通知の構文です:

通知タイプの訳文を追加する

config/locals/views/ja.yml
ja:
  like:
    likeable_type:
      Article: '投稿'
      Idea: 'アイデア'
      Comment: 'コメント'

通知一覧を作成する

ヘッダーにベルアイコンと通知数を表示させます。

app/views/shared/_header.html.erb
<li class="nav-item mt-3 me-3">
     <div class="dropdown">
         <button type="button" class="btn btn-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
             <%= icon 'far', 'bell' %>
             <%= render 'notifications/notification_count' %>
        </button>
        <%= render 'shared/notifications_dropdown' %>
     </div>
</li>

ヘッダーのベルアイコンからドロップダウンで通知一覧を表示させたいのでパーシャルを作成します。

app/views/shared/_notification_dropdown.html.erb
<ul class="dropdown-menu dropdown-menu-lg-end">
    <li class="dropdown-item">通知</li>
    <li><hr class="dropdown-divider"></li>
    <%= turbo_frame_tag 'notifications' do %>
        <% if @notifications.present? %>
                  <%= link_to '全てを既読する', mark_all_as_read_notifications_path,
                  class: 'btn btn-outline-dark btn-sm mx-auto',
                  method: :delete,
                  remote: true,
                  data: {turbo_method: :delete } %>
                  <% @notifications.each do |notification| %>
                      <%= render 'notifications/notification', { notification: notification } %>
                  <% end %>
        <% else %>
            <%= render 'notifications/no_notification' %>
        <% end %>
    <% end %>
</ul>

通知パーシャルを作成する

通知を既読できるようにチェックマークを追加します。
チェクマークをクリックしたら通知が消えるようにします。

app/views/notifications/_notification.html.erb
<%= turbo_frame_tag dom_id(notification) do %>
<li data-notification-id="<%= notification.id %>" class="<%= notification.unread ? 'unread' : '' %> dropdown-item">
  <%= link_to notification_path(notification), 
  method: :patch, 
  data: { turbo_method: :patch } do %>
              <%= icon 'fas', 'check' %>
            <% end %>
  <%= generate_notification_message(notification)%>
  <span class="notification-time"><%= time_ago_in_words(notification.created_at) %> ago</span>
</li>
<% end %>

Image from Gyazo

通知数パーシャルを作成する

app/views/notifications/_notification_count.html.erb
<%= turbo_frame_tag 'notification_count' do %>
    <% if current_user.received_notifications.unread.present? %>
      <span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger">
        <%= current_user.received_notifications.unread.size %>
      </span>
    <% end %>
<% end %>

通知がない場合のパーシャルも作成します。

app/views/notifications/_no_notification.html.erb
<%= turbo_frame_tag 'no_notification' do %>
  <li class="dropdown-item nav-item">通知がありません。</li>
<% end %>

コメントの作成後新規通知を作成されることを確認します。

Started POST "/ideas/3/comments" for ::1 at 2023-07-26 13:04:21 +0900
Processing by CommentsController#create as TURBO_STREAM
  Parameters: {"authenticity_token"=>"[FILTERED]", "comment"=>{"body"=>"comment"}, "commit"=>"登録する", "idea_id"=>"3"}
  User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? ORDER BY "users"."id" ASC LIMIT ?  [["id", 15], ["LIMIT", 1]]
  ↳ app/controllers/application_controller.rb:26:in `set_notification_object'
  Idea Load (0.1ms)  SELECT "ideas".* FROM "ideas" WHERE "ideas"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
  ↳ app/controllers/comments_controller.rb:71:in `block in find_commentable'
  TRANSACTION (0.0ms)  begin transaction
  ↳ app/controllers/comments_controller.rb:9:in `block in create'
  User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 15], ["LIMIT", 1]]
  ↳ app/controllers/comments_controller.rb:9:in `block in create'
  Comment Create (0.3ms)  INSERT INTO "comments" ("body", "user_id", "created_at", "updated_at", "commentable_type", "commentable_id") VALUES (?, ?, ?, ?, ?, ?)  [["body", "comment"], ["user_id", 15], ["created_at", "2023-07-26 13:04:21.615241"], ["updated_at", "2023-07-26 13:04:21.615241"], ["commentable_type", "Idea"], ["commentable_id", 3]]
  ↳ app/controllers/comments_controller.rb:9:in `block in create'
  TRANSACTION (1.2ms)  commit transaction
  ↳ app/controllers/comments_controller.rb:9:in `block in create'
  TRANSACTION (0.0ms)  begin transaction
  ↳ app/models/comment.rb:13:in `create_comment_notification'
  User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 15], ["LIMIT", 1]]
  ↳ app/models/comment.rb:13:in `create_comment_notification'
  User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 14], ["LIMIT", 1]]
  ↳ app/models/comment.rb:13:in `create_comment_notification'
  Notification Create (0.3ms)  INSERT INTO "notifications" ("sender_id", "recipient_id", "notifiable_type", "notifiable_id", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?, ?)  [["sender_id", 15], ["recipient_id", 14], ["notifiable_type", "Comment"], ["notifiable_id", 24], ["created_at", "2023-07-26 13:04:21.627082"], ["updated_at", "2023-07-26 13:04:21.627082"]]
  ↳ app/models/comment.rb:13:in `create_comment_notification'
  TRANSACTION (0.8ms)  commit transaction
  ↳ app/models/comment.rb:13:in `create_comment_notification'
  Like Exists? (0.1ms)  SELECT 1 AS one FROM "likes" WHERE "likes"."likeable_id" = ? AND "likes"."likeable_type" = ? AND "likes"."user_id" = ? LIMIT ?  [["likeable_id", 24], ["likeable_type", "Comment"], ["user_id", 15], ["LIMIT", 1]]
  ↳ app/views/likes/_like.html.erb:2
  Rendered likes/_like.html.erb (Duration: 7.4ms | Allocations: 4279)
  Like Count (0.1ms)  SELECT COUNT(*) FROM "likes" WHERE "likes"."likeable_id" = ? AND "likes"."likeable_type" = ?  [["likeable_id", 24], ["likeable_type", "Comment"]]
  ↳ app/views/likes/_like_count.html.erb:2
  Rendered likes/_like_count.html.erb (Duration: 2.1ms | Allocations: 1140)
  Rendered comments/_comment.html.erb (Duration: 13.2ms | Allocations: 7303)
  Rendered comments/_form.html.erb (Duration: 1.8ms | Allocations: 943)
Completed 200 OK in 76ms (Views: 0.1ms | ActiveRecord: 5.8ms | Allocations: 46432)

通知を既読にします。

Processing by NotificationsController#update as TURBO_STREAM
  Parameters: {"id"=>"55"}
  User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? ORDER BY "users"."id" ASC LIMIT ?  [["id", 14], ["LIMIT", 1]]
  ↳ app/controllers/application_controller.rb:26:in `set_notification_object'
  Notification Load (0.1ms)  SELECT "notifications".* FROM "notifications" WHERE "notifications"."recipient_id" = ? AND "notifications"."id" = ? LIMIT ?  [["recipient_id", 14], ["id", 55], ["LIMIT", 1]]
  ↳ app/controllers/notifications_controller.rb:19:in `update'
  TRANSACTION (0.0ms)  begin transaction
  ↳ app/controllers/notifications_controller.rb:20:in `update'
  User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 15], ["LIMIT", 1]]
  ↳ app/controllers/notifications_controller.rb:20:in `update'
  User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 14], ["LIMIT", 1]]
  ↳ app/controllers/notifications_controller.rb:20:in `update'
  Comment Load (0.0ms)  SELECT "comments".* FROM "comments" WHERE "comments"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/controllers/notifications_controller.rb:20:in `update'
  TRANSACTION (0.0ms)  commit transaction
  ↳ app/controllers/notifications_controller.rb:20:in `update'
  Notification Load (0.1ms)  SELECT "notifications".* FROM "notifications" WHERE "notifications"."recipient_id" = ? AND "notifications"."unread" = ?  [["recipient_id", 14], ["unread", 1]]
  ↳ app/views/notifications/_notification_count.html.erb:2
  Rendered notifications/_notification_count.html.erb (Duration: 1.6ms | Allocations: 949)
Completed 200 OK in 13ms (Views: 0.1ms | ActiveRecord: 0.5ms | Allocations: 8821)

全ての通知を既読にします。

Started DELETE "/notifications/mark_all_as_read" for ::1 at 2023-07-26 21:31:37 +0900
Processing by NotificationsController#mark_all_as_read as TURBO_STREAM
  User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? ORDER BY "users"."id" ASC LIMIT ?  [["id", 14], ["LIMIT", 1]]
  ↳ app/controllers/application_controller.rb:26:in `set_notification_object'
  Notification Load (0.1ms)  SELECT "notifications".* FROM "notifications" WHERE "notifications"."recipient_id" = ? AND "notifications"."unread" = ?  [["recipient_id", 14], ["unread", 1]]
  ↳ app/controllers/notifications_controller.rb:44:in `mark_all_as_read'
  CACHE Notification Load (0.0ms)  SELECT "notifications".* FROM "notifications" WHERE "notifications"."recipient_id" = ? AND "notifications"."unread" = ?  [["recipient_id", 14], ["unread", 1]]
  ↳ app/views/notifications/_notification_count.html.erb:2
  Rendered notifications/_notification_count.html.erb (Duration: 1.2ms | Allocations: 902)
  Rendered notifications/_no_notification.html.erb (Duration: 0.1ms | Allocations: 93)
Completed 200 OK in 6ms (Views: 0.1ms | ActiveRecord: 0.2ms | Allocations: 3988)

おわり

通知機能を追加するためにいくつか優れたgemがあるのでいろいろ試せたらと思います。

Discussion

ログインするとコメントできます