- 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)
173 lines
6.8 KiB
Python
173 lines
6.8 KiB
Python
"""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")
|