Skip to content

012 — Real-Time and Messaging

Four related capabilities share one transport: live chat, presence, in-app notifications, and ERP Back Office list auto-refresh.


Transport Overview

flowchart TB
    subgraph P["Portal browser"]
        WSC["Shared WebSocket client<br/>ref-counted channels"]
        CS["chat.store"]
        NS["notifications.store"]
    end

    subgraph O["Odoo"]
        WS["/websocket endpoint"]
        BUS["bus.bus"]
        MODELS["models emit _sendone(...)"]
        HTTP["REST presence endpoints"]
        CRON["cron: stale presence sweep"]
    end

    WSC <-->|frames| WS
    WS <--> BUS
    MODELS --> BUS
    WSC --> CS
    WSC --> NS
    P -->|"ping / offline"| HTTP
    HTTP --> BUS
    CRON --> BUS

One socket per browser tab. Channels are subscribed with reference counting so chat and notifications coexist without duplicate connections.

Connection protocol — the non-obvious constraint

sequenceDiagram
    participant C as Portal client
    participant G as Odoo.sh WS gateway
    participant O as Odoo bus

    C->>G: WebSocket open
    C->>G: {"event_name":"subscribe", channels:[...]}
    Note over C,G: subscribe MUST be the first frame
    G->>O: session initialised
    C->>G: update_presence (only now)
    O-->>C: relayed bus messages

    rect rgb(255,240,240)
        Note over C,G: Sending update_presence before subscribe →<br/>gateway closes with code 4001 (SESSION_EXPIRED) →<br/>endless reconnect loop
    end

The client enforces this by running syncSubscription() on connect before any other handler fires.


Live Chat

Structure

flowchart LR
    CC["res.partner<br/>customer company"] -->|company_channel_id| CH["discuss.channel"]
    CH --> M1["discuss.channel.member<br/>portal users"]
    CH --> M2["discuss.channel.member<br/>account manager"]
    CH --> MSG["mail.message"]

Each customer company owns one channel, created on demand. Portal users of that company and the company's account manager are members.

Customer-side flow

sequenceDiagram
    actor U as Customer user
    participant P as Customer Portal
    participant API as /ecommerce/api/chat/*
    participant CH as discuss.channel
    participant WS as WebSocket

    U->>P: open chat
    P->>API: init → channel + history + unread count
    P->>WS: subscribe to the channel's string channel
    U->>P: type message
    P->>API: send
    API->>CH: message_post
    CH->>WS: broadcast to subscribers
    WS-->>P: new message frame
    P->>API: mark-read (moves the seen pointer)

Account-manager inbox

The AMP inbox lists one thread per managed customer company — derived from account_manager_user_id, so an account manager only ever sees their own customers. Each row carries the last message, an unread count and the counterpart's presence. Threads support init, send and mark-read.

Why messages are mirrored

Odoo normally broadcasts new channel messages on a record channel. The Odoo.sh WebSocket gateway only relays literal string channels to non-Odoo-web-client sockets, so record channels never reach the Next.js portals there. The channel model therefore mirrors every new message onto a string channel keyed by the channel UUID, which both portals subscribe to.

Consequence: on-premises deployments receive each message twice (record channel plus mirror). Clients deduplicate by message id. Do not "fix" the duplicate by removing the mirror.

Unread counting

Unread is computed from the member's seen pointer: messages newer than the last seen message, excluding the member's own messages and system notifications. A member who has never opened the channel only counts messages posted after they joined — so a new joiner does not see the entire history reported as unread.


Presence

Presence has three inputs because no single one is reliable on Odoo.sh:

flowchart TB
    A["WebSocket update_presence<br/>(after subscribe)"] --> P["mail.presence"]
    B["HTTP POST /presence/ping<br/>heartbeat"] --> P
    C["HTTP POST /presence/offline<br/>on page hide / logout"] --> P
    D["cron every 1 min<br/>stale last_poll → offline"] --> P
    P --> BUS["bus broadcast"]
    BUS --> UI["chat UI · Online Users list"]
Problem Handling
Gateway swallows socket-close events Explicit HTTP offline call on page hide and at logout
Browser crash — no close, no offline call 1-minute cron flips presences whose heartbeat went stale
Heartbeat writes would spam the bus Broadcast only on an actual status transition, not on every ping
Page refresh produces a false "user came online" alert Online alerts are suppressed when the offline gap is shorter than a short threshold; a genuine return from away always exceeds the disconnection timer

Statuses: online / away / offline. Surfaced in both chat UIs and in the ERP Back Office Customers Management → Online Users list.


In-App Notifications

Aspect Detail
Model ecommerce.notification
Types user_login, order_update, message, user_activation
Scope portal_company_partner_id — a notification belongs to one customer company
Self-exclusion owner_user_id records the actor, who is excluded from receiving their own notification
Delivery Created server-side, pushed over the bus, rendered in the portal header dropdown
Read tracking is_seen, with mark-one-seen and mark-all-seen endpoints
Administration ERP Back Office list under Configuration → Notifications

Notification vs. e-mail

flowchart LR
    EV["Order state change<br/>or shared request"] --> N["In-app notification<br/>always created"]
    EV --> Q{"ecommerce.send_email_notification"}
    Q -->|True| M["E-mail to the account manager<br/>with a deep link into the portal"]
    Q -->|False| S["skipped"]

E-mail fires on rfq_submitted, rfq_updated, po_submitted and shared requests, and only when the actor is a portal user. The deep link is built from ecommerce.shop_portal_url, falling back to the customer's own shop_portal_url, with a state-specific route (rfqs / quotations).


Sales Order Chatter

Distinct from live chat: a persistent, per-order comment thread built on mail.message.

Capability Detail
Read / post / update Portal users and account managers on the same order thread
Attachments Upload and delete; only the uploader may delete their own file
Access control Validated against the order's company, not against the message
Provenance Attachments record upload_from_portal, the creating portal user and the company
Visibility attachment_view selector controls where an attachment surfaces
Counters The order carries portal_messages_count and an attachment count, both shown in the ERP Back Office

ERP Back Office Auto-Refresh

Orders inherit an abstract model that publishes a bus message on every create, write and unlink. Open ERP Back Office list views refresh live without a manual reload.

This depends on a separate lp_auto_refresh module being installed for the client-side listener. Without it the broadcasts are emitted and simply ignored — nothing breaks, lists just do not refresh by themselves.


Operational Notes

Symptom Likely cause
Endless WebSocket reconnect, close code 4001 A frame was sent before subscribe
Chat works on-prem but not on Odoo.sh Relying on a record channel instead of the string-channel mirror
A user shows online forever Presence heartbeat stopped and the stale-presence cron is disabled
Duplicate chat messages on-prem Expected — deduplicate by message id
Account manager receives no e-mail ecommerce.send_email_notification is off, the account manager has no e-mail, or the mail template is missing