[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
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
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
モデルの関連付けを設定する
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
sender
とrecipient
はUser
モデルに対する関連付けを定義しています。これらの関連付けにより、通知に送信者 (sender) と受信者 (recipient) の情報を紐付けることができます。foreign_key: 'sender_id'
やforeign_key: 'recipient_id'
は、User
モデルが持つ外部キーに対応していることを指定しています。
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
スコープで未読通知だけを取得することができます。
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
通知送信用コールバックメソッドを作成する
新規のコメントといいね!がある時に通知を送りたいのでメソッドを作成します。
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
自分投稿などにいいね!とコメントした場合通知を送らないようにします。
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オブジェクトが作成された後に非同期的な処理を実行する際に使用されます。このコールバックは、トランザクションが完了した後に実行されるため、データベースに正常にオブジェクトが保存された後にアクションを実行したい場合に役立ちます。例として、通知やメールの送信、ジョブキューにジョブの追加などの非同期タスクを処理することが考えられます。
Application
コントローラーにヘルパーを読み込む
通知変数をグローバル変数にします。
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
文で分岐します。
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
通知の構文です:
通知タイプの訳文を追加する
ja:
like:
likeable_type:
Article: '投稿'
Idea: 'アイデア'
Comment: 'コメント'
通知一覧を作成する
ヘッダーにベルアイコンと通知数を表示させます。
<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>
ヘッダーのベルアイコンからドロップダウンで通知一覧を表示させたいのでパーシャルを作成します。
<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>
通知パーシャルを作成する
通知を既読できるようにチェックマークを追加します。
チェクマークをクリックしたら通知が消えるようにします。
<%= 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 %>
通知数パーシャルを作成する
<%= 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 %>
通知がない場合のパーシャルも作成します。
<%= 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