Skip to content

Implementation

This page covers, at a conceptual level, how conversations, messages, moderation, and real-time delivery are built. It describes what happens and why, not how it's coded. The actual schema (types, resolvers) is defined separately.

Overview

Messaging is a GraphQL API backed by MongoDB: queries and mutations run over HTTP, and live updates (new messages, conversation changes, notifications) are pushed over a WebSocket subscription. There's no separate chat-specific server or protocol. It's the same GraphQL schema and the same permission/visibility model used everywhere else in the platform (see GraphQL Implementation).

flowchart LR
    A[Create/Send mutation] --> B[Conversation updated in Mongo]
    B --> C[In-memory sink for that conversation]
    C --> D[Every active subscriber]
    B --> E[Notification created for other participants]
    E --> F[Per-user notification sink]

Data Model

  • Conversation: participants (tracked as an assignment list, the same mechanism used elsewhere for resource assignment), its messages, and its moderation state (active / flagged / archived, plus a flag reason and, once archived, who resolved it and when).
  • Message: sender, text, an attachment list, an optional pointer to the message it's replying to, and a kind: an ordinary message, a system-generated note (e.g. "flagged for review"), or a moderator note left when a conversation is archived.
  • Read state: kept separately from the conversation, one record per participant, so each person's unread count is theirs alone rather than a single shared flag on the thread.

There is deliberately no domain-context field (no listing or offer reference) on a conversation today. A conversation is just its participants and its messages. As the offer flow (quote → compare → accept, see Roadmap) is introduced, the natural extension is a reference field alongside the existing participant/assignment fields, the same pattern Notification already uses to point back at the conversation and message that caused it, not a new subsystem.

Creating a Conversation

Two distinct operations, gated by different permissions:

  • A general-purpose create conversation operation, available to any authenticated user, used for the user-to-user path (see Messaging Overview)
  • A create staff conversation operation, restricted to staff, used for the staff-initiated path

Both do the same thing underneath: create a conversation with the two participants assigned to it and an opening message. They differ only in who's allowed to call them.

Sending and Reading

Sending a message appends it to the conversation and triggers a notification to every other participant (see Notifications). Marking a conversation read updates only the caller's own read-state record.

Moderation and Mediation

Flagging, unflagging, and archiving are three actions on one operation, restricted to staff:

stateDiagram-v2
    [*] --> Active
    Active --> Flagged: flag (reason)
    Flagged --> Active: unflag
    Active --> Archived: archive (note)
    Flagged --> Archived: archive (note)
  • Flag adds the staff group to the conversation's participant list, the same assignment mechanism that makes any participant able to see a conversation, and appends a system message. This is why a moderator "joining" a conversation isn't a special case: they become visible to it exactly the way any other participant does.
  • Unflag clears the flag and reason, returning to Active.
  • Archive optionally appends a moderator note and records who closed it and when.

A separate query lists flagged conversations: staff with the broadest access see every flagged conversation, everyone else sees only the ones assigned to them or their group, the same visibility rule that governs every other type in the system (see Visibility & Privacy).

Real-Time Transport

Subscriptions run over a WebSocket using the GraphQL-over-WebSocket protocol. The interesting part is authentication: a browser WebSocket handshake can't carry a custom Authorization header, so the token instead travels once, in the first message sent after the socket opens.

sequenceDiagram
    participant Client
    participant Server
    Client->>Server: WebSocket connect
    Client->>Server: connection_init { authorization: Bearer <token> }
    Server->>Server: Resolve user + groups, cache against this connection
    Server-->>Client: connection_ack
    Client->>Server: subscribe (messageAdded, conversationUpdated, notificationReceived, ...)
    Server-->>Client: next (pushed as events occur)

The resolved identity is cached against the connection itself for as long as it's open, rather than re-parsed on every subscribe message, and is dropped when the connection closes. Regular HTTP queries and mutations resolve identity per-request from the Authorization header as usual. This cached-per-connection behavior is specific to the WebSocket transport.

Media Attachments

Attachments are uploaded and downloaded through plain REST endpoints rather than GraphQL, since GraphQL isn't a natural fit for streaming file bytes. A message just holds a list of attachment ids; resolving those ids to filenames, content types, and URLs happens through the schema like anything else.

Access to a given file is granted to: the person who uploaded it, staff, or anyone who is a participant in a conversation that references it, so an attachment is only ever visible to the people who were actually party to the thread it was shared in.

Because an <img> or <a> tag can't attach an Authorization header, download links also accept the token as a query parameter, as a fallback for exactly that case.

Notifications

Sending a message notifies every other participant in the conversation. Notification delivery is pluggable. In-app delivery (persisted + pushed live to the recipient) is the only channel wired up today; push and email are designed to be added later as additional channels without changing how a message triggers a notification in the first place. See Notifications for the full notification model.

A flagged conversation does not currently generate a notification to anyone. The moderation flow above is the only way to see it, via the Flagged queue.

Known Gaps

  • No self-service flagging. Only staff can flag a conversation today; there's no user-facing "Report" action with its own, narrower permission.
  • Flagging doesn't notify. The notification type for it exists in the schema but nothing triggers it yet.
  • No listing/offer linkage. Conversations aren't tied back to the listing or offer that started them. See Data Model above for how that's expected to extend the existing model rather than replace it.
  • Only one notification channel is implemented (in-app); push and email are designed for but not built.