Friction River Software

  • お問い合わせ

CakePHP5入門【CakePHP5実用編⑱】ユーザ間メッセージ送付

A子

ねぇ、WebSocket等の双方向通信の仕組みを使わずに、ユーザ間でメッセージのやり取りをできるようにしたいんだよね
電子メールではない「メッセージ送付」って感じで…

例えば、「×日の△時に○○のチャットルームでおしゃべりしましょう」なんてメッセージを送っておくの

C菜

リアルタイム性が特に必要ないのであれば、データベース経由にするのが一番簡単じゃないでしょうかぁ~?

誰が」「誰に」「いつ」「何を」知らせたいかをデータベーステーブルに記録しておいて、宛先側の人は(ログイン時に)もしも「未読」のメッセージがあったら、その旨を画面上に表示するんですよ~

B美

ふむ
C菜の案が一般的な模範解答でしょうね

特に難しいところも無いし、あなたたちで挑戦してみなさい

A子

了解

まずはデータベーステーブルの設計だね
「誰が」「誰に」「いつ」「何を」知らせるかなので…

create table notifications (
    id int auto_increment primary key,
    sent_user_id int,
    received_user_id int,
    message text,
    read_flag int,
    created datetime,
    modified datetime,
    foreign key(sent_user_id) references users(id),
    foreign key(received_user_id) references users(id)
) charset=utf8mb4;

こんな感じでどうかな?
あ、「いつ」については、レコード作成日時である「created」の項目で良いと思う

C菜

sent_user_id」が送付元のユーザIDで、「received_user_id」が送付先のユーザIDなんですね~
(CakePHPの命名規則には反してますけど~)

あと、「message」はその名の通りメッセージで、「read_flag」は既読か未読かってことですね~

A子

命名規則に反しているのは仕方ないよね(苦笑)
(だって同じ参照元の外部キーが二つあるんだもん)

C菜

とりあえず、「config/const.php」の中に、「read_flag」に格納する値の定義を追加しましょう~

define("MESSAGE_NOT_READ", 1);    //未読
define("MESSAGE_IS_READED", 2);    //既読
define("MESSAGE_STATUS", ["-", "未読", "既読"]);

A子

んじゃ「bake」してみようか

cd html/authapp[Enter]
bin/cake bake all notifications[Enter]



C菜

「src/Model/Entity」の中にある「Notification.php」を修正しますね~

とは言っても「$_accessible」の中の最後の二行を削除しただけですけど~



A子

問題は「src/Model/Table」の中にある「NotificationsTable.php」のほうだよね

「belongsTo」の直後にある「SentUsers」や「ReceivedUsers」って、「Users」に変更すれば良いのかな?

B美

それはダメ!
(名前が重複することになっちゃうからね)

「SentUsers」や「ReceivedUsers」って、「Users」のエイリアスってことにすれば良いのよ
(てか、このファイルを特に修正しなくても大丈夫だから…)

A子

え?えいりあ…何だって?

B美

aliasエイリアス」…別名って意味よ
(「またの名は」…ってこと)

ただし、containする際には注意が必要だからね

$notifications = $this->Notifications->find()->contain(['SentUsers', 'ReceivedUsers'])->all();

のように、エイリアスとして定義したほうを指定しないとダメってわけ

C菜

あぁ!
以前「chat_rooms」テーブルの項目名の中で、「user_id」とすべきところを「admin_id」としたせいで、なぞの「Admins」という(おそらくは)エイリアスが作成されたわけですけど~

あれってcontain時に「Admins」を指定すれば良かったんじゃないですか~?
(【CakePHP5実用編④】を参照)

B美

そういうこと

でも、あれは(無駄な修正ではなく)必要な修正だったと思うわよ
(containするのは「Users」であるべきだから…)

ただ、今回の「notifications」テーブルには、同じ参照元(「users」テーブル)の外部キーが二つ(「sent_user_id」及び「received_user_id」)あるからね
エイリアスは必須ってわけ…

A子

なるほどねぇ
まだまだ知らないことがたくさんあるなー(苦笑)

さて、気を取り直して「src/Controller」の中にある「NotificationsController.php」を修正していこう

C菜

あ、事前準備として「config/const.php」の中にページネーション用の定数を追加しておきましょう~

define("MESSAGE_MAX_PAGE", 10); //メッセージの一覧件数

あと、メインページのメニュー項目を追加するです~

<?= $this->Html->link(__('他のユーザへのメッセージ'), ['controller' => 'Notifications', 'action' => 'index']) ?>


A子

えっと、「index」メソッドによって「他のユーザへのメッセージ」を一覧表示するってことは…
送付先のハンドル名を表示するためにcontain(結合)するのは「ReceivedUsers」で、送付元が自分自身であるという条件で良いのかな?

public function index()
{
    $query = $this->Notifications->find()
        ->contain(['ReceivedUsers'])
        ->where(['sent_user_id' => $this->identity->id]);
    $notifications = $this->paginate($query, ['limit' => MESSAGE_MAX_PAGE,
                                                'order' => ['read_flag' => 'asc', 'created' => 'desc']]);

    $this->set(compact('notifications'));
}

表示順(order)は「未読」のものが上にくるようにして、第2ソートキーとして「最新」のものが上にくるようにしてるよ

C菜

良いと思います~

では、それに対応するファイルが「templates/Notifications」の中にある「index.php」ですけど、こんな感じで書き換えました~


A子

「未読」のものは削除できるけど、「既読」のメッセージは削除できないってわけだね

良いんじゃない?

B美

あぁ、ちょっと口を挟むけどさ

実行してみたらすぐに分かることなんだけど、それでは「メッセージ送付先」の項目をクリックしてもソートは実行されないわよ
(問題は、赤線の部分ね)

C菜

え?
そうなんですか~?

だったらエイリアスではなく、「Users.handle_name」でしょうか~?

B美

いいえ、違うわ
実は「index.php」については、さっきのでOKなの

変更すべきは「NotificationsController.php」のほうなのよね

public function index()
{
    $query = $this->Notifications->find()
        ->contain(['ReceivedUsers'])
        ->where(['sent_user_id' => $this->identity->id]);
    $notifications = $this->paginate($query, ['limit' => MESSAGE_MAX_PAGE,
                                                'sortableFields' => [
                                                    'id',
                                                    'ReceivedUsers.handle_name',
                                                    'read_flag',
                                                    'created',
                                                ],

                                                'order' => ['read_flag' => 'asc', 'created' => 'desc']]);

    $this->set(compact('notifications'));
}

…って感じで、「sortableFields」を追加するだけよ
あ、エイリアス(ReceivedUsers)のほうを使うのがポイントだからね

A子

なるほどー

特に難しくはないけど、知らなかったらハマりそうだね(苦笑)

C菜

それでは次に「add.php」ですね~

セレクトボックスに表示する内容(ユーザリスト)については、「NotificationsController.php」側で作る感じです~
(「$users_array」の箇所)

A子

んじゃ、その「NotificationsController.php」の「add」メソッドはこんな感じで…

public function add()
{
    //ユーザの一覧を取得
    $users = $this->Users->find()
        ->where(['id <>' => $this->identity->id])
        ->order(['email' => 'ASC'])->all();
    $users_array = [0 => '選択してください'];
    foreach ($users as $user) {
        $users_array += [$user->id => $user->handle_name];
    }

    $notification = $this->Notifications->newEmptyEntity();
    if ($this->request->is('post')) {
        $received_user_id = $this->request->getData('received_user_id');
        if ($received_user_id == 0) {
            $this->Flash->error(__('送付先を選択してください。'));

            return $this->redirect(['action' => 'add']);
        }

        $notification = $this->Notifications->patchEntity($notification, $this->request->getData());
        if ($this->Notifications->save($notification)) {
            $this->Flash->success(__('メッセージを作成しました。'));

            return $this->redirect(['action' => 'index']);
        }
        $this->Flash->error(__('メッセージの作成に失敗しました。'));
    }
    $this->set(compact('users_array', 'notification'));
}

C菜

次は「view.php」です~

ポイントは「メッセージ送付先」を「$notification->received_user->handle_name」としているところでしょうか~

A子

「view」メソッドはこうだね

public function view($id = null)
{
    $notification = $this->Notifications->get($id, contain: ['ReceivedUsers']);

    $this->set(compact('notification'));
}

あ、containするのは「ReceivedUsers」だけで…

B美

ちょっと待ったぁ!

主キーである「id」をGETパラメータとして渡している場合、注意しないといけないんじゃない?
(はたしてそのままで良いのかしら?)

C菜

・・・

あっ、URLに別の数字を指定すると、自分以外が作ったメッセージを見れちゃうのでは~?

A子

あぁ、そうか…だったら

public function view($id = null)
{
    $notification = $this->Notifications->get($id, contain: ['ReceivedUsers']);
    if ($notification->sent_user_id != $this->identity->id) {
        $this->Flash->error(__('他人の作成したメッセージを閲覧することはできません。'));

        return $this->redirect(['controller' => 'Top', 'action' => 'index']);
    }


    $this->set(compact('notification'));
}

で、どうかな?

B美

OK、OK

前にも出た話だけど、POSTだったらチェックは要らないけど、GETではチェックが必要になる場合って結構あるからね
(【コラム⑭】を参照)

A子

だったら、削除の「delete」メソッドも絶対にチェックがいるじゃん

public function delete($id = null)
{
    $notification = $this->Notifications->get($id);
    if ($notification->sent_user_id != $this->identity->id) {
        $this->Flash->error(__('他人の作成したメッセージを削除することはできません。'));

        return $this->redirect(['controller' => 'Top', 'action' => 'index']);
    }
    if ($notification->read_flag == MESSAGE_IS_READED) {
        $this->Flash->error(__('既読メッセージを削除することはできません。'));

        return $this->redirect(['action' => 'index']);
    }


    $this->request->allowMethod(['post', 'delete']);
    $notification = $this->Notifications->get($id);
    if ($this->Notifications->delete($notification)) {
        $this->Flash->success(__('メッセージを削除しました。'));
    } else {
        $this->Flash->error(__('メッセージの削除に失敗しました。'));
    }

    return $this->redirect(['action' => 'index']);
}

チェックは2種類…
自分が作ったやつかどうか」と、「未読かどうか」だね

B美

ふむ
良いと思うわよ

まぁ、削除(delete)については「postLink」しているから、チェックは要らないんだけどね
(「念のため」という意味では決してムダじゃないけど…)

それじゃ、あとはメッセージ送付先(受け取る側)の処理ね

C菜

ログイン直後だけじゃなく、ログイン中だったらいつでもメッセージチェックを行いたいですよね~

うーん
ということは、「src/Controller」の中にある「AppController.php」を使うべきでは~?

A子

あー、スーパークラスだもんねぇ
んじゃ、修正すべきメソッドは「beforeFilter」かな?

$notification_count = $this->Notifications->find()
    ->where(['received_user_id' => $this->identity->id, 'read_flag' => MESSAGE_NOT_READ])->count();
$this->set(compact('notification_count'));

「自分宛て」で、かつ「未読」のメッセージの数を取得すれば良いんじゃないかな?

B美

それって、最初のログインページの表示でエラーになるわよ
(だって「$this->identity」がnullだもの)

C菜

あっ、ログインしてはじめて「$this->identity」が有効になるんですよね~
ということは~

if (!is_null($this->identity)) {
    $notification_count = $this->Notifications->find()
        ->where(['received_user_id' => $this->identity->id, 'read_flag' => MESSAGE_NOT_READ])->count();
    $this->set(compact('notification_count'));
}

とすれば、良いんじゃないでしょうか~?

A子

なるほど、なるほど
たしかに、そだね

protected $Notifications;

public function initialize(): void
{

    ・・・

    $this->Notifications = $this->fetchTable('Notifications');
}

あ、当然だけどテーブルフェッチも「initialize」メソッドでやっておくよ

C菜

それでは「templates/layout」の中にある「default.php」を修正して、もしも未読メッセージがあるときはその旨を表示するようにしましょう~

えっと~
あわせて「NotificationsController.php」の「list」メソッドへのリンクボタンを表示しますね~

A子

んじゃ、その「list」メソッドを書いてみよう

public function list()
{
    $notifications = $this->Notifications->find()
        ->contain(['SentUsers'])
        ->where(['received_user_id' => $this->identity->id, 'read_flag' => MESSAGE_NOT_READ])
        ->order(['created' => 'DESC'])->all();

    $this->set(compact('notifications'));
}

containするのは(「ReceivedUsers」ではなく)「SentUsers」のほうね

B美

実行確認する前に指摘しておくけど、それではエラーになるわよ
(着目すべきは「created」ね)

A子

ん?
つづりを間違ったかな?

C菜

いえ、あってますよ~

ん~
・・・
あっ!
二つのテーブルを結合したということは、そのどちらにも「created」が存在するのでは~?

B美

さすがはC菜ね
正解よ

結合したテーブルに同名のフィールドが存在する場合、表名を明示しないとエラーになる(かもしれない)ってわけ

A子

わかったよ

public function list()
{
    $notifications = $this->Notifications->find()
        ->contain(['SentUsers'])
        ->where(['received_user_id' => $this->identity->id, 'read_flag' => MESSAGE_NOT_READ])
        ->order(['Notifications.created' => 'DESC'])->all();

    $this->set(compact('notifications'));
}

…てことだね

C菜

あれ~?
ちょっと待ってください~

「index」メソッドのほうでもソートしてましたけど、(「Notifications.created」ではなく)「created」だけでうまく動いていましたよ~
(「ReceivedUsers」を結合してるのに~)

A子

ほんとだ(苦笑)

もしかしてpaginateを使ったから?

B美

結局は「CakePHP」(というWebフレームワーク)がどのように判断してくれるか…なのよね

paginateを使う場合、主テーブルを「Notifications」であると(勝手に)判断してくれたおかげで(表名の指定を行わなくても)うまくいったのよ(多分…)

C菜

なんだか怖いですね~

エラーを出してくれたほうがすっきりします~

B美

実は、私も同感よ(笑)

たとえ必要が無くても、明示的に「Notifications.created」と書くべきなのよね
(できれば「index」メソッドのほうも修正しておきなさい)

A子

了解


C菜

さて、それでは「templates/Notifications」の中に新規で「list.php」を作りますね~
(「index.php」をベースにして、それを書き換える感じで~)

A子

メッセージ内容を表示するための「view2」メソッドはこんな感じでどう?

public function view2($id = null)
{
    $notification = $this->Notifications->get($id, contain: ['SentUsers']);

    if ($notification->received_user_id != $this->identity->id) {
        $this->Flash->error(__('他人あてのメッセージの閲覧はできません。'));

        return $this->redirect(['controller' => 'Top', 'action' => 'index']);
    }

    $notification->read_flag = MESSAGE_IS_READED;
    $this->Notifications->save($notification);


    $this->set(compact('notification'));
}

宛先が自分かどうかのチェックと、「未読」を「既読」にする処理をやってるよ

C菜

それに対応する「view2.php」がこうです~

B美

それじゃ、テストしてみましょうか

C菜あてのメッセージを作成してみるわね
(すでにA子あてのメッセージもあって、既読も付いてるけど気にしないように(苦笑)…ちょっとテストしただけだから)



C菜

ログインすると、通知が表示されてますね~

このボタンをクリック」を押すと~



C菜

右端の「表示」をクリックしてみます~

んで、そのあと「一覧へ戻る」をクリックすると、リストから未読のメッセージが消えてますね~



B美

私のほうでも確認してみたわ
(「未読」だったものが「既読」に変わっているわね)

A子

ちょっと違和感を感じたのが、C菜が未読メッセージの一覧を見た画面(/notifications/list)と、そのメッセージ詳細画面(/notifications/view2)だね

その画面上に「未読のメッセージがあります。」という表示があるのはおかしくない?

C菜

ですね~

「list」メソッドと「view2」メソッドの実行時には、未読通知を表示しないようにしたいです~

A子

おしえてB美先生(笑)

B美

実行時のControllerコントローラー名とメソッド名を取得すれば可能よ

そうねぇ
「templates/layout」の中にある「default.php」だけど…

$controller = $this->request->getParam('controller');
$action = $this->request->getParam('action');

を記述することによって「$controller」にはControllerコントローラーが、「$action」にはメソッド名が入るわ

A子

ということは、こうかな?

B美

そういうこと
Controllerコントローラー名は「NotificationsController」ではなく「Notifications」であることに注意してね)

てか、A子もなかなかやるじゃない

C菜

テストしてみましたけど、完璧です~

B美

あ、細かいことを指摘しても良いかな?

未読のメッセージを表示する「list」メソッドだけど、詳細表示(view2)から戻った際、レコードがからであっても表示されるのって少し違和感があるわ

A子

レコードがからかどうかを判別するにはどうすればいいの?

B美

isEmpty」メソッドを呼び出すだけよ

if ($notifications->isEmpty()) {
}

…って感じの使い方ね

A子

だったら「list」メソッド内に以下の記述を追加するよ

if ($notifications->isEmpty()) {
    return $this->redirect(['controller' => 'Top', 'action' => 'index']);
}

C菜

あ、私も細かいことですけど、一つあります~
「未読」と「既読」の文言を表示する際、色を変えたいですね~
(「未読」は赤色、「既読」は緑色に~)

なので、「config/const.php」の定数定義を変えてみました~

define("MESSAGE_STATUS", ["-", '<span style="color: red;">未読</span>', '<span style="color: green;">既読</span>']);

B美

ふむ

「if else」や「三項演算子」を使うよりも、シンプルで良いと思うわよ

A子

色が付いただけで、かなり見やすくなったね

C菜

さきほどA子社長が修正した内容についてもテストしてみました~

バッチリ成功しましたよ~







B美

うん

良いんじゃない?

A子

それにしても、毎回なにかしらの新知識が増えていくよね
今回は…

・エイリアスについて
・sortableFieldsにソート項目を明示することで、エイリアスがからむ項目クリックによるソートを行う
・結合したテーブルに同名の項目がある場合、「表名.項目名」とするべき
・表示した画面のControllerコントローラー名やメソッド名を取得する方法
・検索結果のレコードがからかどうかの判別方法

…ってことかー

C菜

B美部長の存在の大きさを実感するです~