From 960ea71a2025b8347e65753f17d894c15990c109 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 28 May 2026 12:35:24 -0400 Subject: [PATCH] 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 --- .../b3358ba0e48c_create_l1_walk_sessions.py | 97 ++++++++++++ backend/app/models/__init__.py | 2 + backend/app/models/l1_walk_session.py | 140 ++++++++++++++++++ 3 files changed, 239 insertions(+) create mode 100644 backend/alembic/versions/b3358ba0e48c_create_l1_walk_sessions.py create mode 100644 backend/app/models/l1_walk_session.py diff --git a/backend/alembic/versions/b3358ba0e48c_create_l1_walk_sessions.py b/backend/alembic/versions/b3358ba0e48c_create_l1_walk_sessions.py new file mode 100644 index 00000000..695ee4f2 --- /dev/null +++ b/backend/alembic/versions/b3358ba0e48c_create_l1_walk_sessions.py @@ -0,0 +1,97 @@ +"""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') diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 1c3c0faa..39912458 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -67,6 +67,7 @@ from .plan_billing import PlanBilling # noqa: F401 from .sales_lead import SalesLead # noqa: F401 from .stripe_event import StripeEvent # noqa: F401 from .internal_ticket import InternalTicket # noqa: F401 +from .l1_walk_session import L1WalkSession # noqa: F401 __all__ = [ "User", @@ -148,4 +149,5 @@ __all__ = [ "SalesLead", "StripeEvent", "InternalTicket", + "L1WalkSession", ] diff --git a/backend/app/models/l1_walk_session.py b/backend/app/models/l1_walk_session.py new file mode 100644 index 00000000..9eb09edd --- /dev/null +++ b/backend/app/models/l1_walk_session.py @@ -0,0 +1,140 @@ +"""L1 walk session model. + +Per-session state for an L1 technician walking a ticket through a flow, +flow proposal, or ad-hoc investigation. Tracks the walked path, notes +captured at each step, and terminal resolution / escalation metadata. +""" +import uuid +from datetime import datetime, timezone +from typing import Any, Optional, TYPE_CHECKING + +import sqlalchemy as sa +from sqlalchemy import String, Text, DateTime, Boolean, ForeignKey, CheckConstraint +from sqlalchemy import text as sa_text +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID, JSONB + +from app.core.database import Base + +if TYPE_CHECKING: + from app.models.account import Account + from app.models.user import User + from app.models.tree import Tree + from app.models.flow_proposal import FlowProposal + + +class L1WalkSession(Base): + """A single L1 technician session walking a ticket. + + session_kind values: + - flow: Walking a published flow (flow_id required, flow_proposal_id null). + - proposal: Walking a draft flow proposal (flow_proposal_id required, flow_id null). + - adhoc: Free-form investigation (both flow_id and flow_proposal_id null). + + status lifecycle: + - active: Session is in progress. + - resolved: Issue resolved; resolution_notes captured. + - escalated: Could not resolve; escalation_reason captured. + - abandoned: Session exited without resolution or explicit escalation. + """ + + __tablename__ = "l1_walk_sessions" + __table_args__ = ( + CheckConstraint( + "ticket_kind IN ('psa', 'internal')", + name="ck_l1_walk_sessions_ticket_kind", + ), + CheckConstraint( + "session_kind IN ('flow', 'proposal', 'adhoc')", + name="ck_l1_walk_sessions_session_kind", + ), + CheckConstraint( + "status IN ('active', 'resolved', 'escalated', 'abandoned')", + name="ck_l1_walk_sessions_status", + ), + 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", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + account_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("accounts.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + created_by_user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + + # ── Actor context ── + acting_as: Mapped[Optional[str]] = mapped_column(String(30), nullable=True) + + # ── Ticket reference ── + ticket_id: Mapped[str] = mapped_column(String(64), nullable=False) + ticket_kind: Mapped[str] = mapped_column(String(10), nullable=False) + + # ── Session kind + target ── + session_kind: Mapped[str] = mapped_column(String(20), nullable=False) + flow_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("trees.id", ondelete="SET NULL"), + nullable=True, + ) + flow_proposal_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("flow_proposals.id", ondelete="SET NULL"), + nullable=True, + ) + + # ── Navigation state ── + current_node_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + walked_path: Mapped[list[dict[str, Any]]] = mapped_column( + JSONB(), nullable=False, server_default=sa_text("'[]'::jsonb"), + ) + walk_notes: Mapped[list[dict[str, Any]]] = mapped_column( + JSONB(), nullable=False, server_default=sa_text("'[]'::jsonb"), + ) + + # ── Lifecycle ── + status: Mapped[str] = mapped_column( + String(20), nullable=False, server_default=sa_text("'active'"), index=True, + ) + + # ── Resolution ── + resolution_notes: Mapped[Optional[str]] = mapped_column(Text(), nullable=True) + helpful: Mapped[Optional[bool]] = mapped_column(Boolean(), nullable=True) + + # ── Escalation ── + escalation_reason: Mapped[Optional[str]] = mapped_column(Text(), nullable=True) + escalation_reason_category: Mapped[Optional[str]] = mapped_column( + String(30), nullable=True, + ) + + # ── Timestamps ── + started_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + last_step_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + resolved_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True, + ) + + # ── Relationships ── + account: Mapped["Account"] = relationship("Account") + created_by: Mapped["User"] = relationship("User", foreign_keys=[created_by_user_id]) + flow: Mapped[Optional["Tree"]] = relationship("Tree") + flow_proposal: Mapped[Optional["FlowProposal"]] = relationship("FlowProposal")