Skip to content

Chat Architecture

Vidivo’s chat system provides real-time messaging between hosts and their clients. It is built with a WebSocket hub for instant delivery and REST endpoints for persistence and history retrieval.

Client (Browser/App)
|
| REST: GET/POST messages, conversations
| WebSocket: real-time delivery
v
API Gateway (Echo v5)
|
+----> Chat Handler (REST endpoints)
| |
| v
| Chat Service (business logic + access control)
| |
| v
| Chat Repository (PostgreSQL)
|
+----> Chat Hub (WebSocket connections)
|
v
ChatClient (per-user connection)
|
v
WritePump / ReadPump (goroutines)

Each conversation is a 1-to-1 channel between two users. Conversations are lazily created when the first message is sent or when explicitly requested via the API.

FieldTypeDescription
idUUIDUnique conversation identifier
created_attimestampWhen the conversation was created
updated_attimestampLast message timestamp

A join table linking users to conversations with read tracking:

FieldTypeDescription
conversation_idUUIDConversation reference
user_idUUIDParticipant reference
last_read_attimestampWhen the participant last read the conversation
FieldTypeDescription
idUUIDMessage identifier
conversation_idUUIDParent conversation
sender_idUUIDWho sent the message
bodytextMessage content
typestringtext or file
file_urlstringURL for file attachments
file_namestringOriginal file name
file_sizeintegerFile size in bytes
file_typestringMIME type
created_attimestampWhen the message was sent
Sender RoleCan Send?Condition
hostAlwaysNo restrictions
adminAlwaysNo restrictions
user / verified_userDuring active call onlyMust have an active call_sessions record with the recipient
guestDuring active call onlyMust have an active call session

The access control is enforced in the service layer (canSendMessage function):

  1. Check the sender’s role
  2. If the sender is a host or admin, allow the message
  3. Otherwise, query the call_sessions table for an active session between the sender and the recipient
  4. If no active session exists, return 403 Forbidden

This design ensures that:

  • Hosts can communicate with their clients at any time (scheduling, follow-up)
  • Clients cannot use the platform for general-purpose messaging
  • The chat feature serves its purpose as a professional communication tool

Clients connect to the WebSocket endpoint with their JWT access token:

wss://api.vidivo.app/v1/chat/ws?token=<access_token>

The handler validates the JWT, extracts the user ID, and registers the connection with the ChatHub.

The ChatHub maintains a map of user_id -> ChatClient:

ChatHub
|
+-- clients map[uuid.UUID]*ChatClient
|
+-- register chan *ChatClient (new connections)
+-- unregister chan *ChatClient (disconnections)
+-- broadcast chan []byte (messages to deliver)

Each ChatClient runs two goroutines:

  • ReadPump: Reads incoming WebSocket messages and routes them to the service layer
  • WritePump: Writes outgoing messages to the WebSocket connection
Sender Client Server Recipient Client
| | |
|-- WS message ---------->| |
| | |
| Validate access |
| Persist to DB |
| Find recipient hub |
| | |
| |-- WS new_message --------->|
| | |

If the recipient is not connected (offline), the message is only persisted. They will see it when they next open the conversation via the REST API.

Client to Server:

TypeFieldsDescription
messageconversation_id, bodySend a text message
typingconversation_idSend typing indicator
readconversation_idMark conversation as read

Server to Client:

TypeFieldsDescription
new_messagemessage objectNew message received
typingconversation_id, user_idOther user is typing
read_receiptconversation_id, user_idOther user read the conversation

Messages with type: "file" include file metadata:

{
"body": "Here is the document",
"type": "file",
"file_url": "https://cdn.vidivo.app/files/session-notes.pdf",
"file_name": "session-notes.pdf",
"file_size": 245760,
"file_type": "application/pdf"
}

Files are uploaded separately (to R2 storage) and then referenced in the message. The chat system does not handle file uploads directly --- it stores the URL reference.

Hosts can broadcast a message to all of their followers:

Host sends broadcast
|
v
Service gets all follower IDs (from follow repo)
|
v
For each follower:
1. Get or create conversation with follower
2. Create message in conversation
3. If follower is connected to hub, deliver via WebSocket

This fan-out approach ensures each follower has their own conversation thread with the host, keeping the chat personal rather than group-based.

The ChatHub uses a single event loop pattern (similar to Redis) to avoid locks:

func (h *ChatHub) Run(ctx context.Context) {
for {
select {
case client := <-h.register:
h.clients[client.UserID] = client
case client := <-h.unregister:
delete(h.clients, client.UserID)
case <-ctx.Done():
return
}
}
}

This ensures thread-safe access to the clients map without mutex contention.