NexGate Chat System — Development Architecture Guide

QBIT SPARK | NexGate Platform The complete reference for building the chat_system module inside nexgate_backend


Table of Contents

  1. Overview & Philosophy
  2. Inbox Model — Two Separate Inboxes
  3. The Four Tunnels — How chat_system Works
  4. Cards — Rich Content Type
  5. Content Types & User Boxes
  6. Where Chat Lives in the Codebase
  7. The Three Communication Channels
  8. Full System Diagram
  9. Authentication Flow
  10. Message Flow — 1:1 Chat
  11. Message Flow — Group Chat
  12. Commerce DM Flow
  13. Call Flow — Voice & Video
  14. Secret Chat — End-to-End Encryption
  15. NexGate Custom Namespaces
  16. RabbitMQ Event Pipeline
  17. Shop Inbox & Staff Access
  18. Offline & Push Notification Flow
  19. Multi-Device Support
  20. Redis — What to Cache & Why
  21. Best Practices & Anti-Patterns
  22. Package Structure
  23. Infrastructure Stack
  24. Build Order

1. Overview & Philosophy

What NexGate Chat Is

NexGate chat is NOT a standalone service.
It is a package inside nexgate_backend.

Three pillars of NexGate:
  VP Feed   → social content
  VP Shop   → commerce
  VP Events → event management
  Chat      → the connective tissue between all three

Chat is deeply integrated with commerce.
Every conversation can become a transaction.
Every transaction has a conversation behind it.

The Key Principle

Ejabberd = the transport layer (moves stanzas)
Spring Boot = the business logic layer (decides what happens)
PostgreSQL = the persistence layer (stores everything)
RabbitMQ = the event bus (connects Ejabberd to Spring Boot)

Ejabberd knows NOTHING about:
  NexGate users
  Orders
  Shops
  Offers
  Commerce

Spring Boot knows EVERYTHING about:
  Who can talk to whom
  What messages mean
  Commerce context
  Offer lifecycle
  Notifications

Why Not a Separate Microservice

Chat is INSIDE nexgate_backend because:
  Shares user data constantly
    (display names, avatars, shop memberships)
  Shares order data
    (order confirmations, shipping updates)
  Shares product data
    (product cards, offer sessions)
  No distributed transactions needed
  Same team, same codebase
  Simple joins instead of API calls

Extract chat service later IF:
  Chat traffic overwhelms platform
  Separate team manages chat
  DB connections exhausted by chat
  NOT before — premature optimization

2. Inbox Model — Two Separate Inboxes

The Two Inboxes

NexGate has TWO completely separate chat inboxes:

  Inbox 1 — Personal Chats:
    1:1 DMs with other users (@username)
    Group chats (friends, community)
    Accessed via: Chat → Personal tab

  Inbox 2 — Commerce Chats:
    DMs with shops ($tag)
    Order updates from shops
    Offer sessions
    Group buy notifications
    Accessed via: Chat → Commerce tab

Why separate?
  Personal: social conversations
  Commerce: transactional conversations
  User never loses personal chats
  in a flood of order notifications ✅
  Clean mental model ✅
  Like WhatsApp vs WhatsApp Business ✅

Conversation Types

DIRECT:
  Between two @users
  Personal 1:1 conversation
  Inbox: Personal

GROUP:
  Multiple @users
  Created explicitly
  Inbox: Personal

COMMERCE_DM:
  Between @user and $shop
  Initiated by either side
  Contains: product cards, offers, order updates
  Inbox: Commerce

COMMERCE_GROUP:
  Multiple @users + commerce context
  Bei ya pamoja group buying
  Inbox: Commerce (for the shop)
  Inbox: Personal (for the buyers — it's a group)

SYSTEM:
  Auto-generated per order
  No human on shop side (automated)
  Order confirmation, shipping updates
  Inbox: Commerce

How Conversations Are Created

DIRECT — user taps on @username → start chat:
  POST /chat/conversations/direct
  { targetUserId }
  Spring Boot: find existing OR create new
  type = DIRECT

GROUP — user creates a group:
  POST /chat/groups/create
  { name, type: PRIVATE|PUBLIC }
  Spring Boot: create + Ejabberd room
  type = GROUP

COMMERCE_DM — buyer taps [Chat with Shop]:
  POST /chat/conversations/commerce
  { shopId }
  Spring Boot: find existing OR create new
  Ejabberd: uses shop JID (shop-456@nexgate.com)
  type = COMMERCE_DM

COMMERCE_DM — order placed (auto):
  Spring Boot order service → triggers:
  CommerceDmService.getOrCreateCommerceThread(userId, shopId)
  First order message auto-sent:
    "Your order ORD-789 has been confirmed"
  type = COMMERCE_DM

SYSTEM — one per order (auto):
  Spring Boot: createSystemConversation(orderId)
  Only system messages
  No user can reply
  type = SYSTEM

Inbox API — What Mobile Calls

Personal inbox:
  GET /chat/conversations?type=personal
  Returns: DIRECT + GROUP conversations
  Sorted by: last_message DESC

Commerce inbox:
  GET /chat/conversations?type=commerce
  Returns: COMMERCE_DM + SYSTEM conversations
  Sorted by: last_message DESC
  Grouped by: shop (all threads with TechStore together)

Unread counts:
  GET /chat/conversations/unread-counts
  Returns:
    { personal: 3, commerce: 7 }
  Source: Redis (never DB query)
  Used for: tab badges in UI

Shop Side — Shop Inbox

Shop staff logs into NexGate shop dashboard:
  Sees: ALL conversations with customers
  Sorted by: unread first, then recent

Shop inbox API:
  GET /chat/shop/{shopId}/conversations
  Auth: requires shop staff JWT
  Returns: all COMMERCE_DM for this shop
  Shows: customer name, last message, unread

Staff replies:
  POST /chat/conversations/{convId}/messages
  { body, attachments }
  Sent as: shop-456@nexgate.com (no staff name) ✅

Shop cannot see:
  Conversations between customers ❌
  Personal chats ❌
  Other shops' conversations ❌

JID Mapping Per Conversation Type

DIRECT (alice → juma):
  Alice JID:  usr-alice@nexgate.com
  Juma JID:   usr-juma@nexgate.com
  Ejabberd:   direct XMPP stanza ✅

GROUP (warriors group):
  Room JID:   conv-abc123@conference.nexgate.com
  Members:    usr-alice, usr-juma, usr-enkiama
  Ejabberd:   MUC room ✅

COMMERCE_DM (alice → TechStore):
  Alice JID:  usr-alice@nexgate.com
  Shop JID:   shop-456@nexgate.com
  Staff:      shop-456@nexgate.com/staff-1 (internal)
  Customer sees: shop-456@nexgate.com ✅

SYSTEM (order notification):
  From JID:   system@nexgate.com
  To JID:     usr-alice@nexgate.com
  type:       headline (no offline storage trigger)
  Spring Boot sends via Ejabberd REST

Conversation Routing Rules

Message arrives from: usr-juma@nexgate.com
  → Check: is this in existing DIRECT conversation?
  → Yes → route to that conversation ✅
  → No → create new DIRECT conversation ✅
  → Inbox: Personal

Message arrives from: shop-456@nexgate.com
  → Check: is this in existing COMMERCE_DM?
  → Yes → route to that conversation ✅
  → No → create new COMMERCE_DM ✅
  → Inbox: Commerce

Message arrives from: system@nexgate.com
  → Route to SYSTEM conversation for that order
  → Inbox: Commerce

Message arrives from: conv-abc123@conference.nexgate.com
  → Route to GROUP conversation ✅
  → Inbox: Personal

3. The Four Tunnels — How chat_system Works

Inspiration

Inspired by the Cu Chi tunnel system in Vietnam —
250km of specialized tunnels, each built for a
specific purpose, each carrying specific content,
all invisible to those above.

chat_system works the same way:
  Four specialized tunnels
  Each carries specific content type
  Each powered by the right engine
  All meet at the Central Station (Ejabberd)
  All invisible to the network above

The Four Tunnels

┌─────────────────────────────────────────────────────────┐
│                    chat_system                          │
│                                                         │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐   │
│  │   TEXT      │  │   MEDIA     │  │   VOICE     │   │
│  │  TUNNEL     │  │  TUNNEL     │  │  TUNNEL     │   │
│  │             │  │             │  │             │   │
│  │  Messages   │  │  Images     │  │  1:1 Audio  │   │
│  │  Cards      │  │  Videos     │  │  Group Audio│   │
│  │  Typing     │  │  Voice note │  │             │   │
│  │  Receipts   │  │  Documents  │  │             │   │
│  │  Secret🔒   │  │  GIFs       │  │             │   │
│  │             │  │  Stickers   │  │             │   │
│  └──────┬──────┘  └──────┬──────┘  └──────┬──────┘   │
│         │                │                │            │
│  ┌──────▼──────────────────────────────────────────┐  │
│  │              EJABBERD (Central Station)          │  │
│  │         Routes ALL tunnels to destination        │  │
│  └──────────────────────────────────────────────────┘  │
│         │                │                │            │
│  ┌──────▼──────┐         │         ┌──────▼──────┐   │
│  │   VIDEO     │         │         │   COTURN    │   │
│  │  TUNNEL     │         │         │   LIVEKIT   │   │
│  │             │         │         │   RELAY     │   │
│  │  1:1 Video  │         │         │   ENGINES   │   │
│  │  Group Video│         │         │             │   │
│  │  Screen share│        │         │             │   │
│  └─────────────┘         │         └─────────────┘   │
│                    FILE THUNDER                        │
│                    processes media                     │
│                    before delivery                     │
└─────────────────────────────────────────────────────────┘

Tunnel 1 — Text Tunnel

What it carries:
  Plain text messages
  Cards (all 6 categories)
  Typing indicators
  Delivery receipts (✓✓)
  Read receipts (🔵)
  Reactions (👍❤️😂)
  Message edits + deletes
  Presence (online/offline)
  Commerce stanzas
  Group invitations
  System notifications
  Secret Chat (encrypted) 🔒

Engine: Ejabberd (XMPP)
Protocol: XML stanzas over WebSocket/TCP
Port: 5222 with TLS

Security levels inside Text Tunnel:
  Level 1 — Normal: stored in DB (readable)
  Level 2 — Commerce: auditable, legal record
  Level 3 — Secret Chat: OMEMO encrypted
              nobody can read, not even NexGate

Tunnel 2 — Media Tunnel

What it carries:
  Images (JPEG, PNG, WebP)
  Videos (MP4, HLS)
  Voice notes (OGG/Opus)
  Documents (PDF, DOC etc)
  GIFs (MP4 loop)
  Stickers (WebP)
  Files (any type)

Engines:
  File Thunder → processes + stores + CDN
  Ejabberd → delivers the stanza with CDN URL

Flow:
  Sender uploads → File Thunder processes
  File Thunder → CDN URL
  Ejabberd stanza → delivers URL to recipient
  Recipient → downloads from CDN directly

  Ejabberd never touches the file itself
  Only delivers the URL stanza ✅

Tunnel 3 — Voice Tunnel

What it carries:
  1:1 voice calls
  Group voice calls (3+ people)

Engines:
  Ejabberd → Jingle signaling (call setup)
  WebRTC → audio capture + encoding (Opus)
  Coturn → relay when P2P blocked (EA NAT)
  LiveKit → group calls (3+ people only)

1:1 voice:
  Try P2P direct first
  If blocked (carrier NAT) → Coturn relay
  Engine: WebRTC + Coturn

Group voice (3+):
  Always LiveKit SFU
  No P2P attempt
  Each person uploads 1 stream
  LiveKit forwards to all others

Switching 1:1 → group mid-call:
  Spring Boot creates LiveKit room
  Sends join stanzas to all
  Old WebRTC terminates
  All join LiveKit seamlessly ✅

Tunnel 4 — Video Tunnel

What it carries:
  1:1 video calls
  Group video calls (3+ people)
  Screen sharing

Engines:
  Ejabberd → Jingle signaling
  WebRTC → video capture + H.264 encoding
  Coturn → relay when P2P blocked
  LiveKit → group calls (3+ people only)

1:1 video:
  Try WebRTC P2P first
  If blocked → Coturn relay
  Engine: WebRTC + Coturn

Group video (3+):
  Always LiveKit SFU
  Simulcast: 3 quality levels per stream
    Low (180p): 2G networks
    Medium (360p): 3G networks
    High (720p): 4G/WiFi
  Each receiver gets quality their network allows

Screen share:
  Android: MediaProjection API
  iOS: ReplayKit broadcast extension
  H.264 encoding, 5-15fps
  Works in both 1:1 and group calls

Switching 1:1 → group mid-call:
  Same as Voice Tunnel upgrade flow ✅

Why Not LiveKit for Everything?

Question: why not use LiveKit for 1:1 calls too?

WebRTC P2P (1:1):
  Alice → directly → Bob (no server)
  Zero extra latency ✅
  Zero server cost ✅
  Privacy: nobody in the middle ✅
  Faster connection ✅

LiveKit SFU (group):
  Alice → LiveKit server → Bob
  Extra server hop = more latency ❌
  Server cost per call ❌
  Server sees media ❌

Rule: P2P when 2 people (always faster + cheaper)
      SFU when 3+ people (P2P impossible on mobile)

Why P2P fails for groups:
  5 people P2P mesh:
    Each uploads 4 streams
    Each downloads 4 streams
    = 8 streams simultaneously
    = 8Mbps needed per person ❌
    EA 4G average = 5Mbps ❌
    Impossible on EA networks ❌

  5 people via LiveKit SFU:
    Each uploads 1 stream only ✅
    LiveKit forwards to others
    Feasible on EA 3G/4G ✅

The Engine Registry

Engine          Tunnel(s)              Role
──────────────────────────────────────────────────────────
Ejabberd        ALL (signaling)        Central Station
                                       Routes everything
                                       XMPP stanzas

File Thunder    Media Tunnel           Process + store
                                       Virus scan
                                       CDN delivery

WebRTC          Voice + Video          Audio/video capture
                                       Encode/decode
                                       On-device processing

Coturn          Voice + Video          Relay station
                                       Bypasses EA NAT
                                       TURN protocol

LiveKit SFU     Voice + Video          Group calls (3+)
                (group only)           Simulcast engine
                                       One→many forwarding

OMEMO           Text Tunnel            Encryption engine
                (Secret Chat)          Keys on device only
                                       Level 3 security

Tunnel Security Levels

Inspired by Cu Chi tunnel depth levels:

Level 1 — Surface (Normal Chat):
  Regular conversations
  Stored in DB (Spring Boot can read)
  Used for: casual chat, group chat
  Engine: Ejabberd + PostgreSQL

Level 2 — Mid-depth (Commerce DMs):
  Business conversations
  Stored + auditable
  Legal record for disputes
  Staff audit log
  Used for: shop DMs, order updates
  Engine: Ejabberd + PostgreSQL (audit)

Level 3 — Deep (Secret Chat):
  End-to-end encrypted (OMEMO)
  Encrypted blob stored in DB
  NexGate CANNOT read content ✅
  Keys NEVER leave device ✅
  Used for: private personal conversations
  Engine: OMEMO + Ejabberd (transport only)

4. Cards — Rich Content Type

What Cards Are

Cards are rich interactive content blocks
that travel inside the Text Tunnel.

Like a specialized cargo train
running inside the text tunnel:
  Same tunnel (Ejabberd routes it) ✅
  Same transport (XMPP stanza) ✅
  Different cargo (rich structured data) ✅
  Different rendering (UI card) ✅

Every card:
  Has a custom xmlns (identifies it) ✅
  Has a type field (what kind exactly) ✅
  Has a <body> fallback (for basic clients) ✅
  Has action buttons (1-3 max) ✅
  Renders as visual card in ChatBox ✅

The Six Card Categories

Category 1 — Social Cards
  Source: VP Feed
  xmlns: urn:nexgate:feed:1
  Types:
    POST_CARD      → feed post shared in chat
    REEL_CARD      → short video shared
    LIVE_CARD      → live stream invite
    PROFILE_CARD   → user profile share

Category 2 — Commerce Cards
  Source: VP Shop
  xmlns: urn:nexgate:commerce:1
         urn:nexgate:offer:1
         urn:nexgate:groupbuy:1
         urn:nexgate:installment:1
  Types:
    PRODUCT_CARD         → single product
    SHOP_CARD            → shop profile
    FLASH_SALE_CARD      → time-limited sale
    CUSTOM_PRICE_OFFER   → private negotiated price
    GROUP_BUY_CARD       → Bei ya pamoja
    INSTALLMENT_PLAN     → buy now pay later

Category 3 — Event Cards
  Source: VP Events
  xmlns: urn:nexgate:event:1
  Types:
    EVENT_CARD         → event details
    TICKET_CARD        → purchased ticket
    EVENT_REMINDER     → reminder notification
    EVENT_GROUP_BUY    → group ticket purchase

Category 4 — System Cards
  Source: Spring Boot (automated, no human)
  xmlns: urn:nexgate:system:1
  Types:
    ORDER_CONFIRMATION    → order placed ✅
    ORDER_STATUS_UPDATE   → shipped/delivered
    PAYMENT_CONFIRMATION  → payment received
    REFUND_CARD           → refund processed
    DISPUTE_CARD          → dispute opened

Category 5 — Group Cards
  Source: chat_system
  xmlns: urn:nexgate:group:1
         urn:nexgate:call:1
  Types:
    GROUP_INVITATION     → join group request
    GROUP_CALL_JOIN_INFO → join ongoing call
    CALL_INITIATED       → incoming call
    CALL_DECLINED        → call declined
    CALL_MISSED          → missed call
    CALL_ENDED           → call summary
    HOST_MUTED_YOU       → muted by host
    HOST_REMOVED_YOU     → removed by host
    PARTICIPANT_ADDED    → someone joined call

Category 6 — API Template Cards (Future)
  Source: Third-party developers via API
  xmlns: urn:nexgate:template:1
  Types:
    TRANSACTIONAL  → order/delivery updates
    MARKETING      → promotions (opt-in only)
    AUTHENTICATION → OTP, verification

Card Anatomy

Every card follows this structure:

  ┌─────────────────────────────────────┐
  │ HEADER                              │
  │   Category icon + source label      │
  │   e.g. 🛍️ VP Shop                  │
  │─────────────────────────────────────│
  │ BODY                                │
  │   Card-specific content             │
  │   Image, title, price, progress etc │
  │─────────────────────────────────────│
  │ FOOTER                              │
  │   Action buttons (1-3 max)          │
  │   Status indicator                  │
  └─────────────────────────────────────┘

Example — Product Card:
  ┌─────────────────────────────────────┐
  │ 🛍️ VP Shop                          │
  │─────────────────────────────────────│
  │ [product image]                     │
  │ Samsung A15                         │
  │ TZS 450,000 · TechStore             │
  │ Stock: 12 units                     │
  │─────────────────────────────────────│
  │ [View Product]    [Chat with Shop]  │
  └─────────────────────────────────────┘

Example — Order Confirmation:
  ┌─────────────────────────────────────┐
  │ ⚙️ Order Update                     │
  │─────────────────────────────────────│
  │ ✅ Order Confirmed                   │
  │ ORD-789 · Samsung A15               │
  │ TZS 400,000 · M-PESA               │
  │─────────────────────────────────────│
  │ [Track Order]                       │
  └─────────────────────────────────────┘

Example — Group Buy:
  ┌─────────────────────────────────────┐
  │ 👥 Group Purchase                   │
  │─────────────────────────────────────│
  │ Samsung A15                         │
  │ Public: TZS 450,000                 │
  │ Group: TZS 350,000 (10 people)      │
  │ ████████░░  8 / 10 joined           │
  │ Expires: 2h 30m                     │
  │─────────────────────────────────────│
  │ [Join Group Buy]                    │
  └─────────────────────────────────────┘

Card Stanza Structure

<!-- Every card follows this pattern -->
<message to="bob@nexgate.com"
         type="chat"
         id="card-001">

  <body>Check out this product</body>
  <!-- ↑ ALWAYS include fallback body
       Basic clients see this text
       NexGate app renders the card -->

  <nexgate-commerce xmlns="urn:nexgate:commerce:1">
    <type>PRODUCT_CARD</type>
    <!-- type field tells app HOW to render -->

    <!-- card-specific data below -->
    <product>
      <id>prod-123</id>
      <name>Samsung A15</name>
      <price>450000</price>
      <currency>TZS</currency>
      <image_url>https://cdn.nexgate.com/img.jpg</image_url>
      <shop_name>TechStore</shop_name>
      <shop_id>shop-456</shop_id>
      <stock>12</stock>
      <snapshot_at>2026-07-17T10:00:00Z</snapshot_at>
    </product>
  </nexgate-commerce>

</message>

Mobile Card Decision Tree

Message arrives → check xmlns:

  urn:nexgate:feed:1 ?
    POST_CARD    → render post preview + [View Post]
    LIVE_CARD    → render live badge + [Watch Live]

  urn:nexgate:commerce:1 ?
    PRODUCT_CARD → render product + [View] [Chat]
    SHOP_CARD    → render shop + [Visit Shop]

  urn:nexgate:offer:1 ?
    PENDING      → render offer + timer + [Accept] [Decline]
    ACCEPTED     → "Offer Accepted ✅"
    EXPIRED      → "Offer Expired ⏰"

  urn:nexgate:groupbuy:1 ?
    GROUP_BUY_CARD → render progress bar + [Join]

  urn:nexgate:event:1 ?
    EVENT_CARD   → render event + [View] [Get Ticket]
    TICKET_CARD  → render ticket QR + [View]

  urn:nexgate:system:1 ?
    ORDER_CONFIRMATION → render order + [Track]
    ORDER_STATUS_UPDATE → render status badge
    PAYMENT_CONFIRMATION → render payment details

  urn:nexgate:call:1 ?
    CALL_INITIATED → show incoming call screen
    GROUP_CALL_JOIN_INFO → [Join Call] [Decline]
    CALL_MISSED → "Missed call from Alice" 📞

  urn:nexgate:group:1 ?
    GROUP_INVITATION → [Accept] [Decline]

  urn:nexgate:template:1 ?
    TRANSACTIONAL → render template card
    MARKETING → render with unsubscribe option

  No xmlns match ?
    → render as plain text (use <body>) ✅

5. Content Types & User Boxes

The Five Content Types

chat_system delivers exactly 5 content types:

Type 1 — Text Plain
  What: pure text messages
  Tunnel: Text Tunnel
  Engine: Ejabberd
  Example: "Hello Juma!"

Type 2 — Cards
  What: rich interactive content blocks
  Tunnel: Text Tunnel (same engine)
  Engine: Ejabberd
  6 categories: Social, Commerce, Event,
                System, Group, Template
  Example: Product card, Order update

Type 3 — Media Bundle
  What: files users share
  Tunnel: Media Tunnel
  Engine: File Thunder + Ejabberd
  Types: Image, Video, Voice note,
         Document, GIF, Sticker

Type 4 — Audio Stream
  What: real-time voice
  Tunnel: Voice Tunnel
  Engine: WebRTC + Coturn (1:1)
          LiveKit SFU (group)
  Includes: 1:1 voice, group voice

Type 5 — Video Stream
  What: real-time video
  Tunnel: Video Tunnel
  Engine: WebRTC + Coturn (1:1)
          LiveKit SFU (group)
  Includes: 1:1 video, group video, screen share

Two User-Facing Boxes

ChatBox:
  What it shows: conversation thread
  Delivers:
    Type 1: Text Plain → message bubbles
    Type 2: Cards → rich interactive blocks
    Type 3: Media Bundle → image/video/voice player

CallBox:
  What it shows: call screen
  Delivers:
    Type 4: Audio Stream → voice call UI
    Type 5: Video Stream → video call UI

  1:1 CallBox:
    Two participants
    Microphone, camera, hang up controls

  Group CallBox:
    Grid of participant tiles
    Raise hand ✋
    Emoji reactions 😂❤️🎉
    Host controls (mute, remove)
    Pin participant
    Speaker/grid layout switch

Call Actions — Who Handles What

Action                  Handler
──────────────────────────────────────────────────────
Mute/unmute mic         Local WebRTC (device)
Camera on/off           Local WebRTC (device)
Switch front/back cam   Local WebRTC (device)
Speaker/earpiece        Local OS audio
Hang up                 Jingle session-terminate

Raise hand ✋            LiveKit metadata broadcast
Lower hand              LiveKit metadata broadcast
Emoji reaction 😂       LiveKit sendData() broadcast
Pin participant         Local UI only (no server)
Switch grid/speaker     Local UI only (no server)
Disable incoming video  Local WebRTC (save data)

Mute participant (host) LiveKit SDK + Spring Boot auth
Remove participant      LiveKit SDK + Spring Boot auth
Add participant mid-call Spring Boot (new LiveKit room)
                        → sends join stanzas to all
                        → old WebRTC terminates
                        → all migrate to LiveKit

Audio → Video switch    Jingle content-add
Video → Audio switch    Jingle content-remove
Screen share start      Jingle content-add (screen)
Screen share stop       Jingle content-remove (screen)

1:1 → Group Call Upgrade Flow

Alice + Bob in 1:1 WebRTC call
Alice taps "Add Participant" → selects Juma

Spring Boot:
  1. Creates new LiveKit room
  2. Generates tokens for Alice, Bob, Juma
  3. Sends stanza to Alice:
       <nexgate-call xmlns="urn:nexgate:call:1">
         <type>GROUP_CALL_JOIN_INFO</type>
         <reason>UPGRADED_FROM_P2P</reason>
         <livekit_token>eyJ-alice...</livekit_token>
       </nexgate-call>
  4. Sends same to Bob (his token)
  5. Sends incoming call to Juma (his token)

Alice + Bob apps:
  Receive stanza
  Terminate WebRTC P2P
  Connect to LiveKit room
  Seamless — no hang up needed ✅

Juma's app:
  Incoming group call notification
  [Join] button
  Joins LiveKit room ✅

Result: 3-way group call on LiveKit ✅

6. Where Chat Lives in the Codebase

Package Structure

com.nexgate.backend
  ├── auth/              ← JWT, XMPP token issuance
  ├── feed/              ← VP Feed
  ├── shop/              ← VP Shop
  ├── events/            ← VP Events
  ├── notification/      ← FCM, Textfy SMS
  │
  └── chat/              ← EVERYTHING CHAT RELATED
        ├── config/      ← Ejabberd config, RabbitMQ config
        ├── controller/  ← REST endpoints for mobile
        ├── service/     ← Business logic
        ├── consumer/    ← RabbitMQ event consumers
        ├── ejabberd/    ← Ejabberd REST client + auth bridge
        ├── stanza/      ← Custom XMPP stanza builders
        ├── commerce/    ← Commerce DM flows
        ├── call/        ← Call management (TURN credentials)
        └── model/       ← Chat domain models (NO DB entities here)

Database

nexgate_postgres (port 5432)
  ├── schema: core    ← nexgate_backend owns
  │     users, shops, products, orders
  │
  └── schema: chat    ← chat package owns
        conversations
        messages
        message_reactions
        message_receipts
        calls
        offer_sessions
        group_invite_links
        notification_log

7. The Three Communication Channels

Channel 1 — Spring Boot → Ejabberd (REST API)

Direction:  Spring Boot calls Ejabberd
Protocol:   HTTP REST (port 5280)
Auth:       Admin credentials (stored in HashiCorp Vault)
Used for:   Sending stanzas, creating rooms, managing members

Examples:
  Send product card to buyer
  Create group room on event ticket purchase
  Kick user from shop group
  Send order confirmation stanza

Flow:
  Spring Boot
    → POST http://ejabberd:5280/api/send_message
    → Authorization: Basic admin@nexgate.com:adminpass
    → Body: { from, to, body + custom stanza }
    → Ejabberd routes to recipient ✅

Channel 2 — Ejabberd → Spring Boot (RabbitMQ)

Direction:  Ejabberd fires events → Spring Boot consumes
Protocol:   RabbitMQ AMQP
Auth:       RabbitMQ credentials (stored in Vault)
Used for:   Knowing when messages arrive, presence changes, calls

Examples:
  Message delivered → Spring Boot persists to DB
  User goes offline → Spring Boot updates last seen
  Call started → Spring Boot creates call record
  Message read → Spring Boot updates receipt status

Flow:
  Ejabberd (mod_rabbitmq)
    → publishes to exchange: nexgate.chat
    → routing key: chat.message.inbound
    → Spring Boot consumer picks up
    → persists + triggers notifications ✅

Channel 3 — Mobile → Ejabberd → Spring Boot (JWT)

Direction:  Mobile connects to Ejabberd
            Ejabberd validates via Spring Boot JWT
Protocol:   XMPP over WebSocket/TCP (port 5222) with TLS
Auth:       JWT token issued by Spring Boot on login
Used for:   User XMPP sessions (all chat activity)

Flow:
  1. Mobile → POST /auth/login (Spring Boot)
     Spring Boot validates credentials
     Issues TWO tokens:
       REST JWT  → for API calls (7 days)
       XMPP JWT  → for Ejabberd (24 hours)

  2. Mobile → Ejabberd port 5222
     Username: usr-kibuti@nexgate.com
     Password: <XMPP JWT>

  3. Ejabberd validates JWT locally
     Uses public key from JWKS endpoint:
       GET https://api.nexgate.com/auth/.well-known/jwks.json
     Verifies signature + expiry
     Allows connection ✅

  4. User is in — can send/receive stanzas

8. Full System Diagram

┌─────────────────────────────────────────────────────────────────────┐
│                        NexGate Platform                             │
│                                                                     │
│  ┌──────────────┐         ┌─────────────────────────────────────┐  │
│  │  Mobile App  │         │        nexgate_backend               │  │
│  │  (Android/   │         │                                     │  │
│  │   iOS)       │         │  ┌─────────┐  ┌─────────────────┐  │  │
│  │              │◀────────┼──│  auth/  │  │     chat/        │  │  │
│  │  Smack (XMPP)│  REST   │  │  JWT    │  │                 │  │  │
│  │  OkHttp(REST)│  API    │  │  XMPP   │  │  controller/    │  │  │
│  │              │         │  │  token  │  │  service/       │  │  │
│  └──────┬───────┘         │  └─────────┘  │  consumer/      │  │  │
│         │                 │               │  ejabberd/      │  │  │
│         │ XMPP+TLS        │               │  stanza/        │  │  │
│         │ port 5222        │               │  commerce/      │  │  │
│         │                 │               │  call/          │  │  │
│         ▼                 │               └────────┬────────┘  │  │
│  ┌──────────────┐         │                        │            │  │
│  │   Ejabberd   │         │               Channel 1│ REST API   │  │
│  │   Cluster    │◀────────┼────────────────────────┘            │  │
│  │              │ Channel1│                                     │  │
│  │  Node 1      │         │  ┌──────────────────────────────┐  │  │
│  │  Node 2      │─────────┼─▶│        RabbitMQ              │  │  │
│  │              │ Channel2│  │  exchange: nexgate.chat       │  │  │
│  │  JWT auth    │         │  │  queues:                     │  │  │
│  │  via JWKS    │         │  │    chat.message.inbound      │  │  │
│  └──────────────┘         │  │    chat.presence             │  │  │
│         │                 │  │    chat.call                 │  │  │
│         │                 │  └──────────────┬───────────────┘  │  │
│         │                 │                 │ Channel 2         │  │
│         │                 │                 ▼                   │  │
│         │                 │         chat/consumer/              │  │
│         │                 │           persists messages         │  │
│         │                 │           triggers FCM              │  │
│         │                 │           updates receipts          │  │
│         │                 └─────────────────────────────────────┘  │
│         │                                   │                      │
│         │                                   ▼                      │
│         │                 ┌─────────────────────────────────────┐  │
│         │                 │      nexgate_postgres:5432          │  │
│         │                 │  schema: core  │  schema: chat      │  │
│         │                 │  users         │  conversations     │  │
│         │                 │  shops         │  messages          │  │
│         │                 │  products      │  reactions         │  │
│         │                 │  orders        │  receipts          │  │
│         │                 │                │  calls             │  │
│         │                 └─────────────────────────────────────┘  │
│         │                                                           │
│         │                 ┌──────────────┐  ┌──────────────────┐  │
│         └────────────────▶│    Coturn    │  │     LiveKit      │  │
│           WebRTC media    │  STUN/TURN   │  │  Group Calls +   │  │
│           (calls)         │  port 3478   │  │  Audio Spaces    │  │
│                           └──────────────┘  └──────────────────┘  │
└─────────────────────────────────────────────────────────────────────┘

9. Authentication Flow

Login + XMPP Token Issuance

Mobile App                Spring Boot              Ejabberd
    │                         │                       │
    │── POST /auth/login ─────▶│                       │
    │   { phone, password }    │                       │
    │                         │── validate BCrypt      │
    │                         │── check not suspended  │
    │                         │── generate REST JWT    │
    │                         │── generate XMPP JWT    │
    │                         │   payload:             │
    │                         │   {                    │
    │                         │     jid: "usr-kibuti   │
    │                         │          @nexgate.com",│
    │                         │     exp: 1753228800    │
    │                         │   }                    │
    │                         │   signed: RS256        │
    │                         │   private key (Vault)  │
    │◀─ { restJwt, xmppJwt } ─│                       │
    │                         │                       │
    │── XMPP connect ─────────────────────────────────▶│
    │   username: usr-kibuti@nexgate.com               │
    │   password: <xmppJwt>                            │
    │                         │                       │
    │                         │◀── GET /auth/.well-    │
    │                         │    known/jwks.json     │
    │                         │    (public key)        │
    │                         │─── { keys: [...] } ──▶│
    │                         │                       │── verify JWT
    │                         │                       │── check expiry
    │                         │                       │── check jid
    │◀─────── session opened ─────────────────────────│
    │                         │                       │

JWT Key Management (Multi-Node)

Spring Boot:
  Private key → stored in HashiCorp Vault
  Used to SIGN XMPP JWTs
  Never shared

Ejabberd (all nodes):
  Public key → fetched from JWKS endpoint
  GET https://api.nexgate.com/auth/.well-known/jwks.json
  Used to VERIFY JWTs
  All nodes fetch same endpoint ✅

Key rotation:
  Spring Boot generates new key pair
  Adds both old + new to JWKS endpoint
  Issues new tokens with new key
  Old tokens valid until expiry (24h)
  After 24h → remove old key
  Zero downtime rotation ✅

10. Message Flow — 1:1 Chat

Mobile Sends Message

Mobile (Alice)            Ejabberd              Spring Boot
    │                       │                       │
    │── XMPP stanza ───────▶│                       │
    │   <message             │                       │
    │     to="bob@nexgate.com"│                      │
    │     type="chat"        │                       │
    │     id="msg-001">      │                       │
    │     <body>Hello</body> │                       │
    │     <origin-id         │                       │
    │       xmlns="urn:xmpp:sid:0"                   │
    │       id="msg-001"/>   │                       │
    │     <request           │                       │
    │       xmlns="urn:xmpp:receipts"/>              │
    │     <markable          │                       │
    │       xmlns="urn:xmpp:chat-markers:0"/>        │
    │   </message>           │                       │
    │                       │                       │
    │                       │── route to Bob ───────▶│(if offline)
    │                       │   (if online → deliver directly)
    │                       │                       │
    │                       │── RabbitMQ event ─────▶│
    │                       │   {                    │
    │                       │     event: "message",  │
    │                       │     from: "alice",     │
    │                       │     to: "bob",         │
    │                       │     stanza_id: "...",  │
    │                       │     body: "Hello",     │
    │                       │     timestamp: ...     │
    │                       │   }                    │
    │                       │                       │── persist to DB
    │                       │                       │── if Bob offline:
    │                       │                       │   send FCM
    │                       │                       │
    │◀── stream ACK ────────│                       │
    │    <a h="N"            │                       │
    │      xmlns="urn:xmpp:sm:3"/>                   │

What Spring Boot Does With Event

RabbitMQ consumer receives:
  1. Find or create conversation record
  2. Insert message into chat.messages
  3. Detect message type:
     - plain text → just persist
     - commerce stanza → trigger offer flow
     - system stanza → update order record
  4. Check if recipient online:
     - Online → delivery handled by Ejabberd
     - Offline → send FCM via notification/
  5. Update conversation.last_message
  6. Update conversation.updated_at

Message Interactions (Stanza Reference)

Edit (XEP-0308):
  <message id="edit-002">
    <body>corrected text</body>
    <replace xmlns="urn:xmpp:message-correct:0"
             id="original-msg-id"/>
  </message>
  Spring Boot: update messages.body, set edited_at

Delete (XEP-0424):
  <message id="retract-001">
    <apply-to xmlns="urn:xmpp:fasten:0"
              id="original-msg-id">
      <retract xmlns="urn:xmpp:message-retract:0"/>
    </apply-to>
    <body>/me retracted a message</body>
  </message>
  Spring Boot: set messages.deleted_at, body = null

React (XEP-0444):
  <message>
    <reactions xmlns="urn:xmpp:reactions:0"
               id="original-msg-id">
      <reaction>👍</reaction>
    </reactions>
  </message>
  Spring Boot: upsert message_reactions

Reply (XEP-0461):
  <message>
    <body>Thanks!</body>
    <reply xmlns="urn:xmpp:reply:0"
           to="alice@nexgate.com"
           id="original-msg-id"/>
  </message>
  Spring Boot: set messages.reply_to_id

Forward (XEP-0297):
  <message>
    <body>Check this</body>
    <forwarded xmlns="urn:xmpp:forward:0">
      <message from="..." to="...">
        <body>original message</body>
      </message>
    </forwarded>
    <nexgate-forward xmlns="urn:nexgate:forward">
      <original_sender_name>Juma</original_sender_name>
      <forward_chain>1</forward_chain>
    </nexgate-forward>
  </message>

11. Message Flow — Group Chat

Key Differences From 1:1

1:1 chat:                    Group chat:
  to = person JID              to = room JID
  type = "chat"                type = "groupchat"
  stanza-id by client          stanza-id by ROOM
  receipts per person          no per-person receipts
  reactions ref origin-id      reactions ref stanza-id

Group JID format:
  {conversationId}@conference.nexgate.com
  e.g. conv-abc123@conference.nexgate.com

Group Creation Flow

Mobile creates group:
  POST /chat/groups/create
  { name, type: PRIVATE|PUBLIC, description }

Spring Boot:
  1. Create conversation record (type=GROUP)
  2. Call Ejabberd REST:
     POST /api/create_room
     {
       name: "conv-abc123",
       service: "conference.nexgate.com",
       options: { persistent: true, members_only: true }
     }
  3. Creator auto-joined as OWNER
  4. Generate invite link token
  5. Return { conversationId, inviteLink }

Group Join — Two Mechanisms

Fan-out

Group message fan-out:
  Member sends to room JID
  Ejabberd MUC broadcasts to ALL members
  Erlang handles fan-out natively
  No Redis pub/sub needed
  Spring Boot persists via RabbitMQ event

At 500 members:
  Still Ejabberd MUC fan-out ✅
  Erlang is built for this

12. Commerce DM Flow

Product Card (Seller Attaches in 1:1)

Seller taps [From My Shop] in 1:1 DM attach menu:

Spring Boot builds stanza:
  <message to="buyer@nexgate.com" type="chat">
    <body>Check out this product</body>
    <nexgate-commerce xmlns="urn:nexgate:commerce:1">
      <type>PRODUCT_CARD</type>
      <product>
        <id>prod-123</id>
        <name>Samsung A15</name>
        <price>450000</price>
        <currency>TZS</currency>
        <image_url>https://cdn.nexgate.com/img.jpg</image_url>
        <shop_name>TechStore</shop_name>
        <shop_id>shop-456</shop_id>
        <stock>12</stock>
        <snapshot_at>2026-07-17T10:00:00Z</snapshot_at>
      </product>
    </nexgate-commerce>
  </message>

Rules:
  1:1 DM only (never group) ✅
  Price = current price at snapshot time
  Buyer's app renders product card UI
  [View Product] [Chat with Shop] buttons

Custom Price Offer Lifecycle

States: PENDING → ACCEPTED|DECLINED|EXPIRED|COMPLETED

Seller creates offer (1:1 DM only):
  POST /chat/commerce/offer/create
  { conversationId, productId, offerPrice, validMinutes: 30 }

Spring Boot:
  1. Create offer_sessions record
  2. Build stanza:
     <nexgate-offer xmlns="urn:nexgate:offer:1">
       <type>CUSTOM_PRICE_OFFER</type>
       <offer_id>offer-abc</offer_id>
       <status>PENDING</status>
       <valid_minutes>30</valid_minutes>
       <pricing>
         <public_price>450000</public_price>
         <offer_price>400000</offer_price>
         <currency>TZS</currency>
       </pricing>
     </nexgate-offer>
  3. Send via Ejabberd REST
  4. Start expiry timer (Redis)

Buyer accepts:
  POST /chat/commerce/offer/accept
  Spring Boot: update status → ACCEPTED
  Trigger checkout flow

Offer expires (30 min):
  Spring Boot scheduler fires
  Update status → EXPIRED
  Send expiry stanza to conversation:
     <nexgate-offer xmlns="urn:nexgate:offer:1">
       <type>OFFER_EXPIRED</type>
       <offer_id>offer-abc</offer_id>
     </nexgate-offer>
  Buyer's UI: "Offer Expired ⏰"

Bei Ya Pamoja (Group Buy)

Lives in: GROUP CHAT (not 1:1)
Stanza:
  <nexgate-groupbuy xmlns="urn:nexgate:groupbuy:1">
    <type>GROUP_BUY_CARD</type>
    <group_buy_id>gb-xyz</group_buy_id>
    <product>...</product>
    <pricing>
      <public_price>450000</public_price>
      <group_price>350000</group_price>
      <currency>TZS</currency>
    </pricing>
    <progress>
      <current>7</current>
      <target>10</target>
    </progress>
    <expires_at>2026-07-17T18:00:00Z</expires_at>
  </nexgate-groupbuy>

Spring Boot updates progress in real-time:
  When member joins → send updated card stanza
  When target reached → trigger group checkout
  When expired → send expired card stanza

Order Updates (System Messages)

Auto-sent by Spring Boot (no manual action):
  Order confirmed:
    <nexgate-system xmlns="urn:nexgate:system:1">
      <type>ORDER_CONFIRMATION</type>
      <order_id>ord-789</order_id>
      <status>CONFIRMED</status>
    </nexgate-system>

  Order shipped:
    <nexgate-system xmlns="urn:nexgate:system:1">
      <type>ORDER_STATUS_UPDATE</type>
      <order_id>ord-789</order_id>
      <status>SHIPPED</status>
    </nexgate-system>

Rules:
  Goes directly to commerce thread ✅
  Cannot be deleted ✅
  Cannot be edited ✅
  Cannot be reacted to ✅
  Immutable for legal/audit ✅

13. Call Flow — Voice & Video

1:1 Call (WebRTC + Jingle)

Alice calls Bob:

Mobile (Alice)          Spring Boot            Ejabberd        Bob's Device
    │                       │                     │                 │
    │── GET /chat/calls/     │                     │                 │
    │   turn-credentials ──▶│                     │                 │
    │                       │── generate HMAC     │                 │
    │                       │   TURN credentials  │                 │
    │◀── { urls, username,  │                     │                 │
    │      credential }      │                     │                 │
    │                       │                     │                 │
    │── Jingle               │                     │                 │
    │   session-initiate ────────────────────────▶│                 │
    │   <iq type="set">      │                     │── route ───────▶│
    │     <jingle            │                     │                 │
    │       action=          │                     │                 │
    │       "session-initiate"│                    │                 │
    │       sid="call-abc">  │                     │                 │
    │       <content         │                     │                 │
    │         name="audio">  │                     │                 │
    │         <description   │                     │                 │
    │           media="audio">│                    │                 │
    │           <payload-type│                     │                 │
    │             name="opus"/>│                   │                 │
    │         </description> │                     │                 │
    │         <transport     │                     │                 │
    │           xmlns=       │                     │                 │
    │           "urn:xmpp:   │                     │                 │
    │           jingle:      │                     │                 │
    │           transports:  │                     │                 │
    │           ice-udp:1">  │                     │                 │
    │           <candidate   │                     │                 │
    │             type="relay"│                    │                 │
    │             ip="turn.  │                     │                 │
    │             nexgate.com"│                    │                 │
    │             port=3478/>│                     │                 │
    │         </transport>   │                     │                 │
    │       </content>       │                     │                 │
    │     </jingle>          │                     │                 │
    │   </iq>                │                     │                 │
    │                       │                     │                 │
    │                       │                     │◀─ session- ─────│
    │                       │                     │   accept         │
    │◀── session-accept ─────────────────────────│                 │
    │                       │                     │                 │
    │◀═══ WebRTC Audio/Video via Coturn ══════════════════════════▶│
    │                       │                     │                 │
    │── session-terminate ──────────────────────▶│                 │
    │                       │                     │                 │
    │── POST /chat/calls/   │                     │                 │
    │   {callId}/end ───────▶│                    │                 │
    │                       │── update call record│                 │

Audio ↔ Video Switching (No Hang Up)

Mid-call: Alice enables camera:
  Alice → Jingle content-add:
    <jingle action="content-add" sid="call-abc">
      <content name="video">
        <description media="video">
          <payload-type name="H264"/>
        </description>
      </content>
    </jingle>

Bob accepts:
  Jingle content-accept
  Video starts — audio uninterrupted ✅

Switch back (video → audio):
  Jingle content-remove
  Audio continues ✅

TURN Credentials Generation

Spring Boot generates time-limited TURN credentials:
  username = timestamp:userId
  credential = HMAC-SHA256(sharedSecret, username)
  valid for: 1 hour

TURN server (Coturn) validates:
  Checks HMAC signature
  Checks timestamp not expired
  Allows relay ✅

Coturn config:
  use-auth-secret
  static-auth-secret = <shared-secret-from-vault>
  realm = nexgate.com

Group Call (LiveKit SFU)

3+ people call → use LiveKit (not P2P):

Spring Boot:
  1. Create LiveKit room: group-call-{callId}
  2. Generate token per participant
  3. Send join info via XMPP stanza:
     <nexgate-call xmlns="urn:nexgate:call:1">
       <type>GROUP_CALL_JOIN_INFO</type>
       <call_id>call-xyz</call_id>
       <livekit_url>wss://livekit.nexgate.com</livekit_url>
       <livekit_token>eyJ...</livekit_token>
       <room_id>group-call-xyz</room_id>
     </nexgate-call>

Each participant:
  Receives stanza → phone rings
  Joins → WebRTC to LiveKit
  LiveKit SFU forwards streams

EA network strategy:
  Simulcast (low/medium/high quality)
  Each receiver gets quality network allows
  Bad network → low quality (not dropped)

14. Secret Chat — End-to-End Encryption

What Secret Chat Is

Normal chat in NexGate:
  Alice sends message → Ejabberd routes → Bob
  Spring Boot stores message in DB (readable)
  NexGate can read messages if legally required
  Good for: commerce DMs, groups, order updates

Secret Chat:
  Alice encrypts ON DEVICE → Ejabberd routes → Bob decrypts ON DEVICE
  Spring Boot stores ENCRYPTED BLOB (unreadable)
  NexGate CANNOT read messages
  Keys NEVER leave the device
  Good for: personal private conversations

Secret Chat = optional feature
User explicitly chooses to start one
Cannot be forced or auto-converted

Technology — OMEMO (XEP-0384)

OMEMO = Encryption that scales to multiple devices
Based on Signal Protocol (same as WhatsApp)
Built for XMPP natively

Why OMEMO over other options:
  Signal Protocol = most secure available ✅
  Multi-device support built-in ✅
  Standard XEP = Ejabberd supports natively ✅
  Forward secrecy ✅
    (old messages safe even if key compromised)
  Deniability ✅
    (cannot prove who sent a message)
  Open standard ✅ (auditable)

How OMEMO Works (Simple)

Key Setup (happens once per device):
  Alice's phone generates:
    Identity Key pair (permanent)
    Signed PreKey pair (rotates every week)
    One-Time PreKeys (100 per batch)

  Alice publishes PUBLIC keys to Ejabberd:
    Via XMPP PubSub (XEP-0060)
    Ejabberd stores: alice's public keys
    Bob can fetch them anytime

  Private keys:
    NEVER leave Alice's device ✅
    Spring Boot NEVER sees them ✅
    Ejabberd NEVER sees them ✅

Message Encryption:
  Alice wants to send to Bob:
    1. Fetch Bob's public keys from Ejabberd
    2. Generate session key (Diffie-Hellman)
    3. Encrypt message with session key
    4. Encrypt session key with Bob's identity key
    5. Send encrypted blob via XMPP stanza

  Bob receives:
    1. Decrypt session key using his private key
    2. Decrypt message using session key
    3. Display plaintext to Bob ✅

  Ejabberd sees: encrypted blob only ❌
  Spring Boot stores: encrypted blob only ❌
  Nobody between Alice and Bob can read ✅

Multi-Device in Secret Chat

Alice has phone + tablet:
  OMEMO encrypts for EACH device separately

  Message encrypted for:
    Bob's phone key ✅
    Bob's tablet key ✅
    Alice's tablet key ✅ (carbon copy)

  Each device decrypts its own copy
  All devices see the message ✅

  If Alice adds new device:
    Must re-establish sessions
    Old messages NOT automatically available
    (forward secrecy — by design) ✅

Secret Chat Flow

Alice starts Secret Chat with Bob:

  Alice:
    Tap Bob's profile
    → "Start Secret Chat" 🔒
    → App checks: do I have Bob's OMEMO keys?
    → If no: fetch from Ejabberd PubSub
    → Establish encrypted session
    → UI shows: "🔒 Secret Chat"
    → "Messages are end-to-end encrypted"

  Alice sends message:
    App encrypts locally ✅
    Sends encrypted stanza:

    <message to="bob@nexgate.com" type="chat">
      <body>I can't read this</body>
      ← fallback for non-OMEMO clients

      <encrypted xmlns="eu.siacs.conversations.axolotl">
        <header sid="473229364">
          <key rid="987654321">
            BASE64_ENCRYPTED_SESSION_KEY_FOR_BOB
          </key>
          <key rid="111111111">
            BASE64_ENCRYPTED_SESSION_KEY_FOR_ALICE_TABLET
          </key>
          <iv>BASE64_IV</iv>
        </header>
        <payload>BASE64_ENCRYPTED_MESSAGE_BODY</payload>
      </encrypted>
    </message>

  Ejabberd:
    Routes stanza (cannot read payload) ✅

  Spring Boot (RabbitMQ consumer):
    Receives event
    Detects: has OMEMO encryption ✅
    Stores encrypted payload in DB:
      messages.body = null
      messages.encrypted_payload = BASE64_BLOB
      messages.is_e2ee = true
    Does NOT attempt to decrypt ✅

  Bob's device:
    Receives stanza
    Decrypts using private key
    Displays plaintext ✅

What Spring Boot Stores vs Doesn't

Normal message:
  messages.body = "Hello Bob!" ← readable
  messages.is_e2ee = false

Secret Chat message:
  messages.body = null ← empty ✅
  messages.encrypted_payload = "BASE64..." ← blob
  messages.is_e2ee = true
  messages.sender_id = usr-alice (known)
  messages.conversation_id = conv-abc (known)
  messages.created_at = timestamp (known)

Spring Boot knows:
  WHO sent ✅ (metadata)
  WHEN sent ✅ (metadata)
  TO WHOM ✅ (metadata)
  WHAT conversation ✅ (metadata)

Spring Boot does NOT know:
  WHAT was said ❌ (content encrypted)
  This is by design ✅
  Legal compliance: "we cannot read it" ✅

Secret Chat Rules

Can do in Secret Chat:
  ✅ Send text messages (encrypted)
  ✅ Send images (encrypted)
  ✅ Send voice notes (encrypted)
  ✅ Send files (encrypted)
  ✅ Set self-destruct timer
  ✅ Verify contact's identity (key fingerprint)

Cannot do in Secret Chat:
  ❌ Forward messages (breaks E2EE chain)
  ❌ Screenshot (Android FLAG_SECURE)
  ❌ Quote/reply across devices
  ❌ Search message content (encrypted in DB)
  ❌ Commerce stanzas (offer sessions need DB)
  ❌ Group Secret Chat (Phase 3 — complex)
  ❌ Web client (keys on device only)

Self-Destruct Timer

Optional feature in Secret Chat:
  Alice sets: "Delete after 5 minutes"
  Both devices delete locally after timer
  Server record remains (encrypted blob)
  BUT: server also deletes after timer ✅

Implementation:
  <encrypted xmlns="eu.siacs.conversations.axolotl">
    ...
    <nexgate-secret xmlns="urn:nexgate:secret:1">
      <destruct_after_seconds>300</destruct_after_seconds>
    </nexgate-secret>
  </encrypted>

Spring Boot:
  Receives event → sets Redis TTL on message:
    EXPIRE msg:msg-abc 300
  After 300 seconds:
    Spring Boot deletes from DB ✅
  Recipient device:
    Timer shown in UI (countdown)
    Local delete after timer ✅

Identity Verification

Users can verify each other's identity:
  Compare OMEMO key fingerprints
  Out-of-band (meet in person, voice call)
  "My fingerprint: A1B2 C3D4 E5F6..."
  "Your fingerprint matches ✅"

In UI:
  Contact profile → "Verify Security"
  Shows: "Your Safety Number with Alice"
  64-character fingerprint
  QR code option ✅
  If verified: show verified badge 🛡️

Why this matters:
  Protects against man-in-the-middle attacks
  "Is Ejabberd showing me Alice's real keys?"
  After verification: guaranteed ✅

Key Management in Spring Boot

Spring Boot role in OMEMO:
  Store public keys (not private) ✅
  Serve public keys via Ejabberd PubSub ✅
  Never store private keys ❌

Public key storage:
  Ejabberd PubSub handles automatically
  Node: eu.siacs.conversations.axolotl.bundles:{deviceId}
  Spring Boot does NOT manage this
  Ejabberd handles key distribution ✅

Key rotation:
  Device rotates Signed PreKey weekly
  Device publishes new PreKey to Ejabberd PubSub
  Spring Boot uninvolved ✅
  One-Time PreKeys replenished automatically
  When running low: device publishes more ✅

Spring Boot only knows:
  Which users have OMEMO enabled
  Which conversations are Secret Chats
  That encrypted blobs exist (not content)

UI Treatment

Secret Chat conversation:
  🔒 Lock icon in conversation header
  Dark/different color scheme (optional)
  "Messages are end-to-end encrypted" banner
  Timer icon if self-destruct enabled ⏱️

Starting Secret Chat:
  Long press on contact OR
  Three dot menu → "Start Secret Chat"
  Separate conversation from normal chat
  Cannot accidentally send to wrong chat

Incoming Secret Chat:
  "Alice wants to start a Secret Chat"
  [Accept] [Decline]

Key fingerprint screen:
  Settings → Conversations → [name]
  → "View Security Code"
  Shows QR + text fingerprint

What's NOT E2EE (Important)

Secret Chat is 1:1 ONLY at launch:
  ❌ Group Secret Chat (Phase 3)
  ❌ Commerce DMs (need server-side logic)
  ❌ Shop conversations (staff audit needed)
  ❌ System messages (order notifications)
  ❌ Call content (WebRTC handles separately)

Call security (separate from OMEMO):
  WebRTC calls: DTLS-SRTP encrypted ✅
    (built into WebRTC standard)
    Keys negotiated per call
    Nobody can intercept call media ✅
  Call metadata: Spring Boot knows
    Who called whom ✅ (metadata)
    How long ✅ (metadata)
    But not WHAT was said ✅

Phase Roadmap for Secret Chat

Phase 1 (Launch):
  ✅ 1:1 Secret Chat (OMEMO)
  ✅ Self-destruct timer
  ✅ Identity verification
  ✅ Image/file encryption
  ✅ Voice note encryption

Phase 2:
  ⬜ Secret Chat on web (harder — keys on device)
  ⬜ Key backup (encrypted, optional)
  ⬜ Sealed sender (hide sender metadata)

Phase 3:
  ⬜ Group Secret Chat (OMEMO groups)
  ⬜ Note to Self (encrypted personal notes)

15. NexGate Custom Namespaces

Complete Registry

All custom stanzas ALWAYS go inside <message>
Ejabberd routes without knowing what is inside
Frontend identifies by xmlns + type field

SOCIAL CARDS (VP Feed):
  xmlns: urn:nexgate:feed:1
  Types: POST_CARD, REEL_CARD, LIVE_CARD, PROFILE_CARD

COMMERCE CARDS (VP Shop):
  xmlns: urn:nexgate:commerce:1
  Types: PRODUCT_CARD, SHOP_CARD, FLASH_SALE_CARD

  xmlns: urn:nexgate:offer:1
  Types: CUSTOM_PRICE_OFFER, OFFER_RESPONSE, OFFER_EXPIRED

  xmlns: urn:nexgate:groupbuy:1
  Types: GROUP_BUY_CARD, GROUP_BUY_PROGRESS, GROUP_BUY_COMPLETED

  xmlns: urn:nexgate:installment:1
  Types: INSTALLMENT_PLAN_CARD

EVENT CARDS (VP Events):
  xmlns: urn:nexgate:event:1
  Types: EVENT_CARD, TICKET_CARD, EVENT_REMINDER, EVENT_GROUP_BUY

SYSTEM CARDS (Automated):
  xmlns: urn:nexgate:system:1
  Types: ORDER_CONFIRMATION, ORDER_STATUS_UPDATE,
         PAYMENT_CONFIRMATION, REFUND_CARD, DISPUTE_CARD

GROUP + CALL CARDS (chat_system):
  xmlns: urn:nexgate:group:1
  Types: GROUP_INVITATION

  xmlns: urn:nexgate:call:1
  Types: CALL_INITIATED, CALL_ACCEPTED, CALL_DECLINED,
         CALL_ENDED, CALL_MISSED, GROUP_CALL_JOIN_INFO,
         PARTICIPANT_ADDED, HOST_MUTED_YOU, HOST_REMOVED_YOU

API TEMPLATE CARDS (Third-party Future):
  xmlns: urn:nexgate:template:1
  Types: TRANSACTIONAL, MARKETING, AUTHENTICATION

MEDIA:
  xmlns: urn:nexgate:media:1
  Types: IMAGE, VIDEO, VOICE_NOTE, FILE, STICKER, GIF

SECRET CHAT METADATA:
  xmlns: urn:nexgate:secret:1
  Types: SELF_DESTRUCT_TIMER, KEY_VERIFICATION

EPHEMERAL (always add no-store hint):
  xmlns: urn:nexgate:states    (recording state)
  xmlns: urn:nexgate:forward   (forward metadata)

Standard XEPs Used

XEP-0085   chatstates              Typing indicators
XEP-0184   urn:xmpp:receipts       Delivery ticks
XEP-0333   urn:xmpp:chat-markers:0 Read ticks
XEP-0308   message-correct:0       Edit message
XEP-0424   message-retract:0       Delete message
XEP-0444   urn:xmpp:reactions:0    Emoji reactions
XEP-0297   urn:xmpp:forward:0      Forward message
XEP-0461   urn:xmpp:reply:0        Reply/quote
XEP-0359   urn:xmpp:sid:0          Stable stanza IDs
XEP-0198   urn:xmpp:sm:3           Stream management
XEP-0199   urn:xmpp:ping           Keepalive ping
XEP-0334   urn:xmpp:hints          no-store hint
XEP-0384   axolotl (OMEMO)         E2EE encryption
XEP-0166   urn:xmpp:jingle:1       Call signaling
XEP-0045   muc protocol            Group chat
XEP-0280   urn:xmpp:carbons:2      Multi-device sync
XEP-0313   urn:xmpp:mam:2          Message archive

Mobile App Decision Tree

Message arrives:

  Has urn:nexgate:feed:1 ?
    POST_CARD  → post preview + [View Post]
    LIVE_CARD  → live badge + [Watch Live]

  Has urn:nexgate:commerce:1 ?
    PRODUCT_CARD → product card + [View] [Chat]

  Has urn:nexgate:offer:1 ?
    PENDING  → offer + timer + [Accept] [Decline]
    ACCEPTED → "Offer Accepted"
    EXPIRED  → "Offer Expired"

  Has urn:nexgate:groupbuy:1 ?
    GROUP_BUY_CARD → progress bar + [Join]

  Has urn:nexgate:event:1 ?
    EVENT_CARD  → event + [View] [Get Ticket]
    TICKET_CARD → ticket QR code

  Has urn:nexgate:system:1 ?
    ORDER_CONFIRMATION  → order + [Track]
    ORDER_STATUS_UPDATE → status badge
    PAYMENT_CONFIRMATION → payment details

  Has urn:nexgate:call:1 ?
    CALL_INITIATED      → incoming call screen
    GROUP_CALL_JOIN_INFO → [Join Call] [Decline]
    CALL_MISSED         → missed call indicator
    HOST_MUTED_YOU      → "You were muted by host"
    HOST_REMOVED_YOU    → "You were removed from call"

  Has urn:nexgate:group:1 ?
    GROUP_INVITATION → [Accept] [Decline]

  Has urn:nexgate:media:1 ?
    IMAGE      → show image
    VOICE_NOTE → waveform + play button
    STICKER    → full size, no bubble
    GIF        → auto-play loop
    VIDEO      → video player
    FILE       → download button

  Has urn:nexgate:template:1 ?
    TRANSACTIONAL → template card
    MARKETING     → card + unsubscribe

  No xmlns match ?
    Render as plain text (use body) 

ALWAYS include body as fallback
  Basic XMPP clients see fallback text
  NexGate app renders rich card

16. RabbitMQ Event Pipeline

Exchange & Queue Setup

Exchange: nexgate.chat (topic exchange)

Queues:
  nexgate.chat.messages     ← all message events
  nexgate.chat.presence     ← online/offline events
  nexgate.chat.calls        ← call start/end events
  nexgate.chat.rooms        ← group create/destroy events

Routing keys:
  chat.message.inbound      → message arrived
  chat.message.edited       → message edited
  chat.message.retracted    → message deleted
  chat.message.reaction     → reaction added/removed
  chat.presence.online      → user came online
  chat.presence.offline     → user went offline
  chat.call.initiated       → call started
  chat.call.ended           → call ended
  chat.room.created         → group created
  chat.room.destroyed       → group deleted

Event Payload Structure

chat.message.inbound:
  {
    event_type: "message.inbound",
    from_jid: "alice@nexgate.com/android",
    to_jid: "bob@nexgate.com",
    stanza_id: "1784107799294256",
    origin_id: "msg-abc-123",
    conversation_type: "DIRECT|GROUP",
    room_jid: null | "conv-abc@conference.nexgate.com",
    body: "Hello Bob!",
    has_custom_namespace: true,
    namespace: "urn:nexgate:commerce:1",
    raw_stanza: "<message>...</message>",
    timestamp: 1721210400000
  }

chat.presence.offline:
  {
    event_type: "presence.offline",
    jid: "alice@nexgate.com/android",
    bare_jid: "alice@nexgate.com",
    timestamp: 1721210400000
  }

chat.call.ended:
  {
    event_type: "call.ended",
    call_id: "call-xyz",
    from_jid: "alice@nexgate.com",
    to_jid: "bob@nexgate.com",
    duration_seconds: 120,
    call_type: "VOICE|VIDEO",
    ended_reason: "normal|declined|missed|failed"
  }

Spring Boot Consumer Responsibilities

@RabbitListener(queues = "nexgate.chat.messages")
fun onMessage(event: ChatMessageEvent) {

  // 1. Parse event
  // 2. Find or create conversation
  // 3. Detect message type:
  if (event.namespace == "urn:nexgate:commerce:1") {
    // parse product card, link to product
  } else if (event.namespace == "urn:nexgate:offer:1") {
    // update offer session status
  } else {
    // plain message
  }

  // 4. Persist to chat.messages
  // 5. Update conversation last_message
  // 6. Check recipient online status
  //    If offline → send FCM via notification/
  // 7. For commerce stanzas → trigger business flows
}

@RabbitListener(queues = "nexgate.chat.presence")
fun onPresence(event: PresenceEvent) {
  // Update user last_seen in core.users
  // Update conversation online indicators
}

17. Shop Inbox & Staff Access

Shop XMPP Identity

Every shop has its own JID:
  $techstore → shop-456@nexgate.com

Staff members share this JID:
  Staff A connects as: shop-456@nexgate.com/staff-1
  Staff B connects as: shop-456@nexgate.com/staff-2

Customer always sees: TechStore (never staff name)
JID resource stripped before delivery ✅

Staff XMPP token:
  Issued by Spring Boot when staff logs in
  { jid: "shop-456@nexgate.com", exp: ... }
  Short-lived: 8 hours (staff shift)

Shop Inbox Routing

Customer messages shop:
  to="shop-456@nexgate.com"
  Ejabberd routes to available staff resource
  (round-robin or first available)

Staff replies:
  from="shop-456@nexgate.com/staff-1"
  Spring Boot strips /staff-1 resource
  Customer receives:
    from="shop-456@nexgate.com" ✅
  Customer never knows which staff replied ✅

Audit log:
  Spring Boot records which staff_id sent each message
  Internal only — never visible to customer
  Legal requirement for disputes

Shop Chat Initiation Rules

ALWAYS ALLOWED (transactional — auto):
  Order confirmed → system sends to buyer
  Order shipped → system sends to buyer
  Payment issue → system sends to buyer

ALLOWED (existing relationship):
  Previous buyer → shop can message from Orders section
  Active follower → shop can send from Customers section
  Goes to existing thread OR message request

NEVER ALLOWED:
  Cold message to random users ❌
  System blocks at API level
  "You can only message customers who
   interacted with your shop"

18. Offline & Push Notification Flow

Message arrives for Bob (offline):

Ejabberd:
  1. Detects Bob offline
  2. Stores in Ejabberd offline queue
  3. Fires RabbitMQ event: chat.message.inbound
     + is_recipient_offline: true

Spring Boot consumer:
  1. Persists message to DB
  2. Detects is_recipient_offline = true
  3. Calls notification/ package:
     - Has FCM token? → send FCM push ✅
     - No FCM token? → try Textfy SMS ✅
     - Priority: FCM first, SMS fallback

FCM payload:
  {
    to: "bob-fcm-token",
    notification: {
      title: "Alice",
      body: "Hello Bob!"
    },
    data: {
      conversation_id: "conv-abc",
      message_id: "msg-001",
      type: "chat"
    }
  }

Bob comes online:
  Ejabberd delivers offline queue
  Spring Boot receives delivery event
  Updates message status → DELIVERED
  Sends <received> receipt to Alice ✅

19. Multi-Device Support

The Problem

Kibuti has:
  Phone (android)    → alice@nexgate.com/android
  Tablet (tablet)    → alice@nexgate.com/tablet
  Web (browser)      → alice@nexgate.com/web

Both logged in simultaneously.
How do messages reach all devices?
How do sent messages sync across devices?
How does read status sync?

Solution — XEP-0280 Message Carbons

Message Carbons = automatic copy to all devices

Kibuti sends message from phone:
  Phone → Ejabberd → Bob ✅
  Ejabberd ALSO sends carbon copy to:
    Kibuti's tablet ✅
    Kibuti's web ✅

Result:
  All Kibuti's devices see sent messages ✅
  Conversation stays in sync ✅
  No extra code needed in Spring Boot
  Ejabberd handles it via mod_carbons

Enable in ejabberd.yml:
  modules:
    mod_carbons: {}

Resource Priority

Each device has a priority:
  android: priority 10 (highest — phone)
  tablet:  priority 5
  web:     priority 1 (lowest)

When Juma sends to Kibuti:
  Ejabberd delivers to HIGHEST priority device
  = android (phone) gets it first
  Carbon copies → all other devices

Priority set during session setup:
  <presence>
    <priority>10</priority>
  </presence>

NexGate mobile sets:
  Foreground app: priority 10
  Background app: priority 0
  Ejabberd routes to foreground device ✅

Read Status Sync Across Devices

Problem:
  Kibuti reads message on phone
  Tablet still shows unread badge

Solution — XEP-0333 Chat Markers sync via Carbons:
  Phone sends <displayed> to Juma
  Ejabberd carbons copy to tablet + web
  Tablet receives: "Kibuti read this on phone"
  Tablet clears unread badge ✅

This is automatic via mod_carbons ✅

XMPP JWT Per Device

Each device gets its OWN XMPP JWT:
  Phone connects → POST /auth/login → xmppJwt-1
  Tablet connects → POST /auth/refresh → xmppJwt-2
  Web connects → POST /auth/login → xmppJwt-3

Each JWT has same JID but different resource:
  { jid: "usr-kibuti@nexgate.com", exp: ... }

Ejabberd assigns resource automatically:
  usr-kibuti@nexgate.com/android  (phone)
  usr-kibuti@nexgate.com/tablet   (tablet)
  usr-kibuti@nexgate.com/web-abc  (browser)

On logout (one device):
  That device's JWT invalidated
  Other devices unaffected ✅

Active Sessions Tracking

Spring Boot tracks active sessions in Redis:
  Key: sessions:usr-kibuti
  Value: SET {
    "android:gajim.B3HKS5VE",
    "tablet:dino.36aa17c4"
  }
  TTL: 24 hours (JWT expiry)

When checking if user is online:
  Redis lookup → instant ✅
  No DB query needed ✅
  No Ejabberd REST call needed ✅

Updated by:
  PresenceConsumer → user online → add to SET
  PresenceConsumer → user offline → remove from SET

Multi-Device Message Archive (MAM)

XEP-0313 Message Archive Management:
  All messages stored in Ejabberd archive (PostgreSQL)
  New device login → fetches message history
  "Give me messages since last week"

New device flow:
  1. Kibuti logs in on new tablet
  2. Tablet connects to Ejabberd
  3. Tablet sends MAM query:
     <iq type="set">
       <query xmlns="urn:xmpp:mam:2">
         <x><field var="start">
           <value>2026-07-10T00:00:00Z</value>
         </field></x>
       </query>
     </iq>
  4. Ejabberd returns all messages since that date
  5. Tablet renders full conversation history ✅

Spring Boot also has messages in chat.messages:
  Mobile can fetch via REST: GET /chat/messages
  Paginated, sorted, filtered
  Two sources of truth:
    Ejabberd MAM: raw XMPP archive
    Spring Boot DB: structured business records

20. Redis — What to Cache & Why

The Golden Rule

Redis is for:
  Data that changes frequently
  Data read far more than written
  Data where 1-2 second staleness is OK
  Data that is expensive to compute

Redis is NOT for:
  Primary source of truth
  Data that must be 100% consistent
  Large objects (images, files)
  Data that needs complex queries

Cache 1 — Online Presence

Key:    presence:usr-kibuti
Value:  { status: "online", last_seen: timestamp, devices: [...] }
TTL:    5 minutes (refreshed by heartbeat)

Why Redis:
  Checked on EVERY message send
  "Is recipient online?" → Redis hit ✅
  Without Redis: Ejabberd REST call per message ❌
  At 7,000 msg/min: 7,000 Ejabberd REST calls/min ❌
  With Redis: 7,000 Redis reads/min (microseconds) ✅

Updated by:
  PresenceConsumer when user comes online
  PresenceConsumer when user goes offline
  Heartbeat pings every 60 seconds

Cache 2 — Active XMPP Sessions

Key:    sessions:usr-kibuti
Value:  SET of resource strings
TTL:    24 hours (JWT expiry)

Why Redis:
  Know which devices are connected
  Without Redis: Ejabberd REST call (user_resources)
  With Redis: instant SET lookup ✅

Updated by:
  PresenceConsumer on connect/disconnect

Cache 3 — Conversation Metadata

Key:    conv:conv-abc123
Value:  {
          name: "Business Friends",
          type: "GROUP",
          member_count: 47,
          last_message: "...",
          last_updated: timestamp
        }
TTL:    10 minutes

Why Redis:
  Inbox list loads conversation metadata for EVERY conversation
  User with 50 conversations → 50 DB reads ❌
  With Redis: 50 cache hits ✅
  Inbox load: <50ms vs 500ms ✅

Updated by:
  MessageConsumer when new message arrives
  GroupService when group changes

Cache 4 — Unread Count Per Conversation

Key:    unread:usr-kibuti:conv-abc123
Value:  integer (unread message count)
TTL:    none (persist until read)

Why Redis:
  Show unread badge on inbox
  Without Redis: COUNT query per conversation ❌
  With Redis: INCR / SET / GET (nanoseconds) ✅

Updated by:
  MessageConsumer: INCR when message arrives for user
  ReceiptConsumer: SET 0 when user sends <displayed>

Total unread across all conversations:
  Key: unread:total:usr-kibuti
  MessageConsumer: INCR
  Reset: SET 0 when inbox opened

Cache 5 — Offer Session State

Key:    offer:offer-abc123
Value:  {
          status: "PENDING",
          expires_at: timestamp,
          offer_price: 400000,
          public_price: 450000
        }
TTL:    30 minutes (offer validity)

Why Redis:
  Offer expiry check on every [Accept] tap
  Without Redis: DB read per check ❌
  With Redis: instant lookup ✅
  TTL = offer auto-expires at Redis level ✅

Updated by:
  OfferLifecycleService on state change
  Redis TTL handles expiry automatically

Cache 6 — Rate Limiting

Key:    ratelimit:msg:usr-kibuti
Value:  integer (message count in window)
TTL:    1 minute (sliding window)

Why Redis:
  Prevent message spam
  Without Redis: DB query per message ❌
  With Redis: INCR + TTL = instant ✅

Rules:
  Max 60 messages per minute per user
  Max 10 group invitations per day
  Max 5 concurrent call attempts

Implementation:
  INCR ratelimit:msg:usr-kibuti
  EXPIRE ratelimit:msg:usr-kibuti 60
  If value > 60: reject with 429

Cache 7 — TURN Credentials

Key:    turn:usr-kibuti
Value:  { username, credential, ttl, urls }
TTL:    1 hour

Why Redis:
  User may request TURN credentials multiple times
  (call fails, retry, reconnect)
  Without Redis: HMAC compute per request
  With Redis: cache for 1 hour ✅
  Same credentials valid for 1 hour anyway

Cache 8 — User Profile (For Chat Display)

Key:    profile:usr-kibuti
Value:  {
          display_name: "Kibuti Mwangi",
          avatar_url: "https://cdn.nexgate.com/...",
          username: "kibuti",
          is_verified: false
        }
TTL:    30 minutes

Why Redis:
  Chat inbox shows sender name + avatar
  50 conversations × profile = 50 DB reads ❌
  With Redis: 50 cache hits ✅

Updated by:
  User updates profile → invalidate cache
  Cache miss → read from core.users → cache

Cache 9 — Group Membership (Hot Groups)

Key:    group:members:conv-abc123
Value:  SET of user JIDs
TTL:    5 minutes

Why Redis:
  Fan-out notification: "who is in this group?"
  For push notifications when group member offline
  Without Redis: DB query per group message ❌
  With Redis: SMEMBERS in microseconds ✅

Only cache for ACTIVE groups (>10 msg/hour)
Inactive groups: read from DB directly

Redis Key Naming Convention

Pattern: {domain}:{entity}:{id}:{sub}

Examples:
  presence:usr-kibuti
  sessions:usr-kibuti
  conv:conv-abc123
  unread:usr-kibuti:conv-abc123
  unread:total:usr-kibuti
  offer:offer-abc123
  ratelimit:msg:usr-kibuti
  ratelimit:invite:usr-kibuti
  turn:usr-kibuti
  profile:usr-kibuti
  group:members:conv-abc123

NEVER store:
  Raw message content (use DB)
  Files or media (use MinIO)
  Financial data (use DB with audit)
  Auth tokens as plain text (use Vault)

Cache Invalidation Strategy

Write-through (update cache + DB together):
  Unread counts ← must be accurate
  Offer status ← critical for commerce

Cache-aside (read DB on miss, then cache):
  User profiles ← changes rarely
  Conversation metadata ← OK if slightly stale
  Group membership ← OK if 5 min stale

TTL-based eviction (let it expire):
  Presence ← Ejabberd handles truth
  TURN credentials ← expire with JWT
  Rate limits ← sliding window

Event-based invalidation:
  User updates profile → DEL profile:usr-{id}
  Group settings change → DEL conv:{id}
  Member joins group → SADD group:members:{id}
  Member leaves group → SREM group:members:{id}

21. Best Practices & Anti-Patterns

✅ DO — Message Persistence

DO: Persist via RabbitMQ consumer (async)
  Message arrives → Ejabberd delivers → fires event
  Consumer persists to DB asynchronously
  User experience not blocked by DB write ✅

DON'T: Persist via synchronous API call
  Message arrives → Spring Boot REST call → DB
  DB write blocks message delivery ❌
  If DB slow → messages delayed ❌
  If DB down → messages lost ❌

✅ DO — Online Status Checks

DO: Check Redis for online status
  MessageConsumer:
    val isOnline = redis.exists("presence:$userId")
    if (!isOnline) sendFCM()

DON'T: Call Ejabberd REST for every message
  val resources = ejabberd.getUserResources(userId)
  if (resources.isEmpty()) sendFCM()
  ← This is an HTTP call per message ❌
  ← At scale: thousands of HTTP calls/min ❌

✅ DO — Stanza Building

DO: Build stanzas in dedicated StanzaBuilder class
  val stanza = StanzaBuilder.productCard(product)
  ejabberdClient.sendStanza(from, to, stanza)

DON'T: Build XML strings manually scattered in code
  val xml = "<message><nexgate-commerce>..." ❌
  ← Error prone, hard to test, hard to maintain

✅ DO — Offer Expiry

DO: Use Redis TTL for expiry detection
  redis.set("offer:$offerId", offer, ttl = 30.minutes)
  // TTL fires → OfferExpiryListener → update DB

DON'T: Use scheduled DB queries
  @Scheduled(fixedRate = 60000)
  fun expireOffers() {
    db.query("SELECT * FROM offers WHERE expires_at < NOW()")
  }
  ← Hammers DB every minute ❌
  ← Redis TTL is instant and free ✅

✅ DO — Conversation Updates

DO: Update conversation in MessageConsumer
  MessageConsumer:
    1. Persist message
    2. Update conversation.last_message (DB)
    3. INCR unread count (Redis)
    4. Update conv metadata cache (Redis)

DON'T: Fetch conversation on every message render
  GET /chat/conversations → DB query every time ❌
  Serve from Redis cache ✅

✅ DO — JID Handling

DO: Always strip resource for storage
  Store: "alice@nexgate.com" (bare JID)
  Never: "alice@nexgate.com/android" (full JID)

  val bareJid = fullJid.substringBefore("/")

DON'T: Store full JID with resource
  Resource changes between sessions ❌
  Queries will break ❌

Exception: call routing (need specific device)
  Full JID used only for Jingle call signaling
  Not stored in DB

✅ DO — Commerce Stanzas

DO: Include <body> fallback in EVERY custom stanza
  <message>
    <body>Check out this product</body>  ← fallback
    <nexgate-commerce xmlns="urn:nexgate:commerce:1">
      ...
    </nexgate-commerce>
  </message>

DON'T: Send custom stanza without fallback
  <message>
    <nexgate-commerce xmlns="urn:nexgate:commerce:1">
      ...
    </nexgate-commerce>
  </message>
  ← Basic XMPP clients show nothing ❌
  ← Debugging nightmares ❌

✅ DO — Group Message Stanza ID

DO: Use stanza-id (room-assigned) for group references
  // Reacting to a group message:
  val stanzaId = message.getStanzaId()  // by="room@..."
  buildReaction(stanzaId, emoji)

DON'T: Use origin-id for group message references
  val originId = message.getOriginId()  // client-generated
  buildReaction(originId, emoji)  ← WRONG for groups ❌

Rule:
  1:1 chat → reactions/edits reference origin-id
  Group chat → reactions/edits reference stanza-id

✅ DO — Shop Staff Privacy

DO: Always strip staff resource from JID
  Outbound to customer:
    from = "shop-456@nexgate.com"  ✅
    (resource stripped by StanzaBuilder)

DON'T: Let staff JID leak to customer
    from = "shop-456@nexgate.com/staff-john"  ❌
    Customer now knows staff name ❌
    Privacy violation ❌

Implementation:
  StanzaBuilder always strips resource
  for any stanza from a shop account

✅ DO — Rate Limiting

DO: Rate limit at Redis level before processing
  fun sendMessage(userId: String, ...) {
    val key = "ratelimit:msg:$userId"
    val count = redis.incr(key)
    if (count == 1L) redis.expire(key, 60)
    if (count > 60) throw RateLimitException()
    // proceed with message
  }

DON'T: Rate limit at DB level
  val recentCount = db.query(
    "SELECT COUNT(*) FROM messages
     WHERE user_id = ? AND created_at > NOW() - INTERVAL '1 minute'"
  )
  ← DB query per message ❌
  ← Slow and expensive ❌

✅ DO — Typing Indicators

DO: Add no-store hint to typing stanzas
  <message type="chat">
    <composing xmlns="http://jabber.org/protocol/chatstates"/>
    <no-store xmlns="urn:xmpp:hints"/>  ← ALWAYS include
  </message>

DON'T: Let typing indicators get stored in MAM
  Without no-store: Ejabberd archives the typing indicator
  New device fetches history: sees "alice is typing..."
  from 3 weeks ago ← makes no sense ❌

✅ DO — Offline Message Handling

DO: Let Ejabberd handle offline queuing + Redis for FCM decision
  Ejabberd stores offline messages natively
  Spring Boot checks Redis for online status
  If offline → FCM via notification/

DON'T: Build your own offline queue in DB
  val offlineMessages = db.save(message)  ❌
  Duplicates Ejabberd's built-in offline storage ❌
  Two sources of truth ❌

✅ DO — Gzip Compression

DO: Enable Gzip on ALL REST responses
  # application.properties
  server.compression.enabled=true
  server.compression.min-response-size=1024
  server.compression.mime-types=application/json,application/xml

Result:
  Chat API responses: 25KB → 6KB ✅
  Inbox load on 2G: 1.25s → 0.3s ✅
  Zero code change required ✅

DON'T: Send uncompressed JSON to mobile on 2G
  25KB per inbox load × 10 loads/day
  = 250KB/day just for inbox
  On Tanzania 2G data plans: expensive ❌

Anti-Pattern — DB Poll for Events

NEVER DO THIS:
  @Scheduled(fixedRate = 1000)
  fun checkForNewMessages() {
    val messages = db.query(
      "SELECT * FROM messages WHERE delivered_at IS NULL"
    )
    messages.forEach { deliver(it) }
  }
  ← Polls DB every second ❌
  ← At scale: crushes DB ❌
  ← High latency (up to 1 second delay) ❌

DO THIS INSTEAD:
  RabbitMQ consumer:
    Ejabberd fires event → consumer reacts instantly ✅
    Zero polling ✅
    Sub-millisecond reaction time ✅

Anti-Pattern — N+1 Queries in Inbox

NEVER DO THIS:
  val conversations = db.getConversations(userId)
  conversations.forEach { conv ->
    conv.lastMessage = db.getLastMessage(conv.id)  // N queries ❌
    conv.unreadCount = db.getUnreadCount(conv.id)  // N queries ❌
    conv.memberNames = db.getMemberNames(conv.id)  // N queries ❌
  }
  50 conversations = 150 DB queries ❌

DO THIS INSTEAD:
  // Redis for unread counts (O(1) per key)
  val unreadCounts = redis.mget(convIds.map { "unread:$userId:$it" })

  // Single JOIN query for last messages
  val conversations = db.query("""
    SELECT c.*, m.body as last_body, m.created_at as last_at
    FROM chat.conversations c
    LEFT JOIN chat.messages m ON m.id = c.last_message_id
    WHERE c.user_id = ?
    ORDER BY c.updated_at DESC
    LIMIT 50
  """)

  // Profiles from Redis
  val profiles = redis.mget(userIds.map { "profile:$it" })

Anti-Pattern — Blocking on Ejabberd REST

NEVER DO THIS (in request path):
  fun sendMessage(req: MessageRequest): Response {
    val result = ejabberdClient.sendMessage(...)  // HTTP call
    db.saveMessage(...)
    return Response.ok()
  }
  ← HTTP call in request path ❌
  ← If Ejabberd slow → user waits ❌
  ← If Ejabberd down → request fails ❌

DO THIS INSTEAD:
  fun sendMessage(req: MessageRequest): Response {
    // Validate + queue immediately
    val messageId = db.saveOutgoing(req)
    rabbitMQ.publish("chat.outbound", req)
    return Response.ok(messageId)  // instant ✅
  }

  // Async consumer sends to Ejabberd
  @RabbitListener(queues = "chat.outbound")
  fun sendToEjabberd(req: MessageRequest) {
    ejabberdClient.sendMessage(...)  // in background ✅
  }

22. Package Structure

Full chat/ Package

chat/
  ├── config/
  │     EjabberdConfig.java        ← Ejabberd connection settings
  │     RabbitMQChatConfig.java    ← queues, exchanges, bindings
  │     LiveKitConfig.java         ← LiveKit server settings
  │     CoturnConfig.java          ← TURN server settings
  │
  ├── controller/
  │     ConversationController.java  ← GET /chat/conversations
  │     MessageController.java       ← GET /chat/messages
  │     GroupController.java         ← POST /chat/groups/create
  │     CallController.java          ← GET /chat/calls/turn-credentials
  │     ReceiptController.java       ← POST /chat/messages/{id}/receipt
  │
  ├── service/
  │     ConversationService.java     ← conversation lifecycle
  │     MessageService.java          ← persist, query messages
  │     GroupService.java            ← group management
  │     OfferSessionService.java     ← offer lifecycle
  │     CallService.java             ← call records, TURN creds
  │     PresenceService.java         ← online status tracking
  │
  ├── consumer/
  │     MessageConsumer.java         ← handles chat.message.*
  │     PresenceConsumer.java        ← handles chat.presence.*
  │     CallConsumer.java            ← handles chat.call.*
  │     RoomConsumer.java            ← handles chat.room.*
  │
  ├── ejabberd/
  │     EjabberdRestClient.java      ← HTTP client for Ejabberd API
  │     EjabberdAuthController.java  ← POST /internal/ejabberd/auth
  │     JwksController.java          ← GET /auth/.well-known/jwks.json
  │     XmppTokenService.java        ← generate/validate XMPP JWT
  │
  ├── stanza/
  │     StanzaBuilder.java           ← builds XMPP stanzas
  │     CommerceStanza.java          ← product card, offer stanzas
  │     GroupBuyStanza.java          ← bei ya pamoja stanzas
  │     SystemStanza.java            ← order update stanzas
  │     CallStanza.java              ← group call join stanzas
  │     MediaStanza.java             ← file/image/voice stanzas
  │
  ├── commerce/
  │     CommerceDmService.java       ← initiate commerce DM
  │     OfferLifecycleService.java   ← offer state machine
  │     GroupBuyService.java         ← bei ya pamoja flows
  │     ShopInboxService.java        ← shop routing, staff access
  │
  └── call/
        TurnCredentialService.java   ← HMAC TURN credentials
        LiveKitService.java          ← group call room management
        CallRecordService.java       ← persist call records

23. Infrastructure Stack

Docker Containers (Local Development)

Container             Port(s)          Purpose
──────────────────────────────────────────────────────────────
ejabberd              5222, 5280       XMPP server
coturn                3478             STUN/TURN relay
livekit               7880, 7881       Group calls + Audio Spaces
nexgate_postgres      5432             Main platform DB
ft_postgres           5433             File Thunder DB
ejabberd_postgres     5434             Ejabberd DB (production)
rabbitmq              5672, 15672      Message queue + management
redis                 6379             Cache, sessions, rate limits
minio                 9000, 9001       Object storage
ft_clamav             3310             Virus scanning

Local processes:
  nexgate_backend     8080             Spring Boot (all-in-one)
  file_thunder        8084             Media engine

Ejabberd Configuration Summary

ejabberd.yml key settings:
  hosts: ["nexgate.com"]
  auth_method: jwt
  jwt_key: https://api.nexgate.com/auth/.well-known/jwks.json

  listen:
    port 5222: ejabberd_c2s (XMPP + TLS)
    port 5280: ejabberd_http (/api, /admin, /oauth, /ws)

  modules:
    mod_muc: group chats
    mod_mam: message archive
    mod_offline: offline message storage
    mod_ping: keepalive
    mod_stream_mgmt: XEP-0198 reliability
    mod_rabbitmq: event publishing

  default_db: sql (production)
  sql_type: pgsql
  sql_server: "ejabberd_postgres"
  sql_database: "ejabberd"

Ejabberd ↔ Spring Boot Security

Channel 1 (Spring Boot → Ejabberd REST):
  Admin credentials in HashiCorp Vault
  Internal Docker network only
  Traefik blocks external access to 5280

Channel 2 (Ejabberd → Spring Boot via RabbitMQ):
  RabbitMQ credentials in Vault
  Internal Docker network only
  AMQP not exposed externally

Channel 3 (Mobile → Ejabberd JWT):
  JWT signed RS256 (private key in Vault)
  Public key via JWKS endpoint (HTTPS only)
  XMPP port 5222 with TLS
  JWT expires 24 hours

24. Build Order

Week 1 — Foundation

Day 1-2: JWT Auth Infrastructure
  EjabberdAuthController.java
    POST /internal/ejabberd/auth
    Validate XMPP JWT, return 200/401

  JwksController.java
    GET /auth/.well-known/jwks.json
    Return RSA public key

  XmppTokenService.java
    Generate XMPP JWT on login
    Add to auth/LoginResponse

  Configure Ejabberd:
    auth_method: jwt
    jwt_key: JWKS URL

  Test: Dino/Gajim connects via JWT ✅

Day 3-4: RabbitMQ Event Pipeline
  RabbitMQChatConfig.java
    Declare exchange, queues, bindings

  MessageConsumer.java
    Listen to chat.message.inbound
    Persist to chat.messages

  PresenceConsumer.java
    Listen to chat.presence.*
    Update last_seen

  Test: Send message in Gajim → appears in DB ✅

Day 5: Conversation Management
  ConversationService.java
    Find or create conversation
    Update last_message

  ConversationController.java
    GET /chat/conversations (inbox)
    GET /chat/conversations/{id}/messages

Week 2 — Core Messaging

Day 1-2: Message Interactions
  Handle edit stanzas (XEP-0308)
  Handle retract stanzas (XEP-0424)
  Handle reaction stanzas (XEP-0444)
  Handle reply stanzas (XEP-0461)
  MessageService.java for each

Day 3: Group Chat
  GroupController.java
    POST /chat/groups/create
    POST /chat/groups/{id}/join
    POST /chat/groups/{id}/decline
    DELETE /chat/groups/{id}/leave

  GroupService.java
    Ejabberd REST: create_room, add_member
    Invite link management

Day 4-5: Receipts & Notifications
  ReceiptController.java
    POST /chat/messages/{id}/receipt
    { status: DELIVERED|READ }

  Offline push:
    MessageConsumer → detect offline
    Call notification/ package
    FCM push ✅

Week 3 — Commerce DMs

Day 1-2: Commerce Stanza Sending
  EjabberdRestClient.java
    sendStanza(from, to, body, customElement)

  StanzaBuilder.java
    buildProductCard(product)
    buildOfferCard(offer)
    buildSystemMessage(order)

  CommerceDmService.java
    initiateCommerceDm(productId, shopId, buyerId)

Day 3-4: Offer Session Lifecycle
  OfferLifecycleService.java
    createOffer(...)
    acceptOffer(offerId)
    declineOffer(offerId)
    expireOffers() ← scheduled job

Day 5: Shop Inbox
  ShopInboxService.java
    Route to available staff
    Strip staff resource from JID
    Audit log staff actions

Week 4 — Calls

Day 1-2: TURN Credentials
  TurnCredentialService.java
    Generate HMAC-SHA256 credentials
    Time-limited (1 hour)

  CallController.java
    GET /chat/calls/turn-credentials

Day 3-4: Group Calls (LiveKit)
  LiveKitService.java
    createRoom(callId)
    generateToken(userId, roomId)

  CallStanza.java
    buildGroupCallJoinInfo(callId, token, url)

  Spring Boot sends join info stanza
  Members receive → join LiveKit room

Day 5: Call Records
  CallRecordService.java
    onCallInitiated(event)
    onCallEnded(event)
    getCallHistory(userId)

Week 5 — Polish & Testing

  Message search (MAM queries to Ejabberd)
  Read count for group messages
  Block/unblock users
  Message request system
  Integration testing with Gajim/Dino
  Load testing with sendxmpp
  Documentation for mobile dev team

NexGate Chat System — Development Architecture Guide v4.0 QBIT SPARK | chat_system · Spring Boot · Ejabberd · RabbitMQ · Redis · WebRTC · Coturn · LiveKit · OMEMO


Revision #5
Created 17 July 2026 09:53:35 by Admin Qbit
Updated 17 July 2026 17:23:36 by Admin Qbit