mcpField
C
chat-widget

chat-widget

github.com/shpigford開発59 回インストール
リポジトリ

chat-widget について

リアルタイムのカスタマーサポートチャットを構築する開発者向けスキルです。ユーザー用ウィジェットと管理画面に加え、データモデル、API、WebSocket、通知、複数フレームワークの実装例を扱います。

主な機能

  1. 01フローティング式チャットウィジェット
  2. 02リアルタイムメッセージと接続状態
  3. 03管理者向けチャット一覧と詳細画面
  4. 04未読管理とアーカイブ操作
  5. 05遅延メール通知

活用シーン

  1. 01アプリ内サポートチャットの追加
  2. 02ユーザーと管理者間のリアルタイム通信
  3. 03サポート担当者による現行・アーカイブ済み会話の管理
  4. 04未読の管理者返信をメールで通知

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/shpigford/skills --skill chat-widget

インストール後、Claude Code で/chat-widgetを入力して読み込まれたか確認します。

詳細

ライブサポートチャットウィジェット

ユーザー向けのフローティングウィジェットと、サポートスタッフ向けの管理ダッシュボードを備えたリアルタイムサポートチャットシステムを構築します。

このスキルを使用する場面

ユーザーが次のことを望む場合に使用します:

  • アプリにライブチャットウィジェットを追加する
  • カスタマーサポートチャット機能を構築する
  • ユーザーと管理者の間のリアルタイムメッセージングを作成する
  • アプリ内サポートチャネルを追加する

アーキテクチャの概要

┌─────────────────────────────────────────────────────────────────┐
│                        FRONTEND                                 │
├─────────────────────────────┬───────────────────────────────────┤
│   User Widget               │   Admin Dashboard                 │
│   - Floating chat button    │   - Chat list (active/archived)   │
│   - Message panel           │   - Conversation view             │
│   - Unread badge            │   - Archive/restore controls      │
│   - Connection indicator    │   - User info display             │
└─────────────┬───────────────┴───────────────┬───────────────────┘
              │                               │
              │     WebSocket + REST API      │
              ▼                               ▼
┌─────────────────────────────────────────────────────────────────┐
│                        BACKEND                                  │
├─────────────────────────────────────────────────────────────────┤
│   Channels                  │   Controllers                     │
│   - ChatChannel (per chat)  │   - User: get/create chat         │
│   - AdminChannel (global)   │   - Admin: list, view, archive    │
├─────────────────────────────┼───────────────────────────────────┤
│   Models                    │   Jobs                            │
│   - Chat (1 per user)       │   - Email notification (delayed)  │
│   - Message (many per chat) │                                   │
└─────────────────────────────────────────────────────────────────┘

実装ガイド

ステップ 1: データモデル

support_chats と support_messages の 2 つのテーブルを作成します。

support_chats

id              - primary key (UUID recommended)
user_id         - foreign key to users (UNIQUE - one chat per user)
last_message_at - timestamp (for sorting chats by recency)
admin_viewed_at - timestamp (tracks when admin last viewed)
archived_at     - timestamp (null = active, set = archived)
created_at
updated_at

support_messages

id              - primary key (UUID recommended)
chat_id         - foreign key to support_chats
content         - text (required)
sender_type     - enum: 'user' | 'admin'
read_at         - timestamp (null = unread)
created_at
updated_at

主要なインデックス:

  • support_chats.user_id (unique)
  • support_chats.last_message_at (並べ替え用)
  • support_chats.archived_at (フィルタリング用)
  • support_messages.chat_id
  • support_messages.(chat_id, created_at) (複合、順序付け用)

モデルのリレーション:

User has_one SupportChat
SupportChat belongs_to User
SupportChat has_many SupportMessages
SupportMessage belongs_to SupportChat

実装するモデルメソッド:

Chat モデル:

function touch_last_message()
  update last_message_at = now()

function unread_for_admin?()
  return exists message where sender_type = 'user'
    and created_at > admin_viewed_at

function mark_viewed_by_admin()
  update admin_viewed_at = now()

function archive()
  update archived_at = now()

function unarchive()
  update archived_at = null

function archived?()
  return archived_at != null

Message モデル:

after_create:
  chat.touch_last_message()
  if sender_type == 'user' and chat.archived?:
    chat.unarchive()  // Auto-reactivate on new user message

after_create_commit:
  broadcast_to_chat_channel(message_data)
  if sender_type == 'user':
    broadcast_to_admin_notification_channel(message_data, chat_info)
  if sender_type == 'admin':
    schedule_email_notification(delay: 5.minutes)
ステップ 2: API エンドポイント

ユーザー向け:

GET  /support_chat       - Get or create user's chat with messages
PATCH /support_chat/mark_read - Mark admin messages as read

管理者向け:

GET  /admin/chats              - List chats (query: archived=true/false)
GET  /admin/chats/:id          - Get chat with messages
POST /admin/chats/:id/archive  - Archive chat
POST /admin/chats/:id/unarchive - Restore chat

コントローラーのロジック:

ユーザー GET /support_chat:

function show()
  chat = current_user.support_chat || create_chat(user: current_user)
  return {
    id: chat.id,
    messages: chat.messages.map(m => serialize_message(m))
  }

管理者 GET /admin/chats:

function index()
  chats = SupportChat
    .where(archived_at: params.archived ? not_null : null)
    .includes(:user, :messages)
    .order(last_message_at: desc)

  return chats.map(c => {
    id: c.id,
    user_email: c.user.email,
    last_message_preview: c.messages.last?.content.truncate(100),
    last_message_sender: c.messages.last?.sender_type,
    message_count: c.messages.count,
    unread: c.unread_for_admin?,
    archived: c.archived?
  })
ステップ 3: WebSocket チャネル

リアルタイム通信用に 2 つのチャネルを作成します。

ChatChannel (各チャットに固有):

class ChatChannel
  on_subscribe(chat_id):
    chat = find_chat(chat_id)
    if not authorized(chat):
      reject()
      return
    stream_from "support_chat:#{chat_id}"

  function authorized(chat):
    return chat.user_id == current_user.id OR current_user.is_admin

  action send_message(content):
    if content.blank: return
    sender_type = current_user.is_admin ? 'admin' : 'user'
    chat.messages.create(content: content, sender_type: sender_type)

AdminNotificationChannel (すべての管理者向けのグローバル):

class AdminNotificationChannel
  on_subscribe:
    if not current_user.is_admin:
      reject()
      return
    stream_from "admin_support_notifications"

ブロードキャスト (Message モデルから):

function broadcast_message():
  message_data = {
    id: id,
    content: content,
    sender_type: sender_type,
    read_at: read_at,
    created_at: created_at
  }

  // Broadcast to chat subscribers (user + any viewing admins)
  broadcast("support_chat:#{chat.id}", {
    type: "new_message",
    message: message_data
  })

  // Notify all admins when user sends message
  if sender_type == 'user':
    broadcast("admin_support_notifications", {
      type: "new_user_message",
      chat_id: chat.id,
      user_email: chat.user.email,
      message: message_data
    })
ステップ 4: フロントエンド - ユーザーウィジェット

次のコンポーネントを備えたフローティングチャットウィジェットを作成します:

コンポーネント構成:

ChatWidget (root container)
├── ChatButton (fixed position, bottom-right)
│   ├── Icon (message bubble when closed, X when open)
│   └── UnreadBadge (shows count, caps at "9+")
└── ChatPanel (slides up when open)
    ├── Header (title + connection status dot)
    ├── MessageList (scrollable)
    │   └── MessageBubble (styled by sender_type)
    └── InputArea
        ├── Textarea (auto-expanding)
        └── SendButton

状態管理フック:

function useSupportChat():
  state:
    chat: Chat | null
    connected: boolean
    loading: boolean

  refs:
    consumer: WebSocketConsumer
    subscription: ChannelSubscription
    seenMessageIds: Set<string>  // For deduplication

  on_mount:
    fetch('/support_chat')
      .then(data => {
        chat = data
        seenMessageIds.addAll(data.messages.map(m => m.id))
      })

  when chat.id changes:
    subscription = consumer.subscribe('ChatChannel', { chat_id: chat.id })
    subscription.on_received(data => {
      if data.type == 'new_message':
        if seenMessageIds.has(data.message.id): return  // Dedupe
        seenMessageIds.add(data.message.id)
        chat.messages.push(data.message)
        if data.message.sender_type == 'admin':
          play_notification_sound()
    })
    subscription.on_connected(() => connected = true)
    subscription.on_disconnected(() => connected = false)

  on_unmount:
    subscription.unsubscribe()

  function sendMessage(content):
    subscription.perform('send_message', { content: content.trim() })

  function markAsRead():
    fetch('/support_chat/mark_read', { method: 'PATCH' })
    // Update local state to mark admin messages as read

  return { chat, connected, loading, sendMessage, markAsRead }

ウィジェットの動作:

  • 右下隅にフローティングボタンを表示する (固定位置)
  • 未読件数バッジを表示する (sender_type='admin' かつ read_at=null のメッセージをカウント)
  • ボタンのクリックでパネルを開閉する
  • パネルを開いたときに markAsRead() を自動的に呼び出す
  • 新しいメッセージが届いたときに自動的に最下部までスクロールする
  • 接続状態インジケーターを表示する (緑のドット = 接続済み)
  • キーボード: Enter で送信、Shift+Enter で改行

メッセージのスタイリング:

  • ユーザーのメッセージ: 右揃え、プライマリカラーの背景
  • 管理者のメッセージ: 左揃え、セカンダリ/ミュートの背景
  • 各メッセージにタイムスタンプを表示する
ステップ 5: フロントエンド - 管理ダッシュボード

チャット一覧とチャット詳細の 2 つのページを作成します。

チャット一覧ページ:

Header: "Support Chats"
Tabs: [Active] [Archived]

Chat cards (sorted by last_message_at desc):
┌─────────────────────────────────────────┐
│ [Unread indicator] [email protected]     │
│ Last message preview text...            │
│ 5 messages · 2 minutes ago              │
└─────────────────────────────────────────┘

機能:

  • タブによるフィルタリング (アクティブとアーカイブ)
  • 未読インジケーター (枠線またはバッジを強調表示)
  • クリックで詳細へ遷移
  • 最後のメッセージが管理者からのものである場合は「You: 」プレフィックスを表示する

チャット詳細ページ:

Header: [email protected] [Archive/Restore button]
Back link

Messages (grouped by date):
──── Monday, January 29 ────
[User bubble]  Message content
               10:30 AM

          [Admin bubble] Reply content
                         10:35 AM

Input area (same as widget)

機能:

  • 区切り線を入れて日付ごとにメッセージをグループ化する
  • ユーザーのメッセージは左、管理者のメッセージは右 (ユーザーウィジェットとは逆)
  • 送信者ラベルを表示する (管理者には「You」、ユーザーにはユーザーのメールアドレス/名前)
  • アーカイブ/復元の切り替えボタン
  • リアルタイム更新のためにユーザーウィジェットと同じ WebSocket サブスクリプション
  • ページの読み込み時に mark_viewed_by_admin() を呼び出す (サーバー側)
ステップ 6: メール通知

管理者が返信し、ユーザーがまだそれを確認していない場合に、ユーザーにメールを送信します。

ジョブ/ワーカー:

class SupportReplyNotificationJob
  perform(message):
    if message.sender_type != 'admin': return
    if message.read_at != null: return  // Already read, skip

    send_email(
      to: message.chat.user.email,
      subject: "New reply from Support",
      body: "You have a new message from our support team..."
    )

スケジューリング:

  • 管理者がメッセージを送信したときに 5 分遅延でジョブをスケジュールする
  • これにより、ユーザーがメールの前にアプリ内でメッセージを確認する時間を確保できる
  • ジョブは送信前にまだ未読かどうかを確認する
ステップ 7: TypeScript の型
interface SupportMessage {
  id: string
  content: string
  sender_type: 'user' | 'admin'
  read_at: string | null  // ISO8601
  created_at: string      // ISO8601
}

interface SupportChat {
  id: string
  messages: SupportMessage[]
}

interface SupportChatListItem {
  id: string
  user_id: string
  user_email: string
  last_message_at: string | null
  last_message_preview: string | null
  last_message_sender: 'user' | 'admin' | null
  message_count: number
  unread: boolean
  archived: boolean
}

interface AdminSupportChat {
  id: string
  user_id: string
  user_email: string
  archived: boolean
  messages: SupportMessage[]
}

// WebSocket message types
interface ChatChannelMessage {
  type: 'new_message'
  message: SupportMessage
}

interface AdminNotificationMessage {
  type: 'new_user_message'
  chat_id: string
  user_email: string
  message: SupportMessage
}

主要な設計上の決定

  1. ユーザーごとに 1 つのチャット - UX を簡素化し、ユーザーは常に同じ会話履歴を持つ
  2. アーカイブによるソフト削除 - 履歴を保持し、復元を可能にする
  3. 自動アーカイブ解除 - ユーザーがアーカイブされたチャットにメッセージを送信すると、それを再有効化する
  4. 遅延メール通知 - 5 分の遅延により、迅速な返信に対するスパムを防ぐ
  5. メッセージの重複排除 - 送信とブロードキャストのエコーによる重複を防ぐために、確認済み ID を追跡する
  6. 管理者専用チャネルの分離 - グローバルな未読件数やデスクトップ通知などの将来の機能を可能にする

テストチェックリスト

実装後:

  • ユーザーがウィジェットを開いてメッセージを送信できる
  • 管理者のダッシュボードにメッセージがリアルタイムで表示される
  • 管理者が返信でき、ユーザー側に即座に表示される
  • 未読バッジに正しい件数が表示される
  • ウィジェットを開くとバッジが消える
  • 接続インジケーターが実際のステータスを反映する
  • アーカイブ/復元が正しく動作する
  • ユーザーのメッセージ送信時に自動でアーカイブが解除される
  • メッセージが未読の場合、5 分後にメールが送信される
  • ユーザーがすでにメッセージを読んでいる場合、メールが送信されない
  • メッセージが時系列順に表示される
  • メッセージが重複して表示されない

よくある落とし穴

  1. 重複排除を忘れる - 現在のユーザーが送信したメッセージは、ブロードキャスト経由で返ってきます
  2. 既読ステータスの競合状態 - データベーストランザクションを使用してください
  3. WebSocket 認証 - ユーザーが特定のチャットにアクセスできることを確認してください
  4. 接続ステータスが古い - 再接続を適切に処理してください
  5. インデックスの不足 - (chat_id, created_at) に複合インデックスを追加してください
  6. メールの送信タイミング - 同期送信ではなく、バックグラウンドジョブを使用してください

フレームワーク別のガイダンス

Ruby on Rails

モデル:

# app/models/support_chat.rb
class SupportChat < ApplicationRecord
  belongs_to :user
  has_many :support_messages, dependent: :destroy

  scope :active, -> { where(archived_at: nil) }
  scope :archived, -> { where.not(archived_at: nil) }
  scope :recent_first, -> { order(last_message_at: :desc) }

  def touch_last_message
    update_column(:last_message_at, Time.current)
  end

  def unread_for_admin?
    support_messages.where(sender_type: :user)
      .where("created_at > ?", admin_viewed_at || Time.at(0)).exists?
  end

  def archive!
    update_column(:archived_at, Time.current)
  end

  def unarchive!
    update_column(:archived_at, nil)
  end
end

# app/models/support_message.rb
class SupportMessage < ApplicationRecord
  belongs_to :support_chat
  enum :sender_type, { user: 0, admin: 1 }
  validates :content, presence: true

  after_create :update_chat_timestamp
  after_create :auto_unarchive, if: :user?
  after_create_commit :broadcast_message
  after_create_commit :schedule_notification, if: :admin?

  private

  def broadcast_message
    ActionCable.server.broadcast("support_chat:#{support_chat_id}", {
      type: "new_message",
      message: { id:, content:, sender_type:, read_at:, created_at: }
    })
  end

  def schedule_notification
    SupportReplyNotificationJob.set(wait: 5.minutes).perform_later(self)
  end
end

チャンネル:

# app/channels/support_chat_channel.rb
class SupportChatChannel < ApplicationCable::Channel
  def subscribed
    @chat = SupportChat.find(params[:chat_id])
    reject unless @chat.user_id == current_user.id || current_user.admin?
    stream_from "support_chat:#{@chat.id}"
  end

  def send_message(data)
    @chat.support_messages.create!(
      content: data["content"],
      sender_type: current_user.admin? ? :admin : :user
    )
  end
end

マイグレーション:

create_table :support_chats, id: :uuid do |t|
  t.references :user, type: :uuid, null: false, foreign_key: true, index: { unique: true }
  t.datetime :last_message_at
  t.datetime :admin_viewed_at
  t.datetime :archived_at
  t.timestamps
end

create_table :support_messages, id: :uuid do |t|
  t.references :support_chat, type: :uuid, null: false, foreign_key: true
  t.text :content, null: false
  t.integer :sender_type, default: 0
  t.datetime :read_at
  t.timestamps
end
add_index :support_messages, [:support_chat_id, :created_at]
React(任意のバックエンドと併用)

フック:

// hooks/useSupportChat.ts
import { useEffect, useState, useRef, useCallback } from 'react'

export function useSupportChat(websocketUrl: string) {
  const [chat, setChat] = useState<Chat | null>(null)
  const [connected, setConnected] = useState(false)
  const wsRef = useRef<WebSocket | null>(null)
  const seenIds = useRef(new Set<string>())

  useEffect(() => {
    fetch('/api/support_chat').then(r => r.json()).then(data => {
      setChat(data)
      data.messages.forEach((m: Message) => seenIds.current.add(m.id))
    })
  }, [])

  useEffect(() => {
    if (!chat?.id) return
    const ws = new WebSocket(`${websocketUrl}?chat_id=${chat.id}`)
    wsRef.current = ws

    ws.onopen = () => setConnected(true)
    ws.onclose = () => setConnected(false)
    ws.onmessage = (event) => {
      const data = JSON.parse(event.data)
      if (data.type === 'new_message' && !seenIds.current.has(data.message.id)) {
        seenIds.current.add(data.message.id)
        setChat(prev => prev ? { ...prev, messages: [...prev.messages, data.message] } : prev)
      }
    }
    return () => ws.close()
  }, [chat?.id])

  const sendMessage = useCallback((content: string) => {
    wsRef.current?.send(JSON.stringify({ action: 'send_message', content }))
  }, [])

  return { chat, connected, sendMessage }
}

ウィジェットコンポーネント:

// components/ChatWidget.tsx
export function ChatWidget() {
  const [isOpen, setIsOpen] = useState(false)
  const { chat, connected, sendMessage } = useSupportChat('/ws/chat')
  const [input, setInput] = useState('')
  const messagesEndRef = useRef<HTMLDivElement>(null)

  const unreadCount = chat?.messages.filter(
    m => m.sender_type === 'admin' && !m.read_at
  ).length ?? 0

  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
  }, [chat?.messages])

  const handleSend = () => {
    if (!input.trim()) return
    sendMessage(input.trim())
    setInput('')
  }

  return (
    <div className="fixed bottom-4 right-4 z-50">
      {isOpen ? (
        <div className="w-80 h-96 bg-white rounded-lg shadow-xl flex flex-col">
          <header className="p-3 border-b flex justify-between items-center">
            <span>Support Chat</span>
            <span className={`w-2 h-2 rounded-full ${connected ? 'bg-green-500' : 'bg-gray-400'}`} />
          </header>
          <div className="flex-1 overflow-y-auto p-3 space-y-2">
            {chat?.messages.map(m => (
              <div key={m.id} className={`p-2 rounded ${m.sender_type === 'user' ? 'bg-blue-100 ml-auto' : 'bg-gray-100'}`}>
                {m.content}
              </div>
            ))}
            <div ref={messagesEndRef} />
          </div>
          <div className="p-3 border-t flex gap-2">
            <input value={input} onChange={e => setInput(e.target.value)}
              onKeyDown={e => e.key === 'Enter' && !e.shiftKey && handleSend()}
              className="flex-1 border rounded px-2" placeholder="Type a message..." />
            <button onClick={handleSend} className="px-3 py-1 bg-blue-500 text-white rounded">Send</button>
          </div>
        </div>
      ) : (
        <button onClick={() => setIsOpen(true)} className="w-14 h-14 bg-blue-500 rounded-full text-white relative">
          💬
          {unreadCount > 0 && (
            <span className="absolute -top-1 -right-1 bg-red-500 text-xs w-5 h-5 rounded-full flex items-center justify-center">
              {unreadCount > 9 ? '9+' : unreadCount}
            </span>
          )}
        </button>
      )}
    </div>
  )
}
Next.js(App Router)

API ルート:

// app/api/support-chat/route.ts
import { getServerSession } from 'next-auth'
import { prisma } from '@/lib/prisma'

export async function GET() {
  const session = await getServerSession()
  if (!session?.user) return Response.json({ error: 'Unauthorized' }, { status: 401 })

  let chat = await prisma.supportChat.findUnique({
    where: { userId: session.user.id },
    include: { messages: { orderBy: { createdAt: 'asc' } } }
  })

  if (!chat) {
    chat = await prisma.supportChat.create({
      data: { userId: session.user.id },
      include: { messages: true }
    })
  }

  return Response.json(chat)
}

Pusher/Ably を使用した WebSocket(サーバーレスに適しています):

// For serverless, use Pusher, Ably, or similar
import Pusher from 'pusher'
const pusher = new Pusher({ appId, key, secret, cluster })

// When message is created:
await pusher.trigger(`support-chat-${chatId}`, 'new-message', messageData)

// Client-side with pusher-js:
const channel = pusher.subscribe(`support-chat-${chatId}`)
channel.bind('new-message', (data) => { /* update state */ })
PHP/Laravel

モデル:

// app/Models/SupportChat.php
class SupportChat extends Model
{
    protected $casts = ['last_message_at' => 'datetime', 'archived_at' => 'datetime'];

    public function user() { return $this->belongsTo(User::class); }
    public function messages() { return $this->hasMany(SupportMessage::class); }

    public function scopeActive($query) { return $query->whereNull('archived_at'); }
    public function scopeArchived($query) { return $query->whereNotNull('archived_at'); }

    public function isUnreadForAdmin(): bool {
        return $this->messages()
            ->where('sender_type', 'user')
            ->where('created_at', '>', $this->admin_viewed_at ?? '1970-01-01')
            ->exists();
    }
}

// app/Models/SupportMessage.php
class SupportMessage extends Model
{
    protected static function booted() {
        static::created(function ($message) {
            $message->supportChat->update(['last_message_at' => now()]);
            broadcast(new NewSupportMessage($message))->toOthers();

            if ($message->sender_type === 'admin') {
                SendSupportReplyNotification::dispatch($message)->delay(now()->addMinutes(5));
            }
        });
    }
}

ブロードキャストイベント:

// app/Events/NewSupportMessage.php
class NewSupportMessage implements ShouldBroadcast
{
    public function __construct(public SupportMessage $message) {}

    public function broadcastOn() {
        return new PrivateChannel('support-chat.' . $this->message->support_chat_id);
    }

    public function broadcastAs() { return 'new-message'; }
}
Vue.js

コンポーザブル:

// composables/useSupportChat.ts
import { ref, onMounted, onUnmounted } from 'vue'

export function useSupportChat() {
  const chat = ref<Chat | null>(null)
  const connected = ref(false)
  let ws: WebSocket | null = null
  const seenIds = new Set<string>()

  onMounted(async () => {
    const res = await fetch('/api/support-chat')
    chat.value = await res.json()
    chat.value?.messages.forEach(m => seenIds.add(m.id))

    ws = new WebSocket(`/ws/chat?id=${chat.value?.id}`)
    ws.onopen = () => connected.value = true
    ws.onclose = () => connected.value = false
    ws.onmessage = (e) => {
      const data = JSON.parse(e.data)
      if (data.type === 'new_message' && !seenIds.has(data.message.id)) {
        seenIds.add(data.message.id)
        chat.value?.messages.push(data.message)
      }
    }
  })

  onUnmounted(() => ws?.close())

  const sendMessage = (content: string) => {
    ws?.send(JSON.stringify({ action: 'send_message', content }))
  }

  return { chat, connected, sendMessage }
}

データベースに関する推奨事項

PostgreSQL(推奨)
  • セキュリティのため、推測できない ID となる UUID の主キーを使用する
  • すべての日時カラムに timestamptz を使用する
  • 全文検索用に content に GIN インデックスを追加する(任意)
MySQL
  • UUID には CHAR(36) または BINARY(16) を使用する
  • マイクロ秒精度には DATETIME(6) を使用する
  • 絵文字をサポートするため、utf8mb4 文字セットを検討する
SQLite(開発/小規模向け)
  • プロトタイピングには十分に使用できます
  • UUID は TEXT として保存する
  • ネイティブの日時型がないため、ISO8601 文字列として保存する
MongoDB(ドキュメントストア)
  • メッセージ数に上限がある場合は、チャットドキュメント内にメッセージを埋め込む
  • または、chat_id を参照する別のコレクションを使用する
  • アーカイブ済みチャットの自動クリーンアップには、TTL インデックスを使用する(任意)

メール処理に関する推奨事項

トランザクションメールサービス
  • Postmark - 最も高い到達率とシンプルな API
  • SendGrid - 無料枠が充実し、堅牢
  • AWS SES - 大規模運用で最も低コスト
  • Resend - モダンな DX と React メールテンプレート
実装パターン
// Always use background jobs for email
Job: SendSupportReplyNotification
  delay: 5 minutes after admin message

  perform(message_id):
    message = find_message(message_id)

    // Guard clauses - don't send if:
    if message.sender_type != 'admin': return
    if message.read_at != null: return        // Already read
    if message.chat.archived?: return         // Chat archived

    send_email(
      to: message.chat.user.email,
      template: 'support_reply',
      data: { message_preview: message.content.truncate(200) }
    )
メールテンプレートのヒント
  • メッセージのプレビューを含める(省略表示)
  • チャットを開く直接リンクを追加する(ウェブアプリの場合)
  • 件名はシンプルにする:「[App] サポートからの新しい返信」
  • コンプライアンス対応のため、配信停止リンクを含める

リアルタイム技術の選択肢

技術最適な用途サーバーレス対応?
ActionCable (Rails)Rails アプリいいえ
Socket.IONode.js アプリいいえ
Pusherあらゆるスタックはい
Ablyあらゆるスタックはい
Supabase RealtimeSupabase ユーザーはい
Firebase RTDBFirebase ユーザーはい
Server-Sent Eventsシンプルな単方向通信はい
フォールバック戦略

WebSocket が利用できない場合は、ポーリングを実装します:

// Poll every 5 seconds when disconnected
if (!websocket.connected) {
  setInterval(() => {
    fetch('/api/support-chat/messages?since=' + lastMessageTime)
      .then(newMessages => appendMessages(newMessages))
  }, 5000)
}

コメント