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>
98 lines
4.8 KiB
Python
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')
|