NexGate Chat — Phase 1 Foundation & Local Experiments NexGate / QBIT SPARK | Version 1.0 Spring Boot WebSocket · Commerce DMs · Offline Delivery · Local Experiments Table of Contents Phase 1 Goals What Ships in Phase 1 Architecture Overview The Four Wheels Service Design Data Flows Commerce DM Flows Offline Delivery & Notification Escalation Message Status System Database Schema Inbox Model Implementation Local Experiments 1. Phase 1 Goals Phase 1 is about shipping fast and learning. The goal is not to build the perfect infrastructure from day one. The goal is to get NexGate chat into users' hands as quickly as possible — while running local experiments in parallel that prepare for Phase 2. Two tracks running simultaneously: Track A — Production (ship it): Track B — Experiments (learn it): Spring Boot WebSocket Ejabberd local Docker setup Text chat + voice notes Spring Boot ↔ Ejabberd auth bridge Commerce DMs (both flows) WebRTC voice call on emulators Offline delivery + notifications Coturn TURN relay local test Message receipts MessagePack encoding test Isolated shop inbox Phase 1 architecture is intentionally simpler than Phase 2. Everything designed here carries forward — the schema, the commerce logic, the notification system, the inbox model. Phase 2 only swaps the transport layer. 2. What Ships in Phase 1 Messaging Features 1:1 Personal DMs text, voice notes, media cards Group chats up to 500 members Broadcast channels creator → fans (one-way) Voice notes Opus recorded, waveform rendered Rich content cards product, custom price, event, Bei ya pamoja, post, stream link Commerce DM Features Buyer initiates chat from product page → product card auto-appears Seller attaches product WhatsApp-style from inside any conversation Custom price offer private to buyer, public price unchanged Proceed to checkout button in chat → redirects to checkout Order updates in thread confirmation, shipping, delivery Inbox Features Isolated shop inbox separate tab per shop Personal inbox always private, owner only Shop inbox shared with authorized staff (Pro tier) Message receipts sent / delivered / read ticks Typing indicators ephemeral, Redis TTL based Online presence shown in conversation header Notification Features FCM push (Android + iOS) HIGH priority, bypasses Doze mode Textfy SMS escalation CRITICAL and IMPORTANT messages Offline queue RabbitMQ holds messages until delivery Catch-up banner summary on reconnect after offline 3. Architecture Overview ┌─────────────────────────────────────────────────┐ │ NexGate Mobile App │ └──────────────┬──────────────────────────────────┘ │ │ WebSocket (JSON over WS) │ wss://chat.nexgate.com │ ┌──────────────▼──────────────────────────────────┐ │ Spring Boot Chat Gateway │ │ │ │ Manages WebSocket connections │ │ Validates JWT on connect │ │ Routes incoming frames via Redis pub/sub │ │ Pushes outgoing frames to clients │ │ Registers presence in Redis │ │ Thin — zero business logic │ └──────────────┬──────────────────────────────────┘ │ │ Redis pub/sub │ ┌──────────────▼──────────────────────────────────┐ │ Spring Boot Chat Service │ │ │ │ Message validation + persistence │ │ Conversation + inbox management │ │ Commerce context handling │ │ Receipt tracking │ │ Notification routing │ │ Offline escalation │ │ Shop inbox role enforcement │ └──────┬──────────┬──────────┬────────────────────┘ │ │ │ ▼ ▼ ▼ ┌──────────┐ ┌────────┐ ┌───────────────────────┐ │PostgreSQL│ │ Redis │ │ RabbitMQ │ │ │ │ │ │ │ │messages │ │presence│ │ chat.offline.delivery │ │convs │ │hot msgs│ │ chat.notify.push │ │receipts │ │typing │ │ chat.notify.escalation│ │calls │ │pub/sub │ │ chat.commerce.events │ └──────────┘ └────────┘ └───────────┬───────────┘ │ ┌────────────┼────────────┐ ▼ ▼ ▼ ┌─────┐ ┌───────┐ ┌────────┐ │ FCM │ │Textfy │ │ Main │ │push │ │ SMS │ │Backend │ └─────┘ └───────┘ └────────┘ Key Rule Gateway never touches business logic Chat Service never manages WS connections Both communicate only via Redis pub/sub Gateway → Redis pub/sub → Chat Service (inbound) Chat Service → Redis pub/sub → Gateway (outbound) 4. The Four Wheels Just as File Thunder has four processing wheels, the Phase 1 chat engine has four foundational components: ┌─────────────────────────────────────────────────┐ │ NEXGATE CHAT ENGINE │ │ │ │ ┌──────────┐ ┌──────────┐ ┌────────────┐ │ │ │ Wheel 1 │ │ Wheel 2 │ │ Wheel 3 │ │ │ │ │ │ │ │ │ │ │ │ Netty │ │ Redis │ │ RabbitMQ │ │ │ │ WS │ │ State │ │ Queue │ │ │ └──────────┘ └──────────┘ └────────────┘ │ │ │ │ ┌────────────┐ │ │ │ Wheel 4 │ │ │ │ │ │ │ │ Textfy │ │ │ │ SMS │ │ │ └────────────┘ │ └─────────────────────────────────────────────────┘ Wheel 1 — Netty WebSocket Spring Boot uses Netty under the hood for WebSocket connections. Handles connect, disconnect, heartbeat, and frame routing. Scales to 50k+ concurrent connections per pod. Wheel 2 — Redis State Tracks everything ephemeral and hot: online presence per user, typing indicators (5s TTL), last 50 messages per conversation (capped list), unread counts, cross-pod pub/sub routing, notification escalation timers. Wheel 3 — RabbitMQ Queue Handles everything async: offline message delivery queue, delayed SMS escalation jobs, commerce event publishing to Main Backend, receipt acknowledgment processing. Wheel 4 — Textfy SMS Critical and important message fallback. NexGate's own SMS platform — zero third-party cost. Swahili templates per message type. Deep links back to specific conversation. Full delivery audit log. 5. Service Design Chat Gateway Responsibilities On WebSocket connect: Validate JWT token Register user presence in Redis: presence:{userId} → TTL 30s (refreshed by heartbeat) Drain offline queue via RabbitMQ trigger On frame received (inbound): Validate session Publish to Redis: chat:inbound ACK client immediately with temp_id On Redis pub/sub message (outbound): Find client connection for recipient Push WS frame to device On WebSocket disconnect: Remove presence from Redis Update last_seen_at via RabbitMQ event Chat Service Responsibilities On inbound message event (from Redis): Validate sender is conversation member Check conversation not blocked/archived Resolve message level (NORMAL / IMPORTANT / CRITICAL) Write to PostgreSQL Write to Redis hot cache Fan-out to recipients: Online → Redis pub/sub → Gateway → WS push Offline → RabbitMQ queue + FCM + escalation timer On commerce message: Attach product snapshot (frozen at send time) Emit commerce event to Main Backend via RabbitMQ On receipt ack: Update message_receipts in PostgreSQL Notify sender (tick update) via Redis pub/sub On presence event (user online): Drain RabbitMQ offline queue for user Send catch-up summary if messages missed RabbitMQ Exchange Design Exchange: nexgate.chat (topic) Routing Key Flow ────────────────────────────────────────────────────── chat.message.inbound Gateway → Chat Service chat.message.outbound Chat Service → Gateway chat.notify.push Chat Service → FCM Worker chat.notify.escalation Chat Service → SMS Worker chat.receipts.delivered Gateway → Chat Service chat.receipts.read App → Chat Service chat.commerce.initiated Chat Service → Main Backend chat.commerce.price.attached Chat Service → Main Backend chat.presence.online Gateway → Chat Service chat.presence.offline Gateway → Chat Service 6. Data Flows Text Message — Full Sequence [Client A] [Gateway] [Chat Service] [Client B] type "Habari" tap Send show pending ⏳ │ │ WS frame: │ { type: MSG_SEND │ temp_id: "abc" │ conv_id: "conv-123" │ body: "Habari" } │──────────────────▶│ │ publish Redis: │ chat:inbound │ ACK: { temp_id: "abc" } │◀────────────────────── │ single tick ✓ ◀───│ │ ...................▶ │ consume Redis │ validate sender │ write PostgreSQL │ write Redis cache │ check B online? ✅ │ publish outbound │◀ .................. │ │ push WS to B │─────────────────────────────────────▶│ │ │ show message │◀ ......................... DELIVERED ack ──│ │ write receipt │ notify A: │ double tick ✓✓ ◀──│◀ ................. │ . │ [B reads conv] . │◀─────── READ ack ────────────────────│ │ write receipt │ blue tick ✓✓ ◀────│◀ ................. │ ─── solid line = WebSocket (real-time) ... dotted line = Redis pub/sub (async, cross-pod) Voice Note — Send Flow User records audio (Opus codec, 16kHz) │ ▼ Upload to File Thunder GET /media/upload-request (DM_ATTACHMENT context) → presigned MinIO URL returned Upload .ogg file directly to MinIO POST /media/confirm { fileId } │ ▼ File Thunder processes: ClamAV scan Waveform extraction via FFmpeg → amplitude array (50 values for UI bars) → waveform.webp thumbnail Store in nexgate-private/messages/{convId}/{fileId}/ │ ▼ File Thunder returns: { fileId, waveformData[], durationSeconds } │ App sends WS frame: { type: MSG_SEND content_type: VOICE_NOTE media_ref: fileId duration_seconds: 15 waveform: [0.2, 0.8, 0.6, ...] } ← embedded for instant UI │ ▼ Same path as text message Recipient receives message with waveform data Waveform bars render instantly (no extra request) Tap play → GET /chat/media/{fileId}/url → signed URL (5 min TTL) → stream audio progressively Offline Delivery Flow Message arrives for offline user B │ Chat Service: Check Redis: B online? ❌ │ ├──▶ RabbitMQ: chat.offline.delivery │ { messageId, recipientId, level } │ ├──▶ FCM HIGH priority push │ { type: NEW_MESSAGE │ convId, senderName, preview } │ └──▶ RabbitMQ: chat.notify.escalation delay: CRITICAL=0min, IMPORTANT=10min . . (time passes) . Escalation consumer wakes: Check Redis: B online now? ✅ → cancel, done ❌ → send Textfy SMS │ Textfy SMS: "NexGate: Ujumbe mpya kutoka Juma. nexgate.app/chat/conv-123" │ User turns on WiFi / opens app: WS reconnects → Gateway registers presence Chat Service drains RabbitMQ queue (priority order) CRITICAL first → IMPORTANT → NORMAL Show catch-up banner: "Umekosa: 2 maagizo, 5 ujumbe" DELIVERED receipts fire for all drained messages 7. Commerce DM Flows Flow 1 — Buyer Initiates from Product Page Buyer on product page │ taps "Chat with Seller" ▼ POST /chat/commerce/initiate { productId, shopId } │ Chat Service: Find or create DM conversation conversation.type = COMMERCE conversation.owner_type = SHOP conversation.owner_id = shopId │ Fetch product snapshot from Main Backend: { name, price, images[0], stock, shopName } Snapshot frozen at this exact moment ✅ Public price change later → does not affect this card │ Create first message automatically: type: PRODUCT_CARD context_type: PRODUCT context_ref_id: productId snapshot_json: { frozen product data } │ ▼ Seller receives in shop inbox tab ┌─────────────────────────────────┐ │ 📦 Samsung A15 │ │ TZS 450,000 │ │ In stock: 12 units │ │ TechStore │ │ │ │ [Reply] [View Product] │ └─────────────────────────────────┘ │ Negotiation happens in thread │ Agreement reached ▼ Seller attaches custom price offer: ┌─────────────────────────────────┐ │ 💰 Special Price Offer │ │ Samsung A15 │ │ TZS 400,000 (was 450,000) │ │ Valid for you only │ │ │ │ Quantity: [─ 1 +] │ │ [Proceed to Checkout →] │ └─────────────────────────────────┘ │ Buyer taps Proceed → redirected to checkout Checkout outside inbox — at negotiated price Public product price: TZS 450,000 unchanged ✅ │ Order placed → confirmation back in thread: ┌─────────────────────────────────┐ │ ✅ Order Confirmed │ │ Order #ORD-789 │ │ Samsung A15 × 1 │ │ TZS 400,000 paid │ └─────────────────────────────────┘ Flow 2 — Seller Attaches from Inside Chat Seller inside any conversation │ taps attach (+) ▼ ┌─────────────────────────────────┐ │ Attach │ │ │ │ 📷 Image │ │ 🎵 Voice Note │ │ 📄 File │ │ 🏪 From My Shop ◀──── this one │ │ 📅 Event │ │ 👥 Group Purchase │ └─────────────────────────────────┘ │ taps "From My Shop" ▼ Seller browses their shop products Picks product Sets custom price for this buyer (optional) │ ▼ Sends as card in chat ┌─────────────────────────────────┐ │ 🏪 TechStore Offer │ │ Samsung A15 │ │ TZS 400,000 │ │ │ │ Quantity: [─ 1 +] │ │ [Proceed to Checkout →] │ └─────────────────────────────────┘ │ Buyer taps Proceed → checkout outside inbox Commerce Message Types message.type values for commerce: PRODUCT_CARD product shared in chat CUSTOM_PRICE_OFFER seller's private price for this buyer EVENT_CARD event shared in chat GROUP_PURCHASE_CARD Bei ya pamoja shared in chat POST_CARD VP Feed post shared in chat ORDER_CONFIRMATION system message after order placed ORDER_STATUS_UPDATE system message for shipping/delivery PAYMENT_CONFIRMATION system message after payment 8. Offline Delivery & Notification Escalation Notification Levels Message level resolved by Chat Service before fan-out: CRITICAL: Order placed / payment received / payment failed Order status changed / delivery update → FCM HIGH + Textfy SMS simultaneously (no waiting) IMPORTANT: Commerce DM from buyer Custom price offer received Bei ya pamoja threshold reached → FCM HIGH immediately → Textfy SMS after 10 minutes if no delivery ack NORMAL: Regular DMs, group messages Social cards, post shares → FCM HIGH only → No SMS escalation Textfy SMS Templates Order notification (Swahili): "NexGate: Agizo jipya kutoka [Buyer]! Kiasi: TZS [amount]. Kagua: nexgate.app/orders/[id]" Payment received: "NexGate: Malipo ya TZS [amount] yamepokelewa kutoka [Buyer]. nexgate.app/wallet" Commerce DM: "NexGate: [Buyer] anakuuliza kuhusu [Product]. Jibu: nexgate.app/chat/[convId]" Bei ya pamoja: "NexGate: Watu [n]/[target] wamejiunga! nexgate.app/group-buy/[id]" Notification Delivery Log All notifications tracked for audit — critical for order disputes: notification_log ───────────────────────────────────── id UUID user_id UUID message_id UUID level ENUM (NORMAL/IMPORTANT/CRITICAL) fcm_status ENUM (SENT/DELIVERED/FAILED) sms_status ENUM (SENT/DELIVERED/FAILED/SKIPPED) sms_provider TEXT sent_at TIMESTAMPTZ delivered_at TIMESTAMPTZ opened_at TIMESTAMPTZ 9. Message Status System Client shows status via tick indicators: ⏳ Pending message on device, not yet sent (no connection) ✓ Sent server received and persisted Gateway ACK returned with temp_id ✓✓ Delivered recipient device received WS delivery ack received by Chat Service ✓✓ Read recipient opened the conversation READ event sent by recipient app (blue ticks) Flow: [Send] → pending ⏳ [Gateway ACK] → sent ✓ [Recipient WS ack] → delivered ✓✓ [Recipient opens conv] → read ✓✓ (blue) Typing Indicators Ephemeral — never persisted to PostgreSQL User starts typing: App sends: { type: TYPING_START, convId } Chat Service sets Redis key: typing:{convId}:{userId} → TTL 5 seconds Redis pub/sub notifies conversation members Recipients see "Juma anaandika..." User stops typing (or TTL expires): Key auto-expires after 5 seconds Chat Service notifies: typing stopped Indicator disappears 10. Database Schema conversations conversations ───────────────────────────────────────────── id UUID PK type ENUM DM / GROUP / BROADCAST / COMMERCE owner_type ENUM USER / SHOP owner_id UUID userId or shopId title TEXT for groups and broadcast channels avatar_file_id UUID File Thunder fileId status ENUM ACTIVE / ARCHIVED / BLOCKED created_by UUID userId created_at TIMESTAMPTZ conversation_members conversation_members ───────────────────────────────────────────── conversation_id UUID FK → conversations user_id UUID role ENUM MEMBER / ADMIN / OWNER joined_at TIMESTAMPTZ last_read_at TIMESTAMPTZ last_read_seq BIGINT sequence ID (for gap detection) is_muted BOOLEAN muted_until TIMESTAMPTZ messages messages ───────────────────────────────────────────── id UUID PK conversation_id UUID FK → conversations sender_id UUID seq BIGINT monotonic per conversation type ENUM TEXT / IMAGE / VIDEO / VOICE_NOTE / FILE / PRODUCT_CARD / CUSTOM_PRICE_OFFER / EVENT_CARD / GROUP_PURCHASE_CARD / POST_CARD / ORDER_CONFIRMATION / ORDER_STATUS_UPDATE / PAYMENT_CONFIRMATION / SYSTEM body TEXT media_ref UUID File Thunder fileId context_type ENUM PRODUCT / ORDER / PAYMENT / EVENT / GROUP_PURCHASE context_ref_id UUID ref to relevant entity snapshot_json JSONB frozen context data at send time reply_to_id UUID FK → messages (thread replies) status ENUM SENT / DELIVERED / READ / FAILED level ENUM NORMAL / IMPORTANT / CRITICAL created_at TIMESTAMPTZ edited_at TIMESTAMPTZ deleted_at TIMESTAMPTZ message_receipts message_receipts ───────────────────────────────────────────── message_id UUID FK → messages user_id UUID status ENUM DELIVERED / READ device_id TEXT timestamp TIMESTAMPTZ calls calls ───────────────────────────────────────────── call_id UUID PK caller_id UUID receiver_id UUID conversation_id UUID FK → conversations type ENUM VOICE / VIDEO status ENUM RINGING / CONNECTED / COMPLETED / MISSED / DECLINED / FAILED started_at TIMESTAMPTZ answered_at TIMESTAMPTZ ended_at TIMESTAMPTZ duration_seconds INT relay_used BOOLEAN was TURN relay used? end_reason ENUM NORMAL / NETWORK / TIMEOUT / DECLINED shop_conversation_access shop_conversation_access ───────────────────────────────────────────── shop_id UUID user_id UUID staff member role ENUM MANAGER / SUPPORT_AGENT / READ_ONLY granted_by UUID owner userId granted_at TIMESTAMPTZ revoked_at TIMESTAMPTZ 11. Inbox Model Implementation Conversation Ownership Personal DM: owner_type = USER owner_id = usr-kibuti Access: only usr-kibuti Shop DM: owner_type = SHOP owner_id = shop-techstore Access: anyone with role in shop_conversation_access for shop-techstore Tab Resolution (App Side) App fetches inbox tabs on load: GET /chat/inbox/tabs Response: [ { type: PERSONAL, label: "Personal", unread: 3 }, { type: SHOP, shopId: "shop-techstore", label: "TechStore", unread: 12 }, { type: SHOP, shopId: "shop-clothinghub", label: "ClothingHub", unread: 0 } ] Chat Service resolves tabs by: 1. User's own personal conversations 2. All shops where user has role in shop_conversation_access Access Control Check Every request to open a shop conversation: Does requesting user own the shop? YES → allow NO → check shop_conversation_access: user_id = requester shop_id = conversation.owner_id revoked_at IS NULL Found? → allow with their role Not found? → 403 Forbidden 12. Local Experiments These run in parallel with Phase 1 production work. All throwaway code — not NexGate quality. Goal is understanding, not production output. Experiment 1 — Ejabberd Local Docker Goal: get Ejabberd running, send one message Steps: docker run -d --name ejabberd \ -p 5222:5222 \ -p 5280:5280 \ -p 5285:5285 \ ghcr.io/processone/ejabberd Open: http://localhost:5280/admin Create two test users: alice, bob Install Conversations app on Android Connect to localhost:5222 as alice Send message to bob See it arrive What you learn: How Ejabberd config works What XMPP stanzas look like in logs How the dashboard shows connections What errors look like and how to fix them Success: message delivered between two test users XMPP Stanzas — What They Look Like When Alice sends "Habari" to Bob, this travels over the wire: Habari available Ninafanya kazi These stanzas are what Ejabberd routes. In Phase 2, NexGate wraps its own data inside custom XMPP stanzas. Experiment 2 — Spring Boot Auth Bridge Goal: Ejabberd calls Spring Boot to validate users Setup: Simple Spring Boot app (H2 in-memory DB) One endpoint: POST /internal/ejabberd/auth receives: { username, token } returns: 200 (allow) or 401 (deny) ejabberd.yml: auth_method: http auth_opts: url: "http://host.docker.internal:8080/internal/ejabberd/auth" Test: Connect Conversations app to Ejabberd Ejabberd calls Spring Boot for auth Spring Boot validates → returns 200 Connection allowed What you learn: Auth flow between Ejabberd and Spring Boot How fast Spring Boot must respond (< 200ms) What happens when auth fails How to structure the internal endpoint Success: Ejabberd rejects unknown users, allows users Spring Boot approves Experiment 3 — WebRTC Voice Call on Emulators Goal: voice call between two Android emulators Setup: Two Android Studio emulators running Simple Android app — just two buttons: [Call] and [Answer] WebRTC library: io.getstream:stream-webrtc-android No UI — just logcat output What to test: Create PeerConnection on both Exchange SDP offer/answer manually (copy-paste between logs) Exchange ICE candidates Hear audio between emulators What you learn: How WebRTC PeerConnection works in practice What SDP looks like What ICE candidates look like How long negotiation actually takes What errors appear and how to fix them Success: audio heard between two emulators SDP offer example (what WebRTC generates): v=0 o=- 123456 2 IN IP4 127.0.0.1 s=- t=0 0 a=group:BUNDLE 0 m=audio 9 UDP/TLS/RTP/SAVPF 111 c=IN IP4 0.0.0.0 a=rtcp:9 IN IP4 0.0.0.0 a=ice-ufrag:someRandomString a=ice-pwd:anotherRandomString a=fingerprint:sha-256 AA:BB:CC:... a=rtpmap:111 opus/48000/2 ← Opus codec negotiated here a=fmtp:111 minptime=10;useinbandfec=1 Experiment 4 — Coturn TURN Relay Goal: force audio through TURN relay, confirm it works Setup: docker run -d --network=host coturn/coturn \ -n --log-file=stdout \ --min-port=49152 --max-port=65535 \ --lt-cred-mech \ --user=test:password123 \ --realm=nexgate.com Test: Disable P2P in WebRTC config (force TURN only) Run voice call experiment Confirm audio still flows through relay What you learn: How Coturn logs relay connections Bandwidth used per call (check with iftop) How to generate HMAC credentials (not plain text) What happens when Coturn is unavailable Success: audio heard between emulators via TURN relay Coturn logs show relay traffic Experiment 5 — MessagePack Encoding Goal: compare JSON vs MessagePack on same message Simple Spring Boot test: Serialize same chat message object Once as JSON Once as MessagePack Measure: Byte size comparison Serialization speed Deserialization speed Expected result: JSON: ~180-200 bytes per message MessagePack: ~60-70 bytes per message ~65% size reduction confirmed Success: numbers prove EA bandwidth saving is real Experiment Success Criteria Summary Experiment 1 — Ejabberd local: ✅ Message delivered between two users ✅ Dashboard shows live connections Experiment 2 — Auth bridge: ✅ Ejabberd rejects unknown tokens ✅ Ejabberd allows Spring Boot approved users Experiment 3 — WebRTC emulators: ✅ Audio heard between two emulators ✅ SDP and ICE flow understood Experiment 4 — Coturn relay: ✅ Audio heard via forced TURN relay ✅ Bandwidth per call measured Experiment 5 — MessagePack: ✅ Size reduction confirmed ✅ Serialization speed measured All five done → ready to write Phase 2 doc → ready to build NexGate chat Phase 2 Summary Phase 1 is the foundation. It ships real features — text chat, voice notes, commerce DMs, offline delivery, the isolated shop inbox — using a clean Spring Boot WebSocket architecture that runs on existing infrastructure. Everything designed here carries directly into Phase 2. The schema stays. The commerce logic stays. The notification system stays. The inbox model stays. Only the transport layer swaps — Spring Boot WS gateway out, Ejabberd in. The five local experiments running in parallel are not wasted time. They are the insurance policy that makes Phase 2 a confident execution rather than a risky exploration. Every surprise Ejabberd, WebRTC, and Coturn have in store — you want to find them in a throwaway experiment, not in your production chat system. NexGate Chat Platform — Phase 1: Foundation & Local Experiments v1.0 QBIT SPARK | Spring Boot WebSocket · Commerce DMs · Offline Delivery