チャットを作る:第4章 — 管理画面とプッシュ通知
Tony Duong
5月 31, 2026 ・ 3 分
💬 これは、このサイトの右下に表示されているライブチャットそのものの実装です。 このシリーズでは、それをどう作ったかを順を追って解説します。チャットの吹き出しを開いて試してから、その仕組みを読み進めてみてください。
第2章と第3章の訪問者側チャットは物語の半分です。この章ではもう半分を扱います:管理者がどう認証するか、AdminChat.tsxが1つのWebSocketで多数の会話をどう多重化するか、そしてWeb Push通知がブラウザタブを閉じた後も管理者にどう届くかです。
管理者認証フロー
管理ページはsrc/app/admin/に配置されています — [locale]ルートツリーの外です。Next.js App Routerはすべてのルートツリーが<html>タグと<body>タグを提供することを要求するため、独自のルートレイアウト(src/app/admin/layout.tsx)が必要です。ブログはsrc/app/[locale]/layout.tsxからそれらを取得するため、/admin/*は別ツリーになります。
ログインシーケンス:
1. 管理者が /admin/login にアクセス
2. AdminLoginPage がフォームをレンダリング (src/app/admin/login/page.tsx)
3. フォーム送信 -> adminLogin(user, pass) (src/lib/chat-client.ts)
4. fetch POST /auth/login (credentials: "include")
5. AuthController が ENV変数をチェック (chat-server/app/controllers/auth_controller.rb)
6. 一致 -> session[:admin] = true、{ ok: true }を返す
7. ブラウザが _chat_server_session Cookie を保存
8. ルーターが /admin/chats に遷移
9. AdminChat がWebSocket接続 -- Connection#connect がsession[:admin]を読む
コントローラー(chat-server/app/controllers/auth_controller.rb)は2つの環境変数と比較します:
if username == ENV["ADMIN_USER"] && password == ENV["ADMIN_PASSWORD"]
session[:admin] = true
render json: { ok: true }
end
ユーザーテーブルなし、パスワードハッシュなし、リカバリーフローなし。単一管理者の個人ブログ向け:柔軟性ゼロですが、環境変数以外の攻撃面もゼロです。WebSocket接続は同じセッションを読み取ってself.admin = trueを設定します(第2章)。
AdminChat.tsx — 1つのWebSocketで多数の会話を管理
第3章の訪問者ChatWidgetは1つの会話を処理します。AdminChatは単一のAdminChannelサブスクリプションですべての会話を処理し、2段階のステートを持ちます:
// src/components/AdminChat.tsx
const [conversations, setConversations] = useState<ChatConversation[]>([]); // 一覧
const [selectedId, setSelectedId] = useState<number | null>(null); // 詳細
const [messages, setMessages] = useState<ChatMessage[]>([]); // 詳細
receivedコールバックは4つのイベントタイプで分岐します:
| イベントタイプ | 効果 |
|---|---|
conversations |
会話リスト全体を置換(接続時に送信) |
new_message |
last_message / unread_countを更新;選択中ならメッセージを追加 |
history |
選択された会話のメッセージリストを置換 |
conversation_deleted |
リストから削除;削除されたものが選択中なら選択をクリア |
new_messageハンドラーには微妙なエッジケースがあります。まだリストにない会話(新しい訪問者)のメッセージが到着した場合、部分的なオブジェクトを構築するのではなく、サーバーにリフレッシュを要求します:
const exists = prev.some((c) => c.id === data.conversation_id);
if (!exists) {
subscriptionRef.current?.perform("list_conversations");
return prev;
}
第3章のselectedIdRefパターンがここにも現れます — コールバックはselectedIdRef.currentを読んでメッセージを追加するか判断しますが、会話を切り替えるたびにサブスクリプションを再作成することはありません。
会話を選択すると、同じWebSocketを通じて2つのサーバーアクションが発火します:
subscriptionRef.current?.perform("get_history", { conversation_id: id });
subscriptionRef.current?.perform("mark_read", { conversation_id: id });
未読カウントはクライアント側で楽観的にゼロにされ、サーバーが変更を永続化します。
Web Pushパイプライン
プッシュ通知には4者が関わります:管理者のブラウザ、Next.jsフロントエンド、Railsバックエンド、プッシュサービス(Google FCM、Mozilla autopush)。これらのサービスへの登録は不要です — VAPIDキーが認証を処理します。
登録
AdminChatが接続すると、プッシュに登録します。src/components/AdminChat.tsxのregisterPushNotificationsは4つのことを行います:
- Service Worker(
/sw-chat.js)を登録 - 通知の許可をリクエスト
- VAPIDパブリックキーでプッシュサービスにサブスクライブ
{ endpoint, p256dh, auth }をRailsにPOST
// src/components/AdminChat.tsx
const registration = await navigator.serviceWorker.register("/sw-chat.js");
const permission = await Notification.requestPermission();
if (permission !== "granted") return;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidKey) as BufferSource,
});
const json = subscription.toJSON();
await fetch(`${getChatHttpUrl()}/push_subscriptions`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
endpoint: json.endpoint,
p256dh: json.keys?.p256dh,
auth: json.keys?.auth,
}),
});
Railsはこの3つのフィールドをpush_subscriptionsテーブルに保存します — 後で暗号化ペイロードを送信するために必要なすべてです。
通知の送信
トリガーはVisitorChannel#send_message(第2章)にあります。両方のストリームにブロードキャスト後、アクティブな管理者WebSocketを確認します:
# chat-server/app/channels/visitor_channel.rb
unless admin_connected?
PushNotificationService.notify(conversation.visitor_name, content) rescue nil
end
PushNotificationService(chat-server/app/services/push_notification_service.rb)は保存されたすべてのサブスクリプションを反復し、VAPID認証情報を使ってWebPush.payload_sendを呼びます。サブスクリプションが410 Goneを返した場合、WebPush::ExpiredSubscriptionをキャッチして古いレコードを削除します。
VAPIDキー
プッシュサービスに対してサーバーを識別する公開鍵/秘密鍵ペアです。以下で生成します:
cd chat-server
bundle exec rails runner "k = WebPush.generate_key; puts k.public_key; puts k.private_key"
パブリックキーは.env.local(NEXT_PUBLIC_VAPID_PUBLIC_KEY)とchat-server/.env(VAPID_PUBLIC_KEY)に配置します。プライベートキーはバックエンド側のみに保持します。
Service Worker
public/sw-chat.jsは2つのイベントリスナーを持つ30行のファイルです。pushは暗号化ペイロードをパースしてshowNotificationを呼びます。notificationclickは通知を閉じ、既存の/admin/chatsタブにフォーカスを試み、なければ新しいタブを開きます。
重要な特性:Service Workerは別スレッドで動作し、タブを閉じた後も永続します。プッシュ通知が管理者がページにいないときでも機能するのは、まさにこれが理由です。
次のステップ
機能はローカルで動作しています。第5章ではデプロイを扱います — Kamal 2がRailsアプリをDockerコンテナにパッケージし、Hetzner VPSに配送し、SSL、環境シークレット、GitHub Actions CIを接続する方法です。
やってみよう
1. プッシュサブスクリプションの確認
DevToolsのApplicationタブで/admin/chatsを開いてください。Service Workersでsw-chat.jsを、Push Messagingでサブスクリプションの詳細を確認してください。
Chromeの場合:DevTools > Application > Service Workersにsw-chat.jsがステータス「activated and is running」で表示されます。Application > Storage > Push Messagingで、サブスクリプションのendpoint URL(Chromeではfcm.googleapis.comを指す)と、/push_subscriptionsにPOSTされた内容と一致するp256dhおよびauthキーが表示されます。
2. VAPIDキーのローテーション
Rails runnerコマンドで新しいキーを生成してください。.env.localとchat-server/.envの両方で置き換えてください。両方のサーバーを再起動し、プッシュが引き続き動作することを確認してください。(片方だけ更新すると、キーの不一致でサブスクリプションが失敗します。)
cd chat-server
bundle exec rails runner "k = WebPush.generate_key; puts k.public_key; puts k.private_key"
1行目をNEXT_PUBLIC_VAPID_PUBLIC_KEY(.env.local)とVAPID_PUBLIC_KEY(chat-server/.env)にコピーします。2行目をVAPID_PRIVATE_KEY(chat-server/.env)にコピーします。両方のサーバーを再起動します。applicationServerKeyが変わったため、ブラウザは接続時に新しいプッシュサブスクリプションを作成します。
3. 通知にメッセージ数を含める
通知の本文に会話のメッセージ数を表示するよう、PushNotificationServiceを修正してください。
chat-server/app/channels/visitor_channel.rbで、conversationオブジェクトを渡します:
PushNotificationService.notify(conversation, content) rescue nil
chat-server/app/services/push_notification_service.rbで:
def self.notify(conversation, message_content)
payload = {
title: "New message from #{conversation.visitor_name}",
body: "#{message_content.truncate(100)} (#{conversation.messages.count} messages)",
url: "/admin/chats"
}.to_json
# ... rest unchanged
end
4. 通知サウンドの追加
プッシュ通知がシステムサウンドを再生するよう、public/sw-chat.jsを修正してください。
pushリスナーの通知オプションにsilent: falseを追加します:
self.registration.showNotification(data.title || "New message", {
body: data.body || "",
icon: "/icon-192.png",
badge: "/icon-192.png",
data: { url: data.url || "/admin/chats" },
silent: false,
})
silent: falseはほとんどのプラットフォームでデフォルトです — silent: trueが設定されていない限り、通知は既にシステムサウンドを再生します。soundプロパティによるカスタムサウンドはブラウザのサポートが一貫していません。
🌐 Claudeによる翻訳