"""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")