10
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【Ruby on Rails】チャット機能を実装!学習の為の記録です。

Posted at

はじめまして、こんにちは!
未経験からのエンジニア転職を目指してオリジナルアプリを作成中の初学者です!
作成中のオリジナルアプリに追加機能として、チャットの導入手順を学習の為に記録しておきたいと思います!!

自分の学習の振り返りの為の記録ですので、自分以外にはナンノコッチャな記事ですが、部分的なポイントが同じ目標を持つ方に参考に少しでもなれば良いなと思っております!!

アプリケーション概要

前提条件について整理をしておきます。

User機能
  • 商品、トッピングを買物カゴに保存
  • 来店日時の選択、利用人数を選択し、買い物カゴに保存した商品購入(予約)
  • ユーザー登録(氏名、住所、クレジットカード保存など)
  • お問い合わせフォームの送信
Admin機能
  • 商品・トッピング・最新情報の登録、編集
  • 期間を指定し、売上・予約・商品ランキングの検索
  • 検索結果のCSV出力
環境
  • Ruby 2.6.5
  • Ruby on Rails 6.0
  • MySQL2

追加要件

  • ユーザーが予約完了(商品購入)後、ユーザーとお店側でメッセージのやり取りが行える
  • 1つの予約に対し、メッセージをやり取りするルームが1つ紐づく
  • ユーザー側では、来店日の3日後以降にはルームが非表示となる
  • お店側では、来店日の3日後以降でもルームは表示できるが、メッセージ送信はできない
  • 今回の実装では、非同期通信によるメッセージ投稿までは実装しない(またの機会に💦)
  • ビューを整える部分までは扱わず、バックエンドの動きを中心とした記事内容とする
完成イメージ

gif

設計

実装手順に進む前に、どのような設計・仕様にするのか整理をしておきたいと思います。

①テーブル・モデル

今回の追加機能で必要となるテーブルは5つです!

  • users
  • admins
  • reservations
  • rooms
  • messages
追加実装前のER図

現在のテーブルは以下となっています。すでにusersテーブル、adminsテーブル、reservationsテーブルは作成済ですので、新規に必要なのはroomsテーブルとmessagesテーブルという事になりそうです。
FuuunRamen_ER.jpg

追加実装するテーブル設計

roomsテーブルとmessagesテーブルを追加し、アソシエーション、カラムを下記設定としたいと思います。
chat.png

ResevationとRoomの関係
チャット機能でよくある関係では、「ユーザー同士のチャット」となる為に、RoomとUserは中間テーブルを用いた多対多の関係になるのですが、私のアプリの場合、「予約に紐づくユーザー1名とお店側のチャット」です。その為、ReservationとRoomは1対1の関係で良さそうです。

messagesテーブルのカラム
ユーザーもお店側もメッセージ投稿を行う為、messagesテーブルには「user_id」と「admin_id」を設けました。ユーザーのメッセージでは「admin_id」はnilとなり、お店側のメッセージでは「user_id」がnilとなって、messagesテーブルに保存されます。 messagesマイグレーションファイルの実行の際、それぞれカラムでは「null: false」オプションをつけていません。 また、Messageモデルには、それぞれのカラムに「opitinal: ture」のバリデーション設定を加えています。

今回の実装では省きますが、メッセージ受信通知を行う場合、通知テーブルとのアソシエーションも行う必要がありそうです!!

②ルーティング・コントローラー

「roomsコントローラー」と「messagesコントローラー」の2つ必要ですが、ユーザーとお店側で処理とビューを分ける必要があるので、4つのコントローラーで機能実装したいと思います!

【ユーザー側】
・ ルーム一覧画面(メッセージのやり取りが可能な予約一覧)
・ ルーム詳細画面(メッセージのやり取り)

【管理者側】
・ ルームの一覧画面(メッセージのやり取り可能い関わらず、全ての予約)
・ ルームの詳細画面(メッセージ可能期間を過ぎた場合ではメッセージ送信不可)

  • rooms_controller.rb
  • messages_controller.rb
  • admins/rooms_controller.rb
  • admins/messages_controller.rb
roomsコントローラー
ルームの一覧を表示する「index」アクションと、ルーム詳細で実際にメッセージをやり取りする「show」アクションが必要となりそうです。

messagesコントローラー
ルーム詳細でメッセージの送信・確認するので、「create」アクションだけあれば良さそうです。今回は送信したメッセージについては削除をしない仕様としてますので、「destroy」アクションも不要とします

③ビュー・導線

シンプルなビュー・導線として、下記のような設計で進めたいと思います。レイアウト・デザインは実装の中でよい仕様を探りつつ進めて行きたいと思います!!
image.png

実装手順

仕様・設計が決まりましたので、実装を進めて行きたいと思います!!詰まらないといいな💦

実装① モデル作成

rails gコマンドで、マイグレーションファイルとモデルファイルを作成

RoomモデルとMessageモデルを作成します。

%  docker-compose exec web rails g model room
Running via Spring preloader in process 160
      invoke  active_record
      create    db/migrate/20201107131113_create_rooms.rb
      create    app/models/room.rb
      invoke    rspec
      create      spec/models/room_spec.rb
      invoke      factory_bot
      create        spec/factories/rooms.rb

% docker-compose exec web rails g model message 
Running via Spring preloader in process 192
      invoke  active_record
      create    db/migrate/20201107131258_create_messages.rb
      create    app/models/message.rb
      invoke    rspec
      create      spec/models/message_spec.rb
      invoke      factory_bot
      create        spec/factories/messages.rb

開発環境にDockerを用いていますので、railsコマンドの前に「docker-compose」コマンドが付いてます。

マイグレーションファイルを編集・実行
class CreateRooms < ActiveRecord::Migration[6.0]
  def change
    create_table :rooms do |t|
      t.references :reservation, null: false, foreign_key: true #追加
      t.timestamps
    end
  end
end
class CreateMessages < ActiveRecord::Migration[6.0]
  def change
    create_table :messages do |t|
      t.references :room, null: false, foreign_key: true #追加
      t.references :user, foreign_key: true #追加 NOT NULL制約はつけない
      t.references :admin, foreign_key: true #追加 NOT NULL制約はつけない
      t.text :message, null: false
      t.timestamps
    end
  end
end

Messageのマイグレーションファイルでは、外部キーとなるuser_id, admin_idには、null: falseオプションを付けていません。
これはメッセージがユーザーか管理者のどちらか一方に紐づく為、nullを許可しておく為です。

編集後にマイグレーションファイルの実行

% docker-compose exec web rails db:migrate
== 20201107131113 CreateRooms: migrating ======================================
-- create_table(:rooms)
   -> 0.0394s
== 20201107131113 CreateRooms: migrated (0.0398s) =============================

== 20201107131258 CreateMessages: migrating ===================================
-- create_table(:messages)
   -> 0.0235s
== 20201107131258 CreateMessages: migrated (0.0237s) ==========================

rails db:migrate:statusコマンドで、一応マイグレーションファイルの実行状態を確認しておきます。

% docker-compose exec web rails db:migrate:status

database: myapp_development

 Status   Migration ID    Migration Name
--------------------------------------------------
   <省略>
   up     20201107131113  Create rooms
   up     20201107131258  Create messages

ステータスが「up」となっているので大丈夫ですね!

モデルファイルの編集

アソシエーション、バリデーションを設定していきます。
編集対象のファイルは既存モデル含めて全部で5つです。

app/models/user.rb
has_many :messages, dependent: :destroy
app/models/admin.rb
has_many :messages, dependent: :destroy
app/models/reservation.rb
has_one :room, dependent: :destroy
app/models/room.rb
belongs_to :reservation
has_many :messages, dependent: :destroy

validates :reservation_id, uniqueness: true
app/models/message.rb
belongs_to :user, optional: true
belongs_to :admin, optional: true
belongs_to :room

validates :message, presence: true

Messageモデルには、UserとAdminに対してoptional: trueオプションを付けています。
今回の仕様では、MessageはUserかAdminのどちらか一つのidはnullになります。

マイグレーションファイルでNOT NULL制約をつけていない為、DBではNULLが許可されますが、Rails側ではbelongs_toの関係となっている為、nilが許可されなくなってしまいます。このoptional: trueを設定することでnilを許可するように設定しています。

実装② ルーティング

ユーザーとお店側でそれぞれルーティングが必要となるので以下のように設定しました。

config/routes.rb
Rails.application.routes.draw do
  #User
  resources :rooms, only: %i(index show)
  resource :message, only: :create

  #Admin
  namespace :admins do
    resources :rooms, only: %i(index show)
    resource :message, only: :create
  end
end
resourcesとresourceの違い

resource :xxxxと単数系で表記した場合、以下の点で違いが発生します。
 ・indexアクションが定義されない
 ・URI Patternにidがふくまれない
今回messageではcreateアクションしか使用せず、idも不要のため、resourceで定義しました。

namespaceを使用する場合

Admin用のルーティングにはnamespace :adminsのdo,endのブロック内で定義しました。
こうすることで、URIにadminを含め、ファイルもadminsディレクトリ配下におくことができます。

それではrails routesコマンドでルーティングを確認してみます。

Prefix         Verb  URI Pattern                  Controller#Action
rooms          GET   /rooms(.:format)             rooms#index
room           GET   /rooms/:id(.:format)         rooms#show
message        POST  /message(.:format)           messages#create

admins_rooms   GET   /admins/rooms(.:format)      admins/rooms#index
admins_room    GET   /admins/rooms/:id(.:format)  admins/rooms#show
admins_message POST  /admins/message(.:format)    admins/messages#create

ルーティングの設定方法も奥が深いです。ディレクトリ構成、URI、コントローラーをどう使用したいかで、いくつも設定方法があります。
今回使用したnamespace以外にも、scopemodulemembercollectionなどがあります。

学習の為に用途や状況でどう使用するのか近日中にまとめたいと思います!

実装③ コントローラー・ビュー

続いてコントローラーを作成します。余計なファイルを生成しないようにrails g controllerコマンドではなく、手動で以下のコントローラーファイル作成しました。

  • app/controllers/rooms_controller.rb
  • app/controllers/messages_controller.rb
  • app/controllers/admins/rooms_controller.rb
  • app/controllers/admins/messages_controller.rb

messageルーティングでは、resource :messageと単数形でしたが、コントローラー名は通常の命名規則通りにmessages_controller.rbと複数形の命名とすることです。
続いてそれぞれのコントローラーの使用するアクションを定義していきます。

app/controllers/rooms_controller.rb
class RoomsController < ApplicationController
  def index
  end
  def show
  end
end
app/controllers/messages_controller.rb
class MessagesController < ApplicationController
  def create
  end
end
app/controllers/admins/rooms_controller.rb
class Admins::RoomsController < Admins::ApplicationController
  def index
  end
  def show
  end
end
app/controllers/admins/messages_controller.rb
class Admins::MessagesController < Admins::ApplicationController
  def create
  end
end

お店側機能を管理するadminsディレクトリ配下に作成したコントローラーファイルではすでに作成済のAdmins::ApplicationControllerを継承しています。

Adminsディレクトリ配下にあるコントローラー群は全てAdmins::ApplicationControllerを継承させるように設定しています。ディレクトリ構成は以下となっています。

app/controllers
   L application_controller.rb
   L rooms_controller.rb
   L messages_controller.rb
   L admins
     L applicaton_controller.rb
     L rooms_controller.rb # admins/application_controller.rbを継承
     L messages_controller.rb # admins/application_controller.rbを継承

admins/application_controller.rbでは、controllersディレクトリ直下にあるapplication_controller.rbを継承しています。

admins/application_controller.rbの中身は以下のように設定してAdmin以外をはじくようにdeviseのauthenticateメソッドを使用しています。

app/controllers/admins/application_controller.rb
class Admins::ApplicationController < ApplicationController
  before_action :authenticate_admin!
end

コントローラーを編集していく前に、ビューファイルも簡単に作成しておきます。以下のディレクトリ構成でファイルを作成します。messageはroomで表示・投稿し、createアクションしかないため、ビューファイルは不要となります。

app/views
   L rooms
     L index.html.erb
     L show.html.erb
   L admins
     L rooms
       L index.html.erb
       L show.html.erb

ファイルの中身は一旦簡単な内容を表示させるようにしておきます。

app/views/rooms/index.html.erb
<div>Userチャットルームの一覧</div>
app/views/rooms/show.html.erb
<div>Userチャットルームの詳細</div>
app/views/admins/rooms/index.html.erb
<div>Adminチャットルームの一覧</div>
app/views/admins/rooms/show.html.erb
<div>Adminチャットルームの詳細</div>

一旦ここまでの設定内容で正しく表示できるかlocalhostで確認してみます!

image.png

無事に表示できました!
それでは、各コントローラーの中身を一気に作成していきます!!

User側コントローラー
app/controllers/rooms_controller.rb
class RoomsController < ApplicationController
  before_action :authenticate_user!
  before_action :get_two_days_ago
  before_action :set_room_or_move, only: :show

  def index
    # 今日から2日前以降の予約を取得
    @reservations = current_user.reservations.where('day >= ?', @two_days_ago).order(day: "DESC")
  end

  def show
    move_to_index_for_expired
    @message = Message.new
    @messages = Message.where(room_id: @room.id)
  end

  private
  def get_two_days_ago
    @two_days_ago = Date.today - 2
  end

  def set_room_or_move
    # ログインユーザーの予約に紐づくルームへのアクセスでない場合、リダイレクト
    redirect_to rooms_path, alert: 'メッセージルームに入れませんでした' if Reservation.find(params[:id]).user_id != current_user.id

    #予約に紐づくルームを@roomに定義
    @room = Room.find_by(reservation_id: params[:id])

    # roomが存在するか判定し、存在しない場合にreservation_idを渡して作成する
    if @room.nil?
      @room = Room.new(reservation_id: params[:id])
      redirect_to rooms_path, alert: 'メッセージルームに入れませんでした' unless @room.save
    end
  end

  def move_to_index_for_expired
    # 来店日を3日間以降過ぎている場合、ルーム一覧へリダイレクト
    if @room.reservation.day < @two_days_ago
      redirect_to rooms_path and return
    end
  end
end
before_action :authenticate_user!
ログインしていないユーザーをはじく為、deviseが用意してくれているメソッドを定義しています。

before_action :get_two_days_ago
メッセージの送受信が可能な期間を来店日後の2日以内としてますので、その日付をbefore_actionで定義しています。

before_action :set_room_or_move, only: :show
「ルームを新規作成・定義する」か、「ルーム一覧へリダイレクトするか」を判断するメソッドをshowアクション前に実行しています。 まずログインユーザーの予約に紐づくルームではない場合にルーム一覧にリダイレクトさせています。

今回は設計ではルームが新規作成されるタイミングは、「ユーザーが商品購入・予約確定と同時に作成」ではなく、「showアクションにリクエストした時に、予約に紐づくルームが新規作成」としました。すでにルームが作成されている場合には、ルーム作成はスキップされる仕様としています。



@reservations = current_user.reservations.where('day >= ?', @two_days_ago).order(day: "DESC")
今日から2日前以降の予約を取得し、インスタンス変数に定義しています。この予約がメッセージの送受信が可能な予約となります。

move_to_index_for_expired
showアクションでは、プライベートメソッドに定義した「move_to_index_for_expired」メソッドを呼び出しています。これは来店日が3日過ぎている予約=>メッセージの送受信期間を過ぎているshowアクションにリクエストした場合に、indexアクションのリダイレクトする設定です。

app/controllers/messages_controller.rb
class MessagesController < ApplicationController
  def create
    message = Message.new(message_params)
    if message.save
      redirect_to room_path(message.room.reservation_id)
    else
      redirect_to room_path(message.room.reservation_id), alert: 'メッセージを送信できませんでした'
    end
  end

  private
  def message_params
    params.require(:message).permit(:message, :room_id).merge(user_id: current_user.id)
  end
end

メッセージ保存後にルーム詳細へのリダイレクト指定が少し特殊な記述になっています。
redirect_to room_path(message.room.reservation_id)

通常であればroomsコントローラーのshowアクションへリダイレクトさせるパスはroom_path(:id)なのですが、ここではreservation_idを指定しています。
これはルームへリクエストする際のビューファイル(rooms/index.html.erb)をみて頂くとなぜこのようにする必要があるのか確認できます。

rooms/index.html.erb
rooms/index.html.erb
<h1>Userチャットルームの一覧</h1>
<% @reservations.each do |reservation| %>
  <div>
    <%= reservation.user.nickname %>
    <%= reservation.day %>
    <%= link_to "DM", room_path(reservation) %>
  </div>
<% end %>

ルーム一覧からルーム詳細へのリンクはroom_path(reservation)として設定しています。ルーム一覧では「メッセージのやり取りが可能な予約」が一覧表示されており、実際にはルーム一覧ではなく、予約一覧といえます。ルームはshowアクションへリクエストした際に初めて作成される為、ルーム一覧で表示されている予約でも、ルームがまだ存在していないケースがある為、room_path(reservation)としてreservationを引数に渡してshowアクションへリンクを設定しています。

その為、ルームへリダイレクトする際にredirect_to room_path(message.room)としてルームを渡すと想定していない異なるルームにリダイレクトされてしまいます。

少し分かりづらい仕様になってしまいました。ルーム作成のタイミングを商品購入・予約確定と同じタイミングにすればこのようなことはなく、直感的な設定ができたかと思うと、やや設計の失敗かなと感じています💦

User側ビューファイル

一旦表示を簡単に表示をさせる為の記述ですが、リンクやフォームは以下のように記述しています。

app/views/rooms/index.html.erb
<h1>Userチャットルームの一覧</h1>
<% @reservations.each do |reservation| %>
  <div>
    <%= reservation.user.nickname %>
    <%= reservation.day %>
    <%= link_to "DM", room_path(reservation) %>
  </div>
<% end %>
app/views/rooms/show.html.erb
<h1>Userチャットルームの詳細</h1>
ルームID<%= @room.id %>
<% if @messages.blank? %>
  <p>メッセージはありません</p>
<% else%>
  <p>メッセージがあります</p>
  <% @messages.each do |message| %>
    <div>
      <% if message.user %>
        <span><%= message.user.nickname %>:</span>
      <% else %>
        <span>スタッフ:</span>
      <% end %>
      <span><%= message.message %></span>
    </div>
  <% end %>
<% end %>
<%= form_with model: @message, url: message_path, local: true do |f| %>
  <%= f.text_field :message, class:"address-new-form" %>
  <%= f.hidden_field :room_id, value: @room.id %>
  <%= f.submit "送信", class:"register-green-btn" %>
<% end %>
f.hidden_field :room_id, value: @room.id
hidden_fieldはブラウザに表示されていませんが、メッセージ送信時にroom_idをパラメーターに持たせてコントローラーに渡しています。どのルームに紐づくメッセージなのかを分かるようにしています。
Admin側コントローラー/ビューファイル

基本的な構成仕様はUserと同様です。

app/controllers/admins/rooms_controller.rb
class Admins::RoomsController < Admins::ApplicationController
  before_action :set_room, only: :show

  def index
    @reservations = Reservation.includes(:user).order(day: "DESC")
  end

  def show
    @message = Message.new
    @messages = Message.where(room_id: @room.id)

    # 来店日を3日以上過ぎている予約判定の為、変数定義
    @close_reservation = @room.reservation if @room.reservation.day < Date.today - 2
  end

  private
  def set_room
    #予約に紐づくルームを@roomに定義
    @room = Room.find_by(reservation_id: params[:id])

    # roomが存在するか判定し、存在しない場合にreservation_idを渡して作成する
    if @room.nil?
      @room = Room.new(reservation_id: params[:id])
      redirect_to rooms_path, alert: 'メッセージルームに入れませんでした' unless @room.save
    end
  end
end
authenticate_admin!
User側のroomsコントローラーとは異なり、`before_action :authenticate_admin!`を定義していません。admins/roomsコントロラーでは、継承しているadmins/applicationコントローラーが`before_action :authenticate_admin!`を定義している為です。

@close_reservation = @room.reservation if @room.reservation.day < Date.today - 2
メッセージのやり取りが行える期間を過ぎている場合にインスタンス変数に定義し、ビューファイル側で処理を分ける
```ruby:app/controllers/admins/rooms_controller.rb class Admins::MessagesController < Admins::ApplicationController def create message = Message.new(message_params) if message.save redirect_to admins_room_path(message.room.reservation_id) else redirect_to admins_room_path(message.room.reservation_id), alert: 'メッセージを送信できませんでした' end end

private
def message_params
params.require(:message).permit(:message, :room_id).merge(admin_id: current_admin.id)
end
end


```html:app/views/rooms/index.html.erb
<h1>Adminチャットルームの一覧</h1>
<% @reservations.each do |reservation| %>
  <div>
    <%= reservation.user.nickname %>
    <%= reservation.day %>
    <%= link_to "DM", admins_room_path(reservation) %>
  </div>
<% end %>
app/views/rooms/show.html.erb
<h1>Adminチャットルームの詳細</h1>
ルームID<%= @room.id %>
<% if @messages.blank? %>
  <p>メッセージはありません</p>
<% else%>
  <p>メッセージがあります</p>
  <% @messages.each do |message| %>
    <div>
      <% if message.user %>
        <span><%= message.user.nickname %>:</span>
      <% else %>
        <span>スタッフ:</span>
      <% end %>
      <span><%= message.message %></span>
    </div>
  <% end %>
<% end %>
<% if @close_reservation %>
  <p>来店日を3日以上過ぎている為、メッセージ送信はできません</p>
<% else %>
  <%= form_with model: [:admins, @message], url: admins_message_path, local: true do |f| %>
    <%= f.text_field :message, class:"address-new-form" %>
    <%= f.hidden_field :room_id, value: @room.id %>
    <%= f.submit "送信", class:"register-green-btn" %>
  <% end %>
<% end %>

完成!!

gif

ざっと以上で、メッセージをやり取りする機能を実装できました!!CSSをほとんど当ててませんし、メッセージの即時更新も出来ておりませんが、本日はココまでとしておきます!

今後の実装内容としては、デザイン・レイアウトを整えて、メッセージの即時反映などを進めていきたいと思います!!!
それではまた〜

10
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?