Skip to content

Internal Chat

Describe the built-in internal chat: who can message whom, how conversations are modeled, and how messages are delivered in real time.

Chat is tenant-scoped text messaging between users of the same tenant. It serves two needs:

  • Escalation — an agent can always direct-message a supervisor or admin (and vice versa), regardless of any tenant setting.
  • Peer chat — agents who share at least one campaign can DM each other, and supervisors/admins can create group threads. This is gated by a per-tenant toggle (tenants.peer_chat_enabled, off by default).

Conversations come in two kinds (chat_conversations.kind): direct (1:1) and group. “Escalation” is not a separate kind — it is simply a direct thread whose other party is privileged.

The data model (migration 066):

  • chat_conversationskind (direct/group), title (group only), pair_key (the two user IDs sorted + joined, unique per tenant for DMs), created_by.
  • chat_participants — membership plus a single last_read_at read cursor per user.
  • chat_messages — append-only body text (no attachments, edits, or deletes), indexed by conversation + time.

All three tables carry tenant_id and enforce Postgres RLS.

Endpoints (all authenticated tenant users; non-members get 404 so existence isn’t revealed):

Method Path Purpose
GET·POST /api/v1/chat/conversations List threads (with unread counts) / open a DM or create a group
GET·POST /api/v1/chat/conversations/{id}/messages Page messages / send a message
POST /api/v1/chat/conversations/{id}/read Advance the read cursor
GET·PUT /api/v1/chat/settings Read / toggle peer_chat_enabled (PUT is supervisor/admin only)

Who can start what (enforced server-side):

  • A privileged user (supervisor/admin/platform_admin) can DM anyone — that’s escalation, always on.
  • Two agents can DM only when peer_chat_enabled is on and they share a campaign.
  • Group creation is supervisor/admin only, and every member is individually checked so a supervisor can’t bridge two agents who couldn’t otherwise talk.

Realtime delivery uses a dedicated WebSocket, GET /ws/chat (JWT via header or ?token=). Messages fan out over the NATS subject t.<tenant>.chat.<conversation_id>.message; the server filters each socket to conversations the user belongs to (using the participant_user_ids carried on the payload, no extra DB hit). Publishing is best-effort — a message is durably stored even if the fan-out fails, and loads on the next fetch.

  • Read state is a single last_read_at cursor per participant; unread = messages newer than it not sent by you. No per-message read receipts, typing or presence.
  • Sending advances your own read cursor and floats the thread to the top of everyone’s list.
  • Messages are append-only with no retention/TTL — rows are removed only by ON DELETE CASCADE when the tenant, conversation or user is deleted.
  • Message pages default to 50, capped at 200.