Files
resolutionflow/backend/alembic/versions/b3358ba0e48c_create_l1_walk_sessions.py
Michael Chihlas 960ea71a20 feat(l1): create l1_walk_sessions table with target-consistency check + RLS
Per-session state for L1 walking a ticket. Supports flow/proposal/adhoc
session kinds; check constraint enforces target-consistency (flow_id set
iff kind=flow; flow_proposal_id set iff kind=proposal; both null iff
kind=adhoc). walked_path + walk_notes JSONB columns track step-by-step
progress; resolved/escalated/abandoned terminal statuses captured.
Account-scoped RLS matches the internal_tickets precedent (FORCE RLS +
tenant_isolation policy with COALESCE/NULLIF guard).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:35:24 -04:00

98 lines
4.8 KiB
Python

"""create_l1_walk_sessions
Revision ID: b3358ba0e48c
Revises: a1e6a018af02
Create Date: 2026-05-28 16:33:52.120027
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'b3358ba0e48c'
down_revision: Union[str, None] = 'a1e6a018af02'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_NULL_UUID = "00000000-0000-0000-0000-000000000000"
_CURRENT_ACCOUNT = (
f"COALESCE(NULLIF(current_setting('app.current_account_id', TRUE), ''), "
f"'{_NULL_UUID}')::uuid"
)
def upgrade() -> None:
op.create_table(
'l1_walk_sessions',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('account_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('created_by_user_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('acting_as', sa.String(30), nullable=True),
sa.Column('ticket_id', sa.String(64), nullable=False),
sa.Column('ticket_kind', sa.String(10), nullable=False),
sa.Column('session_kind', sa.String(20), nullable=False),
sa.Column('flow_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('flow_proposal_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('current_node_id', sa.String(100), nullable=True),
sa.Column('walked_path', postgresql.JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column('walk_notes', postgresql.JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column('status', sa.String(20), nullable=False, server_default='active'),
sa.Column('resolution_notes', sa.Text(), nullable=True),
sa.Column('helpful', sa.Boolean(), nullable=True),
sa.Column('escalation_reason', sa.Text(), nullable=True),
sa.Column('escalation_reason_category', sa.String(30), nullable=True),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.Column('last_step_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], ondelete='RESTRICT'),
sa.ForeignKeyConstraint(['flow_id'], ['trees.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['flow_proposal_id'], ['flow_proposals.id'], ondelete='SET NULL'),
sa.CheckConstraint(
"ticket_kind IN ('psa', 'internal')",
name='ck_l1_walk_sessions_ticket_kind',
),
sa.CheckConstraint(
"session_kind IN ('flow', 'proposal', 'adhoc')",
name='ck_l1_walk_sessions_session_kind',
),
sa.CheckConstraint(
"status IN ('active', 'resolved', 'escalated', 'abandoned')",
name='ck_l1_walk_sessions_status',
),
sa.CheckConstraint(
"(session_kind = 'flow' AND flow_id IS NOT NULL AND flow_proposal_id IS NULL) "
"OR (session_kind = 'proposal' AND flow_proposal_id IS NOT NULL AND flow_id IS NULL) "
"OR (session_kind = 'adhoc' AND flow_id IS NULL AND flow_proposal_id IS NULL)",
name='ck_l1_walk_sessions_target_consistency',
),
)
op.create_index('ix_l1_walk_sessions_account_id', 'l1_walk_sessions', ['account_id'])
op.create_index('ix_l1_walk_sessions_created_by_user_id', 'l1_walk_sessions', ['created_by_user_id'])
op.create_index('ix_l1_walk_sessions_status', 'l1_walk_sessions', ['status'])
op.create_index('ix_l1_walk_sessions_last_step_at', 'l1_walk_sessions', ['last_step_at'])
op.execute("ALTER TABLE l1_walk_sessions ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE l1_walk_sessions FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON l1_walk_sessions
USING (account_id = {_CURRENT_ACCOUNT})
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
""")
def downgrade() -> None:
op.execute("DROP POLICY IF EXISTS tenant_isolation ON l1_walk_sessions")
op.execute("ALTER TABLE l1_walk_sessions DISABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE l1_walk_sessions NO FORCE ROW LEVEL SECURITY")
op.drop_index('ix_l1_walk_sessions_last_step_at', 'l1_walk_sessions')
op.drop_index('ix_l1_walk_sessions_status', 'l1_walk_sessions')
op.drop_index('ix_l1_walk_sessions_created_by_user_id', 'l1_walk_sessions')
op.drop_index('ix_l1_walk_sessions_account_id', 'l1_walk_sessions')
op.drop_table('l1_walk_sessions')