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>
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
140
backend/app/models/l1_walk_session.py
Normal file
140
backend/app/models/l1_walk_session.py
Normal file
@@ -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")
|
||||
Reference in New Issue
Block a user