feat(06-01): add web channel type, Redis key, ORM models, migration, and tests

- Add ChannelType.WEB = 'web' to shared/models/message.py
- Add webchat_response_key() to shared/redis_keys.py
- Create WebConversation and WebConversationMessage ORM models (SQLAlchemy 2.0)
- Create migration 008_web_chat.py with RLS, indexes, and channel_type CHECK update
- Pop conversation_id/portal_user_id extras in handle_message before model_validate
- Add web case to _build_response_extras and _send_response (Redis pub-sub publish)
- Import webchat_response_key in orchestrator/tasks.py
- Write 19 unit tests covering CHAT-01 through CHAT-05 (all pass)
This commit is contained in:
2026-03-25 10:26:34 -06:00
parent c0fa0cefee
commit c72beb916b
7 changed files with 957 additions and 1 deletions

View File

@@ -0,0 +1,172 @@
"""Web chat: web_conversations and web_conversation_messages tables with RLS
Revision ID: 008
Revises: 007
Create Date: 2026-03-25
This migration:
1. Creates the web_conversations table (tenant-scoped, RLS-enabled)
2. Creates the web_conversation_messages table (CASCADE delete, RLS-enabled)
3. Enables FORCE ROW LEVEL SECURITY on both tables
4. Creates tenant_isolation RLS policies matching existing pattern
5. Adds index on web_conversation_messages(conversation_id, created_at) for pagination
6. Replaces the channel_type CHECK constraint on channel_connections to include 'web'
NOTE on CHECK constraint replacement (Pitfall 5):
The existing constraint chk_channel_type only covers the original 7 channels.
ALTER TABLE DROP CONSTRAINT + ADD CONSTRAINT is used instead of just adding a
new constraint — the old constraint remains active otherwise and would reject 'web'.
"""
from __future__ import annotations
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
# Alembic migration metadata
revision: str = "008"
down_revision: Union[str, None] = "007"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# All valid channel types including new 'web' — must match ChannelType StrEnum in message.py
_CHANNEL_TYPES = (
"slack", "whatsapp", "mattermost", "rocketchat", "teams", "telegram", "signal", "web"
)
def upgrade() -> None:
# -------------------------------------------------------------------------
# 1. Create web_conversations table
# -------------------------------------------------------------------------
op.create_table(
"web_conversations",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column(
"tenant_id",
UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"agent_id",
UUID(as_uuid=True),
sa.ForeignKey("agents.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("user_id", UUID(as_uuid=True), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("NOW()"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("NOW()"),
),
sa.UniqueConstraint(
"tenant_id",
"agent_id",
"user_id",
name="uq_web_conversations_tenant_agent_user",
),
)
op.create_index("ix_web_conversations_tenant_id", "web_conversations", ["tenant_id"])
# Enable RLS on web_conversations
op.execute("ALTER TABLE web_conversations ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE web_conversations FORCE ROW LEVEL SECURITY")
op.execute("""
CREATE POLICY tenant_isolation ON web_conversations
USING (tenant_id = current_setting('app.current_tenant', TRUE)::uuid)
""")
# -------------------------------------------------------------------------
# 2. Create web_conversation_messages table
# -------------------------------------------------------------------------
op.create_table(
"web_conversation_messages",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column(
"conversation_id",
UUID(as_uuid=True),
sa.ForeignKey("web_conversations.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("tenant_id", UUID(as_uuid=True), nullable=False),
sa.Column("role", sa.Text, nullable=False),
sa.Column("content", sa.Text, nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("NOW()"),
),
)
# CHECK constraint on role — TEXT+CHECK per Phase 1 convention (not sa.Enum)
op.execute(
"ALTER TABLE web_conversation_messages ADD CONSTRAINT chk_message_role "
"CHECK (role IN ('user', 'assistant'))"
)
# Index for paginated message history queries: ORDER BY created_at with conversation filter
op.create_index(
"ix_web_conversation_messages_conv_created",
"web_conversation_messages",
["conversation_id", "created_at"],
)
# Enable RLS on web_conversation_messages
op.execute("ALTER TABLE web_conversation_messages ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE web_conversation_messages FORCE ROW LEVEL SECURITY")
op.execute("""
CREATE POLICY tenant_isolation ON web_conversation_messages
USING (tenant_id = current_setting('app.current_tenant', TRUE)::uuid)
""")
# -------------------------------------------------------------------------
# 3. Grant permissions to konstruct_app
# -------------------------------------------------------------------------
op.execute("GRANT SELECT, INSERT, UPDATE, DELETE ON web_conversations TO konstruct_app")
op.execute("GRANT SELECT, INSERT, UPDATE, DELETE ON web_conversation_messages TO konstruct_app")
# -------------------------------------------------------------------------
# 4. Update channel_connections CHECK constraint to include 'web'
#
# DROP + re-ADD because an existing CHECK constraint still enforces the old
# set of values — simply adding a second constraint would AND them together.
# -------------------------------------------------------------------------
op.execute("ALTER TABLE channel_connections DROP CONSTRAINT IF EXISTS chk_channel_type")
op.execute(
"ALTER TABLE channel_connections ADD CONSTRAINT chk_channel_type "
f"CHECK (channel_type IN {tuple(_CHANNEL_TYPES)})"
)
def downgrade() -> None:
# Restore original channel_type CHECK constraint (without 'web')
_ORIGINAL_CHANNEL_TYPES = (
"slack", "whatsapp", "mattermost", "rocketchat", "teams", "telegram", "signal"
)
op.execute("ALTER TABLE channel_connections DROP CONSTRAINT IF EXISTS chk_channel_type")
op.execute(
"ALTER TABLE channel_connections ADD CONSTRAINT chk_channel_type "
f"CHECK (channel_type IN {tuple(_ORIGINAL_CHANNEL_TYPES)})"
)
# Drop web_conversation_messages first (FK dependency)
op.execute("REVOKE ALL ON web_conversation_messages FROM konstruct_app")
op.drop_index("ix_web_conversation_messages_conv_created")
op.drop_table("web_conversation_messages")
# Drop web_conversations
op.execute("REVOKE ALL ON web_conversations FROM konstruct_app")
op.drop_index("ix_web_conversations_tenant_id")
op.drop_table("web_conversations")