jam-cloud/.planning/codebase/CHAT_API.md

21 KiB

Chat API Surface Documentation

Purpose: Document all chat-related API endpoints, WebSocket messages, database models, and data structures.

Date: 2026-01-26 Phase: 06-session-chat-research-design Plan: 01


Overview

The chat system uses a hybrid architecture:

  • REST API for sending messages and fetching history
  • WebSocket (Protocol Buffers) for real-time message delivery
  • No jamClient methods - chat is purely web-based (no native client integration)

Supported Channels:

  • global - All online users (disabled for school users)
  • session - Users in a specific music session
  • lesson - Direct messages between teacher and student in a lesson

REST API Endpoints

Base Path

All endpoints are under /api/chat (configured in web/config/routes.rb)

Authentication

All endpoints require:

  • before_filter :api_signed_in_user - valid session/token
  • before_filter :check_session - validates session/lesson access

1. Create Chat Message

Endpoint: POST /api/chat

Controller: ApiChatsController#create File: /web/app/controllers/api_chats_controller.rb

Request Body:

{
  "message": "Hello world",
  "channel": "session",
  "music_session": "session-uuid",
  "lesson_session": "lesson-uuid",
  "client_id": "client-uuid",
  "target_user": "user-uuid"
}

Parameters:

  • message (string, required) - Message text (1-255 chars, profanity filtered)
  • channel (string, required) - Channel type: "global", "session", "lesson"
  • music_session (string, conditional) - Required if channel="session"
  • lesson_session (string, conditional) - Required if channel="lesson"
  • client_id (string, optional) - Client UUID for routing (excludes sender from broadcast)
  • target_user (string, conditional) - Required if channel="lesson" (recipient user ID)

Response (Success 200):

{
  "id": "msg-uuid",
  "message": "Hello world",
  "user_id": "user-uuid",
  "session_id": null,
  "created_at": "2026-01-26T12:00:00.000Z",
  "channel": "session",
  "lesson_session_id": null,
  "purpose": null,
  "user": {
    "name": "John Doe"
  },
  "music_notation": null,
  "claimed_recording": null
}

Response (Error 404):

{
  "error": "specified session not found"
}

Response (Error 403):

{
  "error": "not allowed to join the specified session"
}

Response (Error 422):

{
  "errors": {
    "message": ["is too short (minimum is 1 character)"],
    "user": ["Global chat is disabled for school users"]
  }
}

Business Logic (from ChatMessage.create):

  1. Validates message length (1-255 chars) and profanity
  2. HTML sanitizes message content (strict mode)
  3. For lesson channel:
    • Auto-determines source_user and target_user from lesson
    • Sets unread flags: teacher_unread_messages or student_unread_messages
    • Sends email notification if target user offline
  4. Saves message to database
  5. Broadcasts via WebSocket (see WebSocket section)

Access Control:

  • Global channel: Blocked for school users (user.school_id present)
  • Session channel: Must be participant in session (music_session.access?(user))
  • Lesson channel: Must be teacher or student in lesson (lesson_session.access?(user))
  • School protection: Can only chat with users from same school (unless platform instructor)

2. Get Chat History

Endpoint: GET /api/chat

Controller: ApiChatsController#index File: /web/app/controllers/api_chats_controller.rb

Query Parameters:

GET /api/chat?channel=session&music_session=session-uuid&limit=20&page=0

Parameters:

  • channel (string, required) - Channel type: "global", "session", "lesson"
  • music_session (string, conditional) - Required if channel="session"
  • lesson_session (string, conditional) - Required if channel="lesson"
  • limit (integer, optional) - Number of messages to fetch (default: 20)
  • page (integer, optional) - Page number (default: 0)
  • start (integer, optional) - Offset cursor for pagination

Response (Success 200):

{
  "next": 20,
  "chats": [
    {
      "id": "msg-uuid",
      "message": "Hello world",
      "user_id": "user-uuid",
      "session_id": null,
      "created_at": "2026-01-26T12:00:00.000Z",
      "channel": "session",
      "lesson_session_id": null,
      "purpose": null,
      "user": {
        "name": "John Doe"
      },
      "music_notation": null,
      "claimed_recording": null
    }
  ]
}

Response Fields:

  • next (integer|null) - Cursor for next page, or null if no more messages
  • chats (array) - Array of chat messages (newest first, due to default_scope order)

Business Logic (from ChatMessage.index):

  1. Queries by channel
  2. If music_session param: filters by music_session_id
  3. If lesson_session param: filters by lesson_session_id
  4. Orders by created_at DESC (newest first)
  5. Includes associated user records (eager loading)
  6. Returns pagination cursor if more messages available

Access Control:

  • Same as create endpoint (validates session/lesson access)
  • Global channel: Returns empty array for school users

WebSocket Messages

Protocol

  • Uses Protocol Buffers (defined in pb/src/client_container.proto)
  • Routed through websocket-gateway service
  • Published via RabbitMQ to appropriate targets

Message Type: CHAT_MESSAGE

Protocol Buffer Definition:

message ChatMessage {
    optional string sender_name       = 1;
    optional string sender_id         = 2;
    optional string msg               = 3;
    optional string msg_id            = 4;
    optional string created_at        = 5;
    optional string channel           = 6;
    optional string lesson_session_id = 7;
    optional string purpose           = 8;
    optional string attachment_id     = 9;
    optional string attachment_type   = 10;
    optional string attachment_name   = 11;
}

JSON Equivalent (for jam-ui WebSocket handling):

{
  "sender_name": "John Doe",
  "sender_id": "user-uuid",
  "msg": "Hello world",
  "msg_id": "msg-uuid",
  "created_at": "2026-01-26T12:00:00.000Z",
  "channel": "session",
  "lesson_session_id": null,
  "purpose": null,
  "attachment_id": null,
  "attachment_type": null,
  "attachment_name": null
}

Field Descriptions:

  • sender_name - Display name of sender
  • sender_id - User UUID of sender
  • msg - Message text content
  • msg_id - Unique message ID (for deduplication)
  • created_at - ISO 8601 timestamp
  • channel - Channel type: "global", "session", "lesson"
  • lesson_session_id - Lesson UUID (if lesson channel)
  • purpose - Optional purpose: "Notation File", "Audio File", "JamKazam Recording", "Video Uploaded", etc.
  • attachment_id - ID of attached resource (notation, recording, video)
  • attachment_type - Type: "notation", "audio", "recording", "video"
  • attachment_name - Display name of attachment

Message Routing (from ChatMessage.send_chat_msg)

Session Channel:

@@mq_router.server_publish_to_session(music_session, msg, sender = {:client_id => client_id})
  • Broadcasts to all users in the session
  • Excludes sender (by client_id)
  • Route target: session:{session_id}

Global Channel:

@@mq_router.publish_to_active_clients(msg)
  • Broadcasts to all active (online) clients
  • Route target: __ALL_ACTIVE_CLIENTS__

Lobby Channel:

@@mq_router.publish_to_active_clients(msg)
  • Same as global (TODO: should filter to lobby users only)
  • Route target: __ALL_ACTIVE_CLIENTS__

Lesson Channel:

@@mq_router.publish_to_user(target_user.id, msg, sender = {:client_id => client_id})
@@mq_router.publish_to_user(user.id, msg, sender = {:client_id => client_id})
  • Sends to both teacher and student
  • Route target: user:{user_id}

Client-Side WebSocket Handling

Registration (legacy pattern):

context.JK.JamServer.registerMessageCallback(
  context.JK.MessageType.CHAT_MESSAGE,
  function (header, payload) {
    // Handle incoming chat message
    ChatActions.msgReceived(payload);
  }
);

jam-ui Integration:

  • Uses JamServer.js helper for WebSocket management
  • Messages routed through Redux middleware (similar to MIXER_CHANGES, JAM_TRACK_CHANGES)
  • Should subscribe/unsubscribe based on session state

Database Model

Table: chat_messages

Schema File: /ruby/db/schema.rb

Table Definition:

CREATE TABLE chat_messages (
  id VARCHAR(64) PRIMARY KEY DEFAULT uuid_generate_v4(),
  user_id VARCHAR(64) REFERENCES users(id),
  music_session_id VARCHAR(64) REFERENCES music_sessions(id),
  message TEXT NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  channel VARCHAR(128) NOT NULL DEFAULT 'session',
  target_user_id VARCHAR(64) REFERENCES users(id),
  lesson_session_id VARCHAR(64) REFERENCES lesson_sessions(id) ON DELETE CASCADE,
  purpose VARCHAR(200),
  music_notation_id VARCHAR(64) REFERENCES music_notations(id),
  claimed_recording_id VARCHAR(64) REFERENCES claimed_recordings(id)
);

Indexes:

CREATE INDEX chat_messages_idx_channels ON chat_messages(channel);
CREATE INDEX chat_messages_idx_created_at ON chat_messages(created_at);
CREATE INDEX chat_messages_idx_music_session_id ON chat_messages(music_session_id);

Columns:

  • id - UUID primary key
  • user_id - Sender user ID (FK to users)
  • music_session_id - Session ID (FK to music_sessions, nullable)
  • message - Message text (TEXT, NOT NULL)
  • created_at - Timestamp (default: current_timestamp)
  • channel - Channel type (default: "session")
  • target_user_id - Recipient user ID for direct messages (FK to users, nullable)
  • lesson_session_id - Lesson ID (FK to lesson_sessions, nullable, CASCADE delete)
  • purpose - Optional purpose/type (VARCHAR 200)
  • music_notation_id - Attached notation file (FK to music_notations, nullable)
  • claimed_recording_id - Attached recording (FK to claimed_recordings, nullable)

ActiveRecord Model

File: /ruby/lib/jam_ruby/models/chat_message.rb

Class: JamRuby::ChatMessage

Associations:

belongs_to :user
belongs_to :music_session
belongs_to :target_user, class_name: "JamRuby::User"
belongs_to :lesson_session, class_name: "JamRuby::LessonSession"
belongs_to :music_notation, class_name: "JamRuby::MusicNotation"
belongs_to :claimed_recording, class_name: "JamRuby::ClaimedRecording"

Validations:

validates :user, presence: true
validates :message, length: {minimum: 1, maximum: 255}, no_profanity: true
validate :same_school_protection
validate :no_global_for_schools

Default Scope:

default_scope { order('created_at DESC') }

Note: Messages are ordered newest-first by default!

HTML Sanitization:

html_sanitize strict: [:message]

Strips all HTML tags from message content.


Constants

Channels:

CHANNEL_SESSION = 'session'
CHANNEL_LESSON = 'lesson'
CHANNEL_LOBBY = 'lobby'

Note: global channel is used but not defined as constant.


lesson_sessions Table

Unread Message Tracking (for lesson chat):

ALTER TABLE lesson_sessions ADD COLUMN teacher_unread_messages BOOLEAN DEFAULT FALSE NOT NULL;
ALTER TABLE lesson_sessions ADD COLUMN student_unread_messages BOOLEAN DEFAULT FALSE NOT NULL;

Business Logic:

  • When student sends message: sets teacher_unread_messages = true
  • When teacher sends message: sets student_unread_messages = true
  • Flags should be cleared when user opens lesson chat (not implemented in code yet)

Email Notifications:

  • If target user is offline (!target.online?) and message is present
  • Sends email via UserMailer.lesson_chat(chat_msg).deliver_now

Data Flow Diagrams

Create Message Flow

Client (jam-ui)
  ↓ POST /api/chat
ApiChatsController#create
  ↓
ChatMessage.create(user, session, message, channel, client_id, target_user, lesson)
  ↓ Validates + Sanitizes
  ↓ Saves to DB
ChatMessage.send_chat_msg(...)
  ↓
MessageFactory.chat_message(...) → Protocol Buffer
  ↓
MQRouter.publish_to_*
  ↓ RabbitMQ
WebSocket Gateway
  ↓ WebSocket
All Clients (in channel)
  ↓
JamServer.handleMessage(CHAT_MESSAGE)
  ↓
ChatActions.msgReceived(payload)
  ↓
Redux Store Update
  ↓
UI Re-render

Get History Flow

Client (jam-ui)
  ↓ GET /api/chat?channel=session&music_session=123
ApiChatsController#index
  ↓
ChatMessage.index(user, params)
  ↓ Queries DB (with pagination)
  ↓ Includes user records
  ↓ Returns {chats: [...], next: cursor}
Rabl Template (api_chats/index.rabl)
  ↓ JSON serialization
Client receives chat history
  ↓
Merges with Redux store
  ↓
UI displays messages

Example API Calls

Create Session Message

Request:

POST /api/chat
Content-Type: application/json
Cookie: remember_token=...

{
  "message": "Great jam everyone!",
  "channel": "session",
  "music_session": "abc123",
  "client_id": "client-uuid-456"
}

Response:

{
  "id": "msg-789",
  "message": "Great jam everyone!",
  "user_id": "user-123",
  "session_id": null,
  "created_at": "2026-01-26T15:30:00.000Z",
  "channel": "session",
  "lesson_session_id": null,
  "purpose": null,
  "user": {
    "name": "John Doe"
  },
  "music_notation": null,
  "claimed_recording": null
}

WebSocket Broadcast (to all session participants):

{
  "type": "CHAT_MESSAGE",
  "chat_message": {
    "sender_name": "John Doe",
    "sender_id": "user-123",
    "msg": "Great jam everyone!",
    "msg_id": "msg-789",
    "created_at": "2026-01-26T15:30:00.000Z",
    "channel": "session",
    "lesson_session_id": null,
    "purpose": null
  }
}

Get Session History

Request:

GET /api/chat?channel=session&music_session=abc123&limit=20&page=0
Cookie: remember_token=...

Response:

{
  "next": 20,
  "chats": [
    {
      "id": "msg-789",
      "message": "Great jam everyone!",
      "user_id": "user-123",
      "session_id": null,
      "created_at": "2026-01-26T15:30:00.000Z",
      "channel": "session",
      "lesson_session_id": null,
      "purpose": null,
      "user": {
        "name": "John Doe"
      },
      "music_notation": null,
      "claimed_recording": null
    },
    {
      "id": "msg-788",
      "message": "Let's start!",
      "user_id": "user-456",
      "session_id": null,
      "created_at": "2026-01-26T15:29:00.000Z",
      "channel": "session",
      "lesson_session_id": null,
      "purpose": null,
      "user": {
        "name": "Jane Smith"
      },
      "music_notation": null,
      "claimed_recording": null
    }
  ]
}

Pagination:

  • If next is not null: More messages available
  • Next request: GET /api/chat?...&start=20

Create Global Message

Request:

POST /api/chat
Content-Type: application/json
Cookie: remember_token=...

{
  "message": "Anyone want to jam?",
  "channel": "global",
  "client_id": "client-uuid-456"
}

Response:

{
  "id": "msg-999",
  "message": "Anyone want to jam?",
  "user_id": "user-123",
  "session_id": null,
  "created_at": "2026-01-26T15:31:00.000Z",
  "channel": "global",
  "lesson_session_id": null,
  "purpose": null,
  "user": {
    "name": "John Doe"
  },
  "music_notation": null,
  "claimed_recording": null
}

WebSocket Broadcast (to all active clients):

{
  "type": "CHAT_MESSAGE",
  "chat_message": {
    "sender_name": "John Doe",
    "sender_id": "user-123",
    "msg": "Anyone want to jam?",
    "msg_id": "msg-999",
    "created_at": "2026-01-26T15:31:00.000Z",
    "channel": "global",
    "lesson_session_id": null,
    "purpose": null
  }
}

Missing Functionality

Read/Unread Tracking

Current State:

  • Exists for lesson chat: teacher_unread_messages, student_unread_messages boolean flags
  • Does NOT exist for session chat or global chat
  • No per-message read receipts
  • No persistent unread counts

What Needs to Be Added (Phase 6):

  1. Database Schema Changes:

    • Add last_read_at timestamp to track when user last viewed channel
    • OR: Add unread_count cache column per user/channel
    • OR: Add message_reads join table for per-message tracking (overkill for MVP)
  2. API Endpoints:

    • PUT /api/chat/mark_read - Mark messages as read for current user/channel
    • GET /api/chat/unread_count - Get unread count per channel
  3. WebSocket Messages:

    • Broadcast read receipts to other participants (optional, Phase 11)
  4. Business Logic:

    • Increment unread count when message received (if user not viewing chat)
    • Reset unread count when user opens chat window
    • Show badge on chat button with unread count

Implementation Strategy (to be designed in Phase 6 Plan 2):

  • Use localStorage for client-side unread tracking (simple, no schema changes)
  • OR: Use server-side tracking with new chat_message_reads table (more robust)
  • Preference: Server-side for cross-device consistency

File Attachments

Current State:

  • Lesson chat supports attachments (music_notation, claimed_recording)
  • Session chat does NOT support attachments
  • No UI in jam-ui for attaching files

Out of Scope for Phase 6:

  • File attachment functionality deferred to next milestone
  • Can be added later without breaking changes (fields already exist)

jamClient Methods

Conclusion: Chat is purely web-based. No native client (C++) methods exist.

Relevant jamClient Calls:

  • jamClient.UserAttention(true) - Triggers system notification when message received (if chat hidden)

Note: In jam-ui, we won't use legacy jamClient directly. Instead, use JamServer.js for WebSocket and browser Notification API for desktop notifications.


Security & Validation

Message Validation

  • Length: 1-255 characters
  • Profanity filter: no_profanity: true validation
  • HTML sanitization: Strips all tags (html_sanitize strict: [:message])

Access Control

  • Global Channel:
    • Blocked for school users (user.school_id present)
    • All non-school users can send/receive
  • Session Channel:
    • Must be participant: music_session.access?(current_user)
    • Validated via check_session before_filter
  • Lesson Channel:
    • Must be teacher or student: lesson_session.access?(current_user)
    • Auto-routes messages between teacher/student
    • School protection: Can only chat with same school (unless platform instructor)

Rate Limiting

  • NOT IMPLEMENTED in current API
  • Consider adding in Phase 11 (error handling)

Performance Considerations

Database Indexes

  • Indexed: channel, created_at, music_session_id
  • NOT indexed: lesson_session_id (should add if lesson chat heavily used)
  • NOT indexed: user_id (consider adding for user history queries)

Query Patterns

  • Default scope orders by created_at DESC - always scans newest messages first
  • Pagination uses offset (not cursor) - can be slow for deep pages
  • Eager loads user records - prevents N+1 queries

Message Retention

  • Global channel: Client-side limit of 100 messages (max_global_msgs)
  • Session/lesson: No limit in database (messages persist forever)
  • Consider: Add TTL or archiving for old messages (future optimization)

Integration Checklist for jam-ui

Phase 7 (Data Layer):

  • Create Redux slice for chat messages (chatSlice.js)
  • Add API client methods: fetchChatHistory(), sendChatMessage()
  • Add WebSocket message handler for CHAT_MESSAGE type
  • Implement message deduplication (by msg_id)
  • Add read/unread tracking logic

Phase 8 (UI Components):

  • Create JKSessionChatWindow component
  • Render message list with timestamps
  • Add message composition area (textarea + send button)
  • Implement auto-scroll on new messages
  • Add loading states (fetching history)

Phase 9 (State Management):

  • Wire up Redux actions (send, receive, fetch history)
  • Integrate WebSocket subscription
  • Handle session join/leave (activate channel)
  • Sync unread counts with Redux state

Phase 10 (Testing):

  • Unit tests: API client, Redux reducers
  • Integration tests: Message send/receive flow
  • E2E tests: Complete chat workflow

Phase 11 (Error Handling):

  • Handle API errors (404, 403, 422)
  • Handle WebSocket disconnection
  • Retry failed message sends
  • Show error messages to user

Summary

REST API:

  • Simple 2-endpoint design (create, index)
  • Channel-based routing (global, session, lesson)
  • Access control enforced
  • No read/unread tracking for session chat
  • No rate limiting

WebSocket:

  • Real-time delivery via Protocol Buffers
  • Efficient routing (session, user, broadcast)
  • Attachment metadata included

Database:

  • Single table for all channels
  • Foreign keys for associations
  • Indexes for common queries
  • No read tracking columns

Missing Features:

  • Read/unread tracking (session/global)
  • Rate limiting
  • Message deletion/editing
  • File attachments for session chat

Next Steps:

  • Analyze React patterns in jam-ui (CHAT_REACT_PATTERNS.md)
  • Design Redux state structure (Phase 6 Plan 2)
  • Design read/unread tracking strategy (Phase 6 Plan 2)