"""Support-ticket domain models — the IT help-desk (求助域). ``support_tickets`` is the ticket header + lifecycle; ``support_ticket_messages`` is the append-only timeline (employee/agent comments, status changes, the resolution note). Person references FK ``identity_persons``. """ from __future__ import annotations import uuid from datetime import UTC, datetime from sqlalchemy import ( JSON, BigInteger, DateTime, Float, ForeignKey, Index, SmallInteger, String, Text, UniqueConstraint, func, ) from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.database import Base from app.core.sql_types import GUID as Uuid def _utcnow() -> datetime: # Python-side default so created_at / updated_at are populated in-memory at # flush — without it the server_default columns are expired post-flush and # a sync attribute access (serialization after commit) triggers a lazy # reload outside the async greenlet → MissingGreenlet. return datetime.now(UTC) # ── Lifecycle ─────────────────────────────────────────────────────── # open : submitted, not yet picked up by IT # triaging : an agent claimed it / is assessing # in_progress : actively being worked # waiting_requester: IT replied and needs info back from the requester # resolved : IT marked it solved (requester can confirm-close or reopen) # closed : terminal — confirmed done (by requester or auto) # cancelled : requester withdrew before resolution STATUS_OPEN = "open" STATUS_TRIAGING = "triaging" STATUS_IN_PROGRESS = "in_progress" STATUS_WAITING_REQUESTER = "waiting_requester" STATUS_RESOLVED = "resolved" STATUS_CLOSED = "closed" STATUS_CANCELLED = "cancelled" # Statuses that still need work / attention (the IT queue + "my open tickets"). OPEN_STATUSES = ( STATUS_OPEN, STATUS_TRIAGING, STATUS_IN_PROGRESS, STATUS_WAITING_REQUESTER, ) # "Solved" queue bucket for the IT workbench: both the freshly resolved rows # still awaiting requester confirmation AND the already-confirmed closed rows # belong in the historical "已解决" view. RESOLVED_STATUSES = ( STATUS_RESOLVED, STATUS_CLOSED, ) # Terminal statuses — no further agent action expected. TERMINAL_STATUSES = (STATUS_CLOSED, STATUS_CANCELLED) CATEGORIES = ( "hardware", # laptop / monitor / peripherals broken or needed "account_access", # can't log in, locked out, need access to X "software", # app install / license / bug "network", # VPN / wifi / connectivity "facility", # desk / meeting room / office equipment "other", ) PRIORITIES = ("low", "normal", "high", "urgent") SOURCE_WEB = "web" SOURCE_MOBILE = "mobile" SOURCE_AI = "ai" # One employee need can pass through the assistant and one or more human work # items. Keep that identity separate from a ticket: self-service requests do # not become tickets, while a cross-team issue can legitimately create more # than one ticket. REQUEST_ACTIVE = "active" REQUEST_SELF_RESOLVED = "self_resolved" REQUEST_ESCALATED = "escalated" REQUEST_RESOLVED = "resolved" REQUEST_CLOSED = "closed" REQUEST_ABANDONED = "abandoned" INITIATOR_EMPLOYEE = "employee" INITIATOR_AGENT = "agent" INITIATOR_IT = "it_agent" INITIATOR_SYSTEM = "system" INITIATOR_EXTERNAL = "external" CREATION_EMPLOYEE_DIRECT = "employee_direct" CREATION_AGENT_ASSISTED = "agent_assisted" CREATION_AGENT_AUTOMATIC = "agent_auto_escalation" CREATION_IT_ON_BEHALF = "it_on_behalf" CREATION_FEISHU_IMPORT = "feishu_import" ORIGIN_WEB = "web" ORIGIN_MOBILE = "mobile" ORIGIN_ASSISTANT = "assistant" ORIGIN_SUPPORT_COPILOT = "support_copilot" ORIGIN_FEISHU = "feishu_helpdesk" # Message kinds on the timeline. MSG_COMMENT = "comment" # free-text from requester or agent MSG_STATUS = "status_change" # lifecycle transition note MSG_RESOLUTION = "resolution" # the resolution write-up MSG_SYSTEM = "system" # system-generated (assignment, reopen, …) # A recorded NOTE — several lines of content that somebody (or the assistant, # summarising a room) put on the record. Deliberately not ``system``: a status # line is one quiet centred sentence, and a six-line summary rendered that way # is unreadable. Same reason ``resolution`` is its own kind rather than a # comment: how a message is READ is part of what it is. MSG_NOTE = "note" class SupportServiceRequest(Base): """The end-to-end employee need, whether or not it became a ticket.""" __tablename__ = "support_service_requests" __table_args__ = ( Index("ix_support_requests_requester_created", "requester_id", "created_at"), Index("ix_support_requests_origin_status", "origin", "status", "created_at"), ) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) requester_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), nullable=True, ) origin: Mapped[str] = mapped_column(String(32), nullable=False, default=ORIGIN_WEB) status: Mapped[str] = mapped_column(String(24), nullable=False, default=REQUEST_ACTIVE) outcome: Mapped[str | None] = mapped_column(String(32)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=_utcnow, server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=_utcnow, onupdate=_utcnow, server_default=func.now(), ) resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) class SupportTicket(Base): __tablename__ = "support_tickets" __table_args__ = ( Index("ix_support_tickets_requester", "requester_id", "status"), Index("ix_support_tickets_assignee", "assignee_id", "status"), Index("ix_support_tickets_status_created", "status", "created_at"), Index("ix_support_ticket_service_request", "service_request_id"), Index("ix_support_ticket_first_response_due", "status", "first_response_due_at"), Index("ix_support_ticket_resolution_due", "status", "resolution_due_at"), ) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) # Human-friendly reference, e.g. ``HELP-20260617-0007``. Unique. ticket_number: Mapped[str] = mapped_column(String(24), nullable=False, unique=True) requester_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("identity_persons.id"), nullable=False, ) service_request_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("support_service_requests.id"), nullable=True, ) title: Mapped[str] = mapped_column(String(255), nullable=False) body: Mapped[str] = mapped_column(Text, nullable=False) category: Mapped[str] = mapped_column(String(32), nullable=False, default="other") priority: Mapped[str] = mapped_column(String(16), nullable=False, default="normal") status: Mapped[str] = mapped_column( String(24), nullable=False, default=STATUS_OPEN, ) source: Mapped[str] = mapped_column(String(16), nullable=False, default=SOURCE_WEB) intent_initiator: Mapped[str] = mapped_column( String(24), nullable=False, default=INITIATOR_EMPLOYEE, ) creation_actor: Mapped[str] = mapped_column( String(24), nullable=False, default=INITIATOR_EMPLOYEE, ) creation_mode: Mapped[str] = mapped_column( String(32), nullable=False, default=CREATION_EMPLOYEE_DIRECT, ) origin_channel: Mapped[str] = mapped_column( String(32), nullable=False, default=ORIGIN_WEB, ) employee_confirmed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) assignee_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), ) resolution: Mapped[str | None] = mapped_column(Text) # The Feishu IM card message id (group chat), so a later edit can update # the same card in place. Best-effort — NULL when IM posting is off. feishu_message_id: Mapped[str | None] = mapped_column(String(64)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=_utcnow, server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=_utcnow, onupdate=_utcnow, server_default=func.now(), ) first_response_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) first_response_due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) resolution_due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) sla_paused_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) sla_paused_seconds: Mapped[int] = mapped_column( BigInteger, nullable=False, default=0, server_default="0", ) last_agent_reminded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) agent_reminder_count: Mapped[int] = mapped_column( SmallInteger, nullable=False, default=0, server_default="0", ) last_requester_reminded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) requester_reminder_count: Mapped[int] = mapped_column( SmallInteger, nullable=False, default=0, server_default="0", ) # ── Requester satisfaction (CSAT) ──────────────────────────────── # 3-point scale aligned with the Feishu-helpdesk mirror caliber so the # two entrances stay directly comparable during the并行期: # 1=不满意 / 2=一般 / 3=满意; NULL = not rated (excluded from rates — # the mirror's score=0 sentinel maps to NULL here). One rating per # ticket, requester-only, while resolved/closed. satisfaction_score: Mapped[int | None] = mapped_column(SmallInteger, nullable=True) satisfaction_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) messages: Mapped[list[SupportTicketMessage]] = relationship( back_populates="ticket", cascade="all, delete-orphan", order_by="SupportTicketMessage.created_at", ) class SupportTicketMessage(Base): __tablename__ = "support_ticket_messages" __table_args__ = ( Index("ix_support_ticket_messages_ticket", "ticket_id", "created_at"), ) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) ticket_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("support_tickets.id"), nullable=False, ) # NULL author = system-generated message. author_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), ) kind: Mapped[str] = mapped_column(String(16), nullable=False, default=MSG_COMMENT) body: Mapped[str] = mapped_column(Text, nullable=False) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=_utcnow, server_default=func.now(), ) ticket: Mapped[SupportTicket] = relationship(back_populates="messages") # ── Knowledge base (self-growing help articles) ───────────────────── # Published articles are the deflection surface: the AI copilot searches # them, and the help center can surface them. An article can be authored # manually or AI-drafted from a resolved ticket (source='ai_ticket'), # then adopted (published) by an admin — closing the self-grow loop. KB_DRAFT = "draft" KB_PUBLISHED = "published" KB_ARCHIVED = "archived" KB_SOURCE_MANUAL = "manual" KB_SOURCE_AI_TICKET = "ai_ticket" # Imported from a Feishu Helpdesk (服务台) knowledge-base export — one row per # FAQ. UNLIKE the wiki mirror below, these are handed over to Helix on import: # the Feishu helpdesk is being retired, so Helix becomes the authoring surface # and admins edit / publish / delete them like any native article. KB_SOURCE_HELPDESK_FAQ = "helpdesk_faq" # Mirrored from a Feishu wiki docx (read-only here — the wiki stays the ONLY # authoring surface; the sync in feishu_docs.py owns these rows' content AND # status, so they are rebuildable from the wiki at any time). KB_SOURCE_FEISHU_DOC = "feishu_doc" class SupportKbArticle(Base): __tablename__ = "support_kb_articles" __table_args__ = ( Index("ix_support_kb_status", "status", "category"), # One article per mirrored wiki doc (MySQL unique indexes admit # multiple NULLs, so native manual/ai_ticket articles are unaffected). Index("ux_support_kb_external_obj", "external_obj_token", unique=True), ) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) title: Mapped[str] = mapped_column(String(255), nullable=False) body: Mapped[str] = mapped_column(Text, nullable=False) category: Mapped[str] = mapped_column(String(32), nullable=False, default="other") status: Mapped[str] = mapped_column(String(16), nullable=False, default=KB_DRAFT) source: Mapped[str] = mapped_column(String(16), nullable=False, default=KB_SOURCE_MANUAL) source_ticket_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("support_tickets.id"), ) author_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=_utcnow, server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=_utcnow, onupdate=_utcnow, server_default=func.now(), ) published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # Semantic-retrieval embedding — portable JSON float array (NOT TiDB VECTOR, # so local MySQL tests run unchanged); Python cosine ranks search. NULL until # embedded (graceful: keyword search is always the fallback). embedding_model # records the producing model so a model change can re-embed selectively. # none_as_null: store Python None as SQL NULL (not JSON 'null'), so # `embedding IS NULL` reliably means "not embedded yet". embedding: Mapped[list[float] | None] = mapped_column( JSON(none_as_null=True), nullable=True, ) embedding_model: Mapped[str | None] = mapped_column(String(64), nullable=True) embedded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # ── Feishu-doc mirror provenance (source='feishu_doc' only) ───── # obj_token of the wiki doc (stable across renames/moves), the wiki URL # employees open from search results, the configured root subtree this doc # was discovered under (scopes the disappeared→archive sweep), and the # doc's obj_edit_time (epoch SECONDS — Feishu wiki returns seconds, unlike # the helpdesk ticket API's milliseconds) as the incremental watermark. external_obj_token: Mapped[str | None] = mapped_column(String(64), nullable=True) external_url: Mapped[str | None] = mapped_column(String(512), nullable=True) external_root_token: Mapped[str | None] = mapped_column(String(64), nullable=True) external_edited_at: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # ── Retrieval + provenance extras (migration 578) ─────────────── # Alternate phrasings of the same question, newline-separated ("相似问题" # in the Feishu helpdesk export). Plain text, not JSON, so the keyword # path ILIKEs it directly and the semantic path just concatenates it into # the embedding input. Any article may carry them, not only imported FAQs. search_aliases: Mapped[str | None] = mapped_column(Text, nullable=True) # 'zh' / 'en' / 'ja' — display + admin filter only; retrieval stays # language-agnostic (the embedding model is multilingual). language: Mapped[str | None] = mapped_column(String(8), nullable=True) # The source system's own category label, kept verbatim because # ``category`` collapses everything into six canonical buckets. source_category: Mapped[str | None] = mapped_column(String(64), nullable=True) # ── The wiki index (migration 593) ────────────────────────────── # One line saying what this page is for. It exists so the corpus can be # reasoned about as a LIST rather than as a pile of full texts: telling two # similar pages apart, deciding whether new knowledge belongs on an # existing page or a new one, and spotting duplicates all need a hundred # pages in one view, and a hundred bodies do not fit anywhere. # # Nullable, and every reader falls back to the head of ``body`` — the index # has to work on the corpus as it is today, not only after a summary run. summary: Mapped[str | None] = mapped_column(String(255), nullable=True) # ── Article links (migration 593) ─────────────────────────────────── # What relates one page to another. Until now the corpus was a set of # unconnected documents: a reader who found the right page had no way to reach # the one next to it, a merge had nowhere to record what it absorbed, and two # pages that contradicted each other could only be noticed by someone who # happened to read both. LINK_RELATED = "related" LINK_SUPERSEDES = "supersedes" LINK_CONTRADICTS = "contradicts" LINK_DERIVED_FROM = "derived_from" KB_LINK_KINDS = (LINK_RELATED, LINK_SUPERSEDES, LINK_CONTRADICTS, LINK_DERIVED_FROM) class SupportKbArticleLink(Base): """A typed edge between two KB articles. Symmetric kinds (``related`` / ``contradicts``) are stored ONCE, in a canonical direction, because two rows for one fact is two rows that can disagree — and a reader who forgets to query both directions sees half the graph. ``kb_wiki.link_articles`` owns the canonicalisation; nothing should insert here directly. """ __tablename__ = "support_kb_article_links" __table_args__ = ( UniqueConstraint( "from_article_id", "to_article_id", "kind", name="uq_support_kb_link", ), Index("idx_support_kb_link_from", "from_article_id", "kind"), Index("idx_support_kb_link_to", "to_article_id", "kind"), ) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) from_article_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("support_kb_articles.id", ondelete="CASCADE"), nullable=False, ) to_article_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("support_kb_articles.id", ondelete="CASCADE"), nullable=False, ) kind: Mapped[str] = mapped_column(String(24), nullable=False, default=LINK_RELATED) #: Why this edge exists, in words — a merge's absorbed title, the lint's #: reasoning. An untyped, unexplained graph is one nobody trusts enough to #: act on. note: Mapped[str | None] = mapped_column(Text) created_by_person_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id", ondelete="SET NULL"), nullable=True, ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) # ── Deflection events (pre-file self-service funnel) ──────────────── # Before an employee files a ticket they describe the problem; we search # the KB (semantic) + optionally an AI answer and show it. The event # records what was shown and the OUTCOME — the only honest way to measure # real deflection (a ticket that was prevented), vs. the proxy metrics # (AI-sourced share, KB growth) PR4-prior had to rely on. DEFLECT_OPEN = "open" # shown help, awaiting the employee's choice DEFLECT_DEFLECTED = "deflected" # employee said it helped — no ticket filed DEFLECT_ESCALATED = "escalated" # employee filed a ticket anyway # The employee walked away without choosing; swept by the autoclose cron so # forever-open sessions stop skewing the funnel denominator's honesty # (deflection_rate only counts decided sessions either way). DEFLECT_EXPIRED = "expired" DEFLECT_OUTCOMES = (DEFLECT_OPEN, DEFLECT_DEFLECTED, DEFLECT_ESCALATED, DEFLECT_EXPIRED) class SupportAiSuggestion(Base): """One IT-side ai-suggest draft. ``adopted_at`` is set when an agent reply on the same ticket lands textually close to the suggestion soon after it was drafted — the honest 'did the AI save IT time' signal.""" __tablename__ = "support_ai_suggestions" __table_args__ = ( Index("ix_support_ai_suggestions_ticket", "ticket_id", "created_at"), ) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) ticket_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("support_tickets.id"), nullable=False, ) agent_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), ) suggestion: Mapped[str] = mapped_column(Text, nullable=False) model: Mapped[str | None] = mapped_column(String(64), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=_utcnow, server_default=func.now(), ) adopted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) adopted_similarity: Mapped[float | None] = mapped_column(Float, nullable=True) class SupportDeflectionEvent(Base): __tablename__ = "support_deflection_events" __table_args__ = ( Index("ix_support_deflect_person", "person_id", "created_at"), Index("ix_support_deflect_outcome", "outcome", "created_at"), Index("ix_support_deflection_service_request", "service_request_id"), ) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) person_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("identity_persons.id"), nullable=False, ) service_request_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("support_service_requests.id"), nullable=True, ) query: Mapped[str] = mapped_column(Text, nullable=False) # KB article ids shown (JSON list of str) + whether an AI answer was given. kb_article_ids: Mapped[list | None] = mapped_column(JSON(none_as_null=True), nullable=True) copilot_answered: Mapped[bool] = mapped_column(default=False, nullable=False) outcome: Mapped[str] = mapped_column(String(16), nullable=False, default=DEFLECT_OPEN) # When escalated, the ticket it became (nullable; SET on escalate). ticket_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("support_tickets.id"), ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=_utcnow, server_default=func.now(), ) resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) class SupportExternalTicketLink(Base): """Exact, auditable external-ticket to service-request mapping.""" __tablename__ = "support_external_ticket_links" __table_args__ = ( Index("ux_support_external_ticket", "source", "external_ticket_id", unique=True), Index("ix_support_external_request", "service_request_id"), ) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) service_request_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("support_service_requests.id"), nullable=False, ) source: Mapped[str] = mapped_column(String(32), nullable=False) external_ticket_id: Mapped[str] = mapped_column(String(128), nullable=False) linked_by_person_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), nullable=True, ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=_utcnow, server_default=func.now(), ) __all__ = [ "CREATION_AGENT_ASSISTED", "CREATION_AGENT_AUTOMATIC", "CREATION_EMPLOYEE_DIRECT", "CREATION_FEISHU_IMPORT", "CREATION_IT_ON_BEHALF", "DEFLECT_DEFLECTED", "DEFLECT_ESCALATED", "DEFLECT_EXPIRED", "DEFLECT_OPEN", "SupportAiSuggestion", "SupportExternalTicketLink", "SupportServiceRequest", "DEFLECT_OUTCOMES", "SupportDeflectionEvent", "CATEGORIES", "KB_ARCHIVED", "KB_DRAFT", "KB_PUBLISHED", "KB_SOURCE_AI_TICKET", "KB_SOURCE_FEISHU_DOC", "KB_SOURCE_HELPDESK_FAQ", "KB_SOURCE_MANUAL", "SupportKbArticle", "MSG_COMMENT", "MSG_RESOLUTION", "MSG_STATUS", "MSG_NOTE", "MSG_SYSTEM", "INITIATOR_AGENT", "INITIATOR_EMPLOYEE", "INITIATOR_EXTERNAL", "INITIATOR_IT", "INITIATOR_SYSTEM", "OPEN_STATUSES", "RESOLVED_STATUSES", "PRIORITIES", "ORIGIN_ASSISTANT", "ORIGIN_FEISHU", "ORIGIN_MOBILE", "ORIGIN_SUPPORT_COPILOT", "ORIGIN_WEB", "REQUEST_ABANDONED", "REQUEST_ACTIVE", "REQUEST_CLOSED", "REQUEST_ESCALATED", "REQUEST_RESOLVED", "REQUEST_SELF_RESOLVED", "SOURCE_AI", "SOURCE_MOBILE", "SOURCE_WEB", "STATUS_CANCELLED", "STATUS_CLOSED", "STATUS_IN_PROGRESS", "STATUS_OPEN", "STATUS_RESOLVED", "STATUS_TRIAGING", "STATUS_WAITING_REQUESTER", "TERMINAL_STATUSES", "SupportTicket", "SupportTicketMessage", ]