From 60f4751a23e20ead7a4904e5ba889d64ad28cfd3 Mon Sep 17 00:00:00 2001 From: rookit Date: Mon, 29 Jun 2026 11:31:11 +0800 Subject: [PATCH] Design websocket message and inbox realtime Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...26-06-29-websocket-message-inbox-design.md | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-29-websocket-message-inbox-design.md diff --git a/docs/superpowers/specs/2026-06-29-websocket-message-inbox-design.md b/docs/superpowers/specs/2026-06-29-websocket-message-inbox-design.md new file mode 100644 index 0000000..771d670 --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-websocket-message-inbox-design.md @@ -0,0 +1,177 @@ +# WebSocket Message and Inbox Design + +## Context + +The current Inbox and comment flows use REST APIs for writes and cursor-based reads. `InboxDrawer` also polls unread count every 60 seconds. This design replaces polling with a WebSocket realtime channel while keeping REST as the durable source of truth for creation, pagination, manual refresh, and reconnect recovery. + +The scope covers both: + +- Inbox user/system messages, including unread count, new messages, replies, deletes, mark-read, and mark-all-read state. +- Resource comments in `DiscussionThread`, including new comments and deleted comments for the currently viewed record. + +## Chosen approach + +Use Django Channels with `channels-redis`. + +Reasons: + +- The backend already runs an ASGI service and uses Redis. +- Channels keeps authentication, ORM access, serializers, permissions, and transaction handling inside the Django ecosystem. +- User-level Inbox groups and record-level comment groups map naturally to Channels groups. +- REST endpoints can remain stable, reducing migration risk. + +Rejected alternatives: + +- Handwritten Starlette WebSocket handling would avoid dependencies but would duplicate JWT auth, connection management, group subscription, and Django permission integration. +- SSE would be simpler for one-way events but is not the requested WebSocket direction and is less flexible for dynamic subscribe/unsubscribe. + +## Architecture + +Add a dedicated realtime module, such as `apps.realtime`, responsible for: + +- WebSocket consumers. +- Event type definitions and payload shape. +- Broadcast helpers used by Inbox and Comments. +- Subscription permission checks. + +`apps.inbox` and `apps.comments` remain owners of their domain writes. They emit realtime events only after successful database commits. They do not manage socket connections directly. + +Expose a single WebSocket endpoint: + +```text +/ws/events/ +``` + +ASGI should route `/ws/events/` to Channels while preserving the existing Django HTTP app and the existing `/api/mcp` ASGI mount. + +Redis is used as the Channels channel layer through the existing Redis configuration. + +## Groups and subscriptions + +Each authenticated connection automatically joins its Inbox group: + +```text +inbox.user. +``` + +The frontend can subscribe and unsubscribe to resource comment groups as the user opens or leaves a record detail view: + +```text +comments.. +``` + +Inbox events are only sent to the affected user's group. Comment events are sent only to the matching resource group. + +WebSocket messages are not used for domain writes. Creating comments, sending messages, deleting messages, and marking messages read continue to use existing REST endpoints. + +## Event envelope + +All server-to-client events use a common envelope: + +```json +{ + "type": "inbox.message_created", + "event_id": "uuid-or-stable-id", + "occurred_at": "2026-06-29T03:13:11Z", + "actor_id": 1, + "payload": {} +} +``` + +`event_id` lets the frontend deduplicate events. `occurred_at` helps decide whether reconnect recovery needs a REST refresh. `payload` contains a typed body for each event. + +## Inbox events + +The Inbox realtime channel supports: + +- `inbox.message_created`: carries a complete serialized `InboxMessage`. +- `inbox.message_deleted`: carries `message_id`. +- `inbox.message_read`: carries `message_id` and `read_at`. +- `inbox.all_read`: carries `read_at` and enough state for the frontend to mark loaded items as read. +- `inbox.unread_count_changed`: carries the latest unread count and is the authoritative badge value. + +The frontend should still call `fetchInboxUnreadCount()` once after login or reconnect. After that, `inbox.unread_count_changed` updates the badge. When the drawer is open, message events update the loaded list in place. Manual refresh remains available. + +## Comment events + +The comment realtime channel supports: + +- `comment.created`: carries a complete serialized `RecordComment`. +- `comment.deleted`: carries `comment_id`. + +`DiscussionThread` continues to use REST for initial load, cursor pagination, and manual refresh. While mounted, it subscribes to its current `content_type` and `object_id`; on unmount or record switch, it unsubscribes. + +If search is active, the component still applies local filtering to the current loaded list after realtime updates. + +## Consistency and transactions + +Broadcasts must be scheduled with `transaction.on_commit()` so clients only receive events after the corresponding database state is committed. + +For events that include serialized objects, serialization should happen after commit or from a freshly loaded object so payloads match what REST readers can retrieve. + +The client treats REST as the source of truth. WebSocket updates are incremental hints that keep already-loaded UI state fresh. + +## Authentication and reconnect behavior + +The frontend authenticates the WebSocket connection with the current JWT. Because browser WebSocket APIs cannot set arbitrary headers, the implementation can use either a query token or a supported subprotocol-based token exchange. The design does not require HTTPS/WSS as a functional prerequisite, though production deployments may still choose WSS. + +If authentication fails, the server closes the socket with a clear close code. The frontend should surface login expiration through existing auth behavior. + +Reconnect behavior is hybrid: + +- Initial data and pagination remain REST-based. +- While disconnected, UI keeps existing data and can show a non-blocking realtime connection warning. +- On reconnect, the frontend refreshes unread count and any currently open Inbox/comment view via REST, then resumes incremental events. +- Event handlers deduplicate by `event_id` and entity id to avoid double-inserting the current user's own writes. + +## Frontend integration + +Add a global realtime connection layer, for example `RealtimeProvider` plus `useRealtime`. + +Responsibilities: + +- Connect after login and disconnect on logout. +- Maintain connection status. +- Send heartbeat or respond to server ping if needed. +- Reconnect with backoff. +- Dispatch typed events to subscribers. +- Track comment subscriptions for mounted `DiscussionThread` instances. + +`InboxDrawer` changes: + +- Remove the 60-second unread polling timer. +- Load unread count once after login/reconnect. +- Use realtime unread-count events for the badge. +- Use realtime message events to update currently loaded rows. +- Keep manual refresh and REST pagination. + +`DiscussionThread` changes: + +- Subscribe to the current record group while mounted. +- Apply `comment.created` and `comment.deleted` events to loaded comments. +- Keep REST for create/delete, initial load, pagination, search, and manual refresh. + +## Deployment and documentation + +Add the Channels dependencies and route `/ws/events/` to the ASGI service. Deployment documentation only needs to mention that `/ws/` must be proxied to the ASGI service; it does not need to describe the internal Channels or Redis details. + +No database migration is expected because the design does not require new model fields. + +## Validation plan + +Backend validation: + +- WebSocket rejects unauthenticated or invalid JWT connections. +- Authenticated connections join only their user Inbox group. +- Comment subscribe/unsubscribe targets the requested record group. +- Inbox create, reply, delete, mark-read, and mark-all-read emit events after commit. +- Comment create and delete emit events only to the matching record group. + +Frontend validation: + +- Inbox badge updates without polling. +- Open Inbox list receives new, deleted, and read-state updates. +- Comment thread receives new and deleted comments for the subscribed record only. +- Reconnect triggers REST refresh for unread count and currently open data. +- Duplicate events do not create duplicate rows. +