--- phase: 06-web-chat plan: 01 subsystem: backend tags: [web-chat, websocket, redis-pubsub, rbac, orm, migration] dependency_graph: requires: [] provides: - WebSocket endpoint at /chat/ws/{conversation_id} - REST API at /api/portal/chat/* for conversation CRUD - web_conversations and web_conversation_messages tables with RLS - Redis pub-sub response delivery for web channel - ChannelType.WEB in shared message model affects: - packages/orchestrator/orchestrator/tasks.py (new web channel routing) - packages/shared/shared/api/__init__.py (chat_router added) - packages/gateway/gateway/main.py (Phase 6 routers mounted) tech_stack: added: - gateway/channels/web.py (FastAPI WebSocket + normalize_web_event) - shared/api/chat.py (conversation CRUD REST API) - shared/models/chat.py (WebConversation + WebConversationMessage ORM) - migrations/versions/008_web_chat.py (DB tables + RLS + CHECK constraint update) patterns: - WebSocket auth via first JSON message (browser cannot send custom headers) - Redis pub-sub for async response delivery from Celery to WebSocket - thread_id = conversation_id for agent memory scoping - try/finally around all Redis connections to prevent leaks - TEXT+CHECK for role column (not sa.Enum) per Phase 1 ADR - SQLAlchemy 2.0 Mapped[]/mapped_column() style - require_tenant_member RBAC guard on all REST endpoints key_files: created: - packages/gateway/gateway/channels/web.py - packages/shared/shared/api/chat.py - packages/shared/shared/models/chat.py - migrations/versions/008_web_chat.py - tests/unit/test_web_channel.py - tests/unit/test_chat_api.py modified: - packages/shared/shared/models/message.py (ChannelType.WEB added) - packages/shared/shared/redis_keys.py (webchat_response_key added) - packages/shared/shared/api/__init__.py (chat_router exported) - packages/gateway/gateway/main.py (Phase 6 routers mounted) - packages/orchestrator/orchestrator/tasks.py (web channel extras + routing) decisions: - "WebSocket auth via first JSON message after connection — browser WebSocket API cannot send custom HTTP headers" - "thread_id = conversation_id in normalize_web_event — scopes agent memory to one web conversation (consistent with WhatsApp wa_id scoping)" - "Redis pub-sub response delivery: orchestrator publishes to webchat_response_key, WebSocket handler subscribes with 60s timeout" - "TEXT+CHECK for role column ('user'/'assistant') per Phase 1 ADR — not sa.Enum" - "dependency_overrides used in tests instead of patching shared.db.get_session — FastAPI dependency injection doesn't follow module-level patches" metrics: duration: "~8 minutes" completed_date: "2026-03-25" tasks_completed: 2 files_created: 6 files_modified: 5 --- # Phase 6 Plan 01: Web Chat Backend Infrastructure Summary **One-liner:** WebSocket endpoint + Redis pub-sub response bridge + RBAC REST API providing complete web chat plumbing from portal UI to the agent pipeline. ## What Was Built This plan establishes the complete backend for web chat — the "web" channel that lets portal users talk to AI employees directly from the Konstruct portal UI without setting up Slack or WhatsApp. ### ChannelType.WEB and Redis key `ChannelType.WEB = "web"` added to the shared message model. `webchat_response_key(tenant_id, conversation_id)` added to `redis_keys.py` following the established namespace pattern (`{tenant_id}:webchat:response:{conversation_id}`). ### DB Schema (migration 008) Two new tables with FORCE ROW LEVEL SECURITY: - `web_conversations` — one per (tenant_id, agent_id, user_id) triple with UniqueConstraint for get-or-create semantics - `web_conversation_messages` — individual messages with TEXT+CHECK role column ('user'/'assistant') and CASCADE delete - `channel_connections.channel_type` CHECK constraint replaced to include 'web' ### WebSocket Endpoint (`/chat/ws/{conversation_id}`) Full message lifecycle in `gateway/channels/web.py`: 1. Accept connection 2. Auth handshake via first JSON message (browser limitation) 3. For each message: typing indicator → save to DB → Celery dispatch → Redis subscribe → save response → send to client 4. try/finally cleanup on all Redis connections ### REST API (`/api/portal/chat/*`) Four endpoints in `shared/api/chat.py`: - `GET /conversations` — list with RBAC (platform_admin sees all, others see own) - `POST /conversations` — get-or-create with IntegrityError race condition handling - `GET /conversations/{id}/messages` — paginated history with cursor support - `DELETE /conversations/{id}` — message reset keeping conversation row ### Orchestrator Integration `tasks.py` updated: - `handle_message` pops `conversation_id` and `portal_user_id` before `model_validate` - `_build_response_extras` handles "web" case returning `{conversation_id, tenant_id}` - `_send_response` handles "web" case with Redis pub-sub publish and try/finally cleanup - `webchat_response_key` imported at module level ## Test Coverage 19 unit tests written (TDD, all passing): | Test | Covers | |------|--------| | `test_webchat_response_key_format` | Key format correct | | `test_webchat_response_key_isolation` | Tenant isolation | | `test_channel_type_web_exists` | ChannelType.WEB | | `test_normalize_web_event_*` (5 tests) | Message normalization CHAT-01 | | `test_send_response_web_publishes_to_redis` | Redis pub-sub publish CHAT-02 | | `test_send_response_web_connection_cleanup` | try/finally Redis cleanup | | `test_send_response_web_missing_conversation_id_logs_warning` | Error handling | | `test_typing_indicator_sent_before_dispatch` | Typing indicator CHAT-05 | | `test_chat_rbac_enforcement` | 403 for non-member CHAT-04 | | `test_platform_admin_cross_tenant` | Admin bypass CHAT-04 | | `test_list_conversation_history` | Paginated messages CHAT-03 | | `test_create_conversation` | Get-or-create CHAT-03 | | `test_create_conversation_rbac_forbidden` | 403 for non-member | | `test_delete_conversation_resets_messages` | Message reset | Full 313-test suite passes. ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 1 - Bug] Test dependency injection: patch vs dependency_overrides** - **Found during:** Task 1 test implementation - **Issue:** `patch("shared.db.get_session")` doesn't work for FastAPI endpoint testing because FastAPI's dependency injection resolves `Depends(get_session)` at function definition time, not via module attribute lookup - **Fix:** Used `app.dependency_overrides[get_session] = _override_get_session` pattern in test helper `_make_app_with_session_override()` — consistent with other test files in the project - **Files modified:** `tests/unit/test_chat_api.py` **2. [Rule 2 - Missing functionality] session.refresh mock populating server defaults** - **Found during:** Task 1 create_conversation test - **Issue:** Mocked `session.refresh()` was a no-op, leaving `created_at`/`updated_at` as `None` on new ORM objects (server_default not applied without real DB) - **Fix:** Test uses an async side_effect function that populates datetime fields on the object passed to `refresh()` - **Files modified:** `tests/unit/test_chat_api.py` ## Self-Check: PASSED All key artifacts verified: - `ChannelType.WEB = "web"` — present in message.py - `webchat_response_key()` — present in redis_keys.py - `WebConversation` ORM class — present in models/chat.py - `chat_websocket` WebSocket endpoint — present in gateway/channels/web.py - `chat_router` — exported from shared/api/__init__.py - `web_conversations` table — created in migration 008 - Commits `c72beb9` and `56c11a0` — verified in git log - 313/313 unit tests pass