""" Copyright 2024-2026 ReplyPier Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ import enum import uuid from pgvector.sqlalchemy import Vector from sqlalchemy import ( JSON, Column, DateTime, Float, ForeignKey, Index, Integer, String, Text, UniqueConstraint, ) from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship from sqlalchemy.sql import func from app.database import Base # Display prefix for ticket numbers (TKT-1024). Also mirrored into # session_to_agents.ticket_id so existing session/widget consumers keep working. TICKET_NUMBER_PREFIX = "TKT" # FastEmbed model used for dedup/similarity embeddings. The dimension is baked # into the tickets.embedding column — changing models requires a re-embed # migration. TICKET_EMBEDDING_MODEL = "BAAI/bge-small-en-v1.5" TICKET_EMBEDDING_DIM = 384 class _ValueStrEnum(str, enum.Enum): """str-enum whose str() is the value — so in-memory enum instances and refreshed-from-DB plain strings render identically (session mirroring, ai_state comparisons, API payloads).""" def __str__(self) -> str: # pragma: no cover - trivial return self.value class TicketStatus(_ValueStrEnum): OPEN = "open" TRIAGING = "triaging" # AI triage running INVESTIGATING = "investigating" # investigation run active AWAITING_APPROVAL = "awaiting_approval" # L2: resolution proposed IN_PROGRESS = "in_progress" # human working it RESOLVED_PENDING_CONFIRMATION = "resolved_pending_confirmation" RESOLVED = "resolved" CLOSED = "closed" REOPENED = "reopened" class TicketPriority(_ValueStrEnum): URGENT = "urgent" HIGH = "high" MEDIUM = "medium" LOW = "low" class TicketSource(_ValueStrEnum): CHAT_AI = "chat_ai" HUMAN_AGENT = "human_agent" MANUAL = "manual" API = "api" class ResolutionOutcome(_ValueStrEnum): FIXED = "fixed" WORKAROUND = "workaround" NOT_A_BUG = "not_a_bug" DUPLICATE = "duplicate" CANNOT_REPRODUCE = "cannot_reproduce" CUSTOMER_UNRESPONSIVE = "customer_unresponsive" ESCALATED_EXTERNAL = "escalated_external" # Statuses that count as "still open" — derived so a future status defaults to # open rather than silently escaping open-ticket queries and dedup checks. TERMINAL_TICKET_STATUSES = {TicketStatus.RESOLVED, TicketStatus.CLOSED} OPEN_TICKET_STATUSES = [s.value for s in TicketStatus if s not in TERMINAL_TICKET_STATUSES] # Legal status transitions, enforced centrally in TicketService.transition_status. TICKET_STATUS_TRANSITIONS = { TicketStatus.OPEN: { TicketStatus.TRIAGING, TicketStatus.INVESTIGATING, TicketStatus.AWAITING_APPROVAL, TicketStatus.IN_PROGRESS, TicketStatus.RESOLVED_PENDING_CONFIRMATION, TicketStatus.RESOLVED, TicketStatus.CLOSED, }, TicketStatus.TRIAGING: { TicketStatus.OPEN, TicketStatus.INVESTIGATING, TicketStatus.IN_PROGRESS, TicketStatus.AWAITING_APPROVAL, TicketStatus.RESOLVED, TicketStatus.CLOSED, }, TicketStatus.INVESTIGATING: { TicketStatus.OPEN, TicketStatus.AWAITING_APPROVAL, TicketStatus.IN_PROGRESS, TicketStatus.RESOLVED_PENDING_CONFIRMATION, TicketStatus.RESOLVED, TicketStatus.CLOSED, }, TicketStatus.AWAITING_APPROVAL: { TicketStatus.OPEN, TicketStatus.INVESTIGATING, TicketStatus.IN_PROGRESS, TicketStatus.RESOLVED_PENDING_CONFIRMATION, TicketStatus.RESOLVED, TicketStatus.CLOSED, }, TicketStatus.IN_PROGRESS: { TicketStatus.OPEN, TicketStatus.INVESTIGATING, TicketStatus.AWAITING_APPROVAL, TicketStatus.RESOLVED_PENDING_CONFIRMATION, TicketStatus.RESOLVED, TicketStatus.CLOSED, }, TicketStatus.RESOLVED_PENDING_CONFIRMATION: { TicketStatus.RESOLVED, TicketStatus.CLOSED, TicketStatus.REOPENED, }, TicketStatus.RESOLVED: {TicketStatus.CLOSED, TicketStatus.REOPENED}, TicketStatus.CLOSED: {TicketStatus.REOPENED}, TicketStatus.REOPENED: { TicketStatus.TRIAGING, TicketStatus.INVESTIGATING, TicketStatus.AWAITING_APPROVAL, TicketStatus.IN_PROGRESS, TicketStatus.RESOLVED_PENDING_CONFIRMATION, TicketStatus.RESOLVED, TicketStatus.CLOSED, }, } class Ticket(Base): """A native support ticket — the aggregate root of the AI-first ticketing feature. Decoupled from chat sessions: a ticket may aggregate 0..n linked sessions via TicketSession; a session spawns at most one native ticket. Status/priority/source/outcome columns are plain strings (values from the str enums above, mirroring faq_generation_jobs) so new values never need an ALTER TYPE migration. """ __tablename__ = "tickets" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) # Per-org monotonically increasing display number (TKT-{n}), allocated from # ticket_sequences with SELECT ... FOR UPDATE in the same transaction that # commits the ticket. ticket_number = Column(Integer, nullable=False) organization_id = Column( UUID(as_uuid=True), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True, ) # Nullable: manual/internal tickets may have no customer. customer_id = Column(UUID(as_uuid=True), ForeignKey("customers.id", ondelete="SET NULL"), nullable=True) title = Column(String(500), nullable=False) # Preserved when AI triage rewrites the title. original_title = Column(String(500), nullable=True) description = Column(Text, nullable=True) status = Column(String, nullable=False, default=TicketStatus.OPEN, index=True) priority = Column(String, nullable=False, default=TicketPriority.MEDIUM) # SEV-1..3 (1 = worst). Nullable — set by triage or humans, not required. severity = Column(Integer, nullable=True) source = Column(String, nullable=False, default=TicketSource.MANUAL) # AI triage output. intent = Column(String, nullable=True) triage_confidence = Column(Float, nullable=True) ai_summary = Column(Text, nullable=True) # ["payments", "ledger"] — rendered as the "payments · ledger" sub-line. tags = Column(JSON, nullable=True) # Assignment. All nullable: an unassigned open ticket with an active # investigation run is "AI-owned" in the UI. assignee_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) group_id = Column(UUID(as_uuid=True), ForeignKey("groups.id", ondelete="SET NULL"), nullable=True) # Which chat agent created it (source=chat_ai). agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id", ondelete="SET NULL"), nullable=True) # Dedup: cosine similarity over FastEmbed embeddings (pgvector). duplicate_of_ticket_id = Column(UUID(as_uuid=True), ForeignKey("tickets.id", ondelete="SET NULL"), nullable=True) embedding = Column(Vector(TICKET_EMBEDDING_DIM), nullable=True) # Resolution. resolution_summary is internal; customer_resolution_message # is the separate plain-language text sent to the customer. resolution_outcome = Column(String, nullable=True) resolution_summary = Column(Text, nullable=True) customer_resolution_message = Column(Text, nullable=True) # Who resolved it ('ai' | 'user') — powers the AI-resolved stat and the # AI-state list column without scanning activities. resolved_by_actor = Column(String, nullable=True) # SLA timestamps. first_response_at = Column(DateTime(timezone=True), nullable=True) resolved_at = Column(DateTime(timezone=True), nullable=True) closed_at = Column(DateTime(timezone=True), nullable=True) confirmation_requested_at = Column(DateTime(timezone=True), nullable=True) reopened_count = Column(Integer, nullable=False, default=0, server_default="0") # CSAT. The ask goes out on close (csat_requested_at); the score arrives # later, when the customer rates the linked conversation — see # app/services/ticket_csat.py for the capture path and why it is # conversation-only. csat_requested_at = Column(DateTime(timezone=True), nullable=True) csat_score = Column(Integer, nullable=True) csat_rating_id = Column(UUID(as_uuid=True), ForeignKey("ratings.id", ondelete="SET NULL"), nullable=True) csat_responded_at = Column(DateTime(timezone=True), nullable=True) # Optional one-way escalation reference (e.g. 'JIRA' + issue key + URL). external_ref_type = Column(String, nullable=True) external_ref_id = Column(String, nullable=True) external_ref_url = Column(String, nullable=True) created_by_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) organization = relationship("Organization") customer = relationship("Customer") assignee = relationship("User", foreign_keys=[assignee_user_id]) created_by = relationship("User", foreign_keys=[created_by_user_id]) group = relationship("UserGroup") agent = relationship("Agent") duplicate_of = relationship("Ticket", remote_side=[id]) sessions = relationship("TicketSession", back_populates="ticket", cascade="all, delete-orphan") activities = relationship( "TicketActivity", back_populates="ticket", cascade="all, delete-orphan", order_by="TicketActivity.created_at", ) __table_args__ = ( UniqueConstraint("organization_id", "ticket_number", name="uq_tickets_org_number"), Index("ix_tickets_org_status", "organization_id", "status"), Index("ix_tickets_org_assignee", "organization_id", "assignee_user_id"), Index("ix_tickets_org_created", "organization_id", "created_at"), # Drives the CSAT stat (AI- vs human-resolved averages over a window). Index("ix_tickets_org_csat", "organization_id", "csat_responded_at"), # NOTE: production also has an HNSW cosine index on embedding — it # lives only in the migration (sqlite tests can't compile it). ) @property def display_number(self) -> str: return f"{TICKET_NUMBER_PREFIX}-{self.ticket_number}" class TicketSequence(Base): """Per-org allocator for ticket display numbers. Read with SELECT ... FOR UPDATE and committed together with the ticket row. """ __tablename__ = "ticket_sequences" organization_id = Column( UUID(as_uuid=True), ForeignKey("organizations.id", ondelete="CASCADE"), primary_key=True, ) next_number = Column(Integer, nullable=False, default=1) class TicketSession(Base): """Link table between tickets and chat sessions. A link table (rather than an FK on session_to_agents) so merged/duplicate tickets can aggregate sessions. The legacy session_to_agents.ticket_* columns are kept as a denormalized mirror for existing consumers. """ __tablename__ = "ticket_sessions" ticket_id = Column(UUID(as_uuid=True), ForeignKey("tickets.id", ondelete="CASCADE"), primary_key=True) session_id = Column( UUID(as_uuid=True), ForeignKey("session_to_agents.session_id", ondelete="CASCADE"), primary_key=True, index=True, ) linked_at = Column(DateTime(timezone=True), server_default=func.now()) ticket = relationship("Ticket", back_populates="sessions") session = relationship("SessionToAgent")