feat(models): 组织、用户、角色、客户、会话与消息模型
按组织边界定义数据关系,补齐仓储层与通用工具,迁移脚本可重复执行。
This commit is contained in:
1 parent
2aee6286d6
commit
5e0615f36d
126 files changed
+17436
No files matched your search
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from app.database import Base
|
||||
from .organization import Organization
|
||||
from .user import User
|
||||
from .fcm_token import FCMToken
|
||||
from .auth_session import AuthSession
|
||||
from .customer import Customer
|
||||
from .role import Role
|
||||
from .permission import Permission
|
||||
from .ai_config import AIConfig, AIModelType
|
||||
from .agent import Agent
|
||||
from .knowledge_to_agent import KnowledgeToAgent
|
||||
from .knowledge import Knowledge
|
||||
from .chat_history import ChatHistory
|
||||
from .session_to_agent import SessionToAgent, SessionStatus
|
||||
from .rating import Rating
|
||||
from app.models.jira import JiraToken
|
||||
from app.models.shopify import ShopifyShop
|
||||
from app.models.workflow import Workflow
|
||||
from app.models.workflow_node import WorkflowNode, ExitCondition
|
||||
from app.models.workflow_connection import WorkflowConnection
|
||||
from app.models.mcp_tool import MCPTool, MCPToolToAgent, MCPTransportType
|
||||
from app.models.file_attachment import FileAttachment
|
||||
from app.models.customer import LeadStage
|
||||
from app.models.lead_capture import (
|
||||
LeadCaptureConfig, LeadCaptureResponse,
|
||||
LeadAssignmentMode, CrmSyncTarget,
|
||||
)
|
||||
from app.models.channels import (
|
||||
ChannelAccount, ChannelType, ChannelConversation, AgentChannelConfig,
|
||||
)
|
||||
from app.models.faq_generation_job import (
|
||||
FAQGenerationJob, FAQJobType, FAQJobStatus, FAQJobStage,
|
||||
)
|
||||
from app.models.faq import FAQ, FAQStatus
|
||||
from app.models.help_center import (
|
||||
HelpCenterSettings, HelpCenterQuery, DomainStatus, SSLStatus,
|
||||
)
|
||||
from app.models.ticket import (
|
||||
Ticket, TicketSequence, TicketSession,
|
||||
TicketStatus, TicketPriority, TicketSource, ResolutionOutcome,
|
||||
)
|
||||
from app.models.ticket_activity import TicketActivity, TicketActivityType, TicketActorType
|
||||
from app.models.ticket_settings import OrganizationTicketSettings
|
||||
from app.models.investigation import (
|
||||
InvestigationRun, InvestigationRunType, InvestigationRunStatus, InvestigationTrigger,
|
||||
InvestigationHypothesis, InvestigationEvent, RCADocument, TicketProposal,
|
||||
)
|
||||
from app.models.ticket_db_connector import TicketDBConnector, DBConnectorAuditLog
|
||||
from app.models.crm import (
|
||||
CrmConnection, CrmSyncJob, CrmCustomerSync,
|
||||
CrmProvider, CrmConnectionStatus, CrmSyncJobStatus,
|
||||
)
|
||||
from app.models.notification_settings import UserNotificationSettings
|
||||
from app.models.guardrail_event import GuardrailEvent
|
||||
|
||||
|
||||
|
||||
# This ensures all models are imported in the correct order
|
||||
__all__ = [
|
||||
"Organization",
|
||||
"User",
|
||||
"FCMToken",
|
||||
"AuthSession",
|
||||
"Customer",
|
||||
"Permission",
|
||||
"Role",
|
||||
"AIConfig",
|
||||
"AIModelType",
|
||||
"Agent",
|
||||
"KnowledgeToAgent",
|
||||
"Knowledge",
|
||||
"ChatHistory",
|
||||
"SessionToAgent",
|
||||
"SessionStatus",
|
||||
"Rating",
|
||||
"JiraToken",
|
||||
"ShopifyShop",
|
||||
"Workflow",
|
||||
"WorkflowNode",
|
||||
"ExitCondition",
|
||||
"WorkflowConnection",
|
||||
"MCPTool",
|
||||
"MCPToolToAgent",
|
||||
"MCPTransportType",
|
||||
"FileAttachment",
|
||||
"LeadStage",
|
||||
"LeadCaptureConfig",
|
||||
"LeadCaptureResponse",
|
||||
"LeadAssignmentMode",
|
||||
"CrmSyncTarget",
|
||||
"ChannelAccount",
|
||||
"ChannelType",
|
||||
"ChannelConversation",
|
||||
"AgentChannelConfig",
|
||||
"FAQGenerationJob",
|
||||
"FAQJobType",
|
||||
"FAQJobStatus",
|
||||
"FAQJobStage",
|
||||
"FAQ",
|
||||
"FAQStatus",
|
||||
"HelpCenterSettings",
|
||||
"HelpCenterQuery",
|
||||
"DomainStatus",
|
||||
"SSLStatus",
|
||||
"Ticket",
|
||||
"TicketSequence",
|
||||
"TicketSession",
|
||||
"TicketStatus",
|
||||
"TicketPriority",
|
||||
"TicketSource",
|
||||
"ResolutionOutcome",
|
||||
"TicketActivity",
|
||||
"TicketActivityType",
|
||||
"TicketActorType",
|
||||
"OrganizationTicketSettings",
|
||||
"UserNotificationSettings",
|
||||
"InvestigationRun",
|
||||
"InvestigationRunType",
|
||||
"InvestigationRunStatus",
|
||||
"InvestigationTrigger",
|
||||
"InvestigationHypothesis",
|
||||
"InvestigationEvent",
|
||||
"RCADocument",
|
||||
"TicketProposal",
|
||||
"TicketDBConnector",
|
||||
"DBConnectorAuditLog",
|
||||
"CrmConnection",
|
||||
"CrmSyncJob",
|
||||
"CrmCustomerSync",
|
||||
"CrmProvider",
|
||||
"CrmConnectionStatus",
|
||||
"CrmSyncJobStatus",
|
||||
"GuardrailEvent",
|
||||
]
|
||||
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, DateTime, Integer, String, Text, Boolean, Enum as SQLEnum, ForeignKey, JSON, Table, Float, func
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.database import Base
|
||||
import enum
|
||||
import json
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class AgentType(str, enum.Enum):
|
||||
CUSTOMER_SUPPORT = "customer_support"
|
||||
SALES = "sales"
|
||||
TECH_SUPPORT = "tech_support"
|
||||
GENERAL = "general"
|
||||
CUSTOM = "custom"
|
||||
|
||||
|
||||
class ChatStyle(str, enum.Enum):
|
||||
CHATBOT = "CHATBOT"
|
||||
ASK_ANYTHING = "ASK_ANYTHING"
|
||||
# Premium design presets
|
||||
GLASS = "GLASS"
|
||||
TERMINAL = "TERMINAL"
|
||||
PLAYFUL = "PLAYFUL"
|
||||
CALM_MINT = "CALM_MINT"
|
||||
AURORA = "AURORA"
|
||||
SUNRISE = "SUNRISE"
|
||||
|
||||
|
||||
class WidgetPosition(str, enum.Enum):
|
||||
FLOATING = "FLOATING"
|
||||
FIXED = "FIXED"
|
||||
|
||||
|
||||
class AgentCustomization(Base):
|
||||
__tablename__ = "agent_customizations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id", ondelete="CASCADE"))
|
||||
photo_url = Column(String)
|
||||
chat_background_color = Column(String, default="#F8F9FA")
|
||||
chat_bubble_color = Column(String, default="#E9ECEF")
|
||||
chat_text_color = Column(String, default="#212529")
|
||||
icon_url = Column(String)
|
||||
icon_color = Column(String, default="#6C757D")
|
||||
accent_color = Column(String, default="#f34611")
|
||||
font_family = Column(String, default="Inter, system-ui, sans-serif")
|
||||
custom_css = Column(Text)
|
||||
customization_metadata = Column(JSON, default={})
|
||||
chat_style = Column(SQLEnum(ChatStyle), default=ChatStyle.CHATBOT, nullable=False)
|
||||
widget_position = Column(SQLEnum(WidgetPosition), default=WidgetPosition.FLOATING, nullable=False)
|
||||
welcome_title = Column(String, nullable=True)
|
||||
welcome_subtitle = Column(String, nullable=True)
|
||||
# First in-conversation agent bubble shown on open (distinct from welcome_title/subtitle)
|
||||
welcome_message = Column(Text, nullable=True)
|
||||
chat_initiation_messages = Column(JSON, nullable=True)
|
||||
# Predefined quick-action buttons shown beneath the welcome message (list of label strings)
|
||||
quick_actions = Column(JSON, nullable=True)
|
||||
show_citations = Column(Boolean, default=False, nullable=False)
|
||||
# Optionally require the visitor's email before chatting (off by default)
|
||||
collect_email = Column(Boolean, default=False, nullable=False)
|
||||
# Small footer line telling visitors the replies come from AI. On by default:
|
||||
# the honest disclosure is the one users expect, and it is only ever hidden
|
||||
# once a human agent takes the conversation over, where it would be untrue.
|
||||
show_ai_disclaimer = Column(Boolean, default=True, nullable=False, server_default="true")
|
||||
# Lets a visitor abandon the current conversation and start a clean one. Off by
|
||||
# default: it closes the session, so a human agent mid-handover would lose the
|
||||
# thread — operators opt in when that trade is right for them.
|
||||
allow_new_chat = Column(Boolean, default=False, nullable=False, server_default="false")
|
||||
|
||||
# Relationship
|
||||
agent = relationship("Agent", back_populates="customization")
|
||||
|
||||
|
||||
|
||||
# Association table for agent-usergroup relationship
|
||||
agent_usergroup = Table(
|
||||
'agent_usergroup',
|
||||
Base.metadata,
|
||||
Column('agent_id', UUID(as_uuid=True), ForeignKey('agents.id', ondelete='CASCADE')),
|
||||
Column('group_id', UUID(as_uuid=True), ForeignKey('groups.id', ondelete='CASCADE'))
|
||||
)
|
||||
|
||||
|
||||
class Agent(Base):
|
||||
__tablename__ = "agents"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name = Column(String(100), nullable=False)
|
||||
display_name = Column(String(100), nullable=True)
|
||||
description = Column(Text)
|
||||
agent_type = Column(SQLEnum(AgentType), nullable=False)
|
||||
_instructions = Column('instructions', Text, nullable=False)
|
||||
tools = Column(Text) # Stored as JSON array of tool configurations
|
||||
is_active = Column(Boolean, default=False)
|
||||
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"))
|
||||
is_default = Column(Boolean, default=False)
|
||||
transfer_to_human = Column(Boolean, default=False, nullable=False)
|
||||
# Off means the AI never answers on this agent: every chat is queued for the
|
||||
# team from the customer's first message. Distinct from transfer_to_human,
|
||||
# which only lets the AI hand over when it decides to.
|
||||
ai_replies_enabled = Column(Boolean, default=True, nullable=False, server_default="true")
|
||||
# Offer the AI's choices as tappable buttons on messaging channels that
|
||||
# can show them (WhatsApp, Messenger, Instagram, Telegram); not the widget
|
||||
channel_quick_replies = Column(Boolean, default=False, nullable=False, server_default="false")
|
||||
ask_for_rating = Column(Boolean, default=True, nullable=True)
|
||||
# On human handoff, optionally collect the visitor's contact details
|
||||
handoff_collect_email = Column(Boolean, default=True, nullable=False)
|
||||
handoff_collect_name = Column(Boolean, default=True, nullable=False)
|
||||
enable_rate_limiting = Column(Boolean, default=False, nullable=True)
|
||||
overall_limit_per_ip = Column(Integer, default=100, nullable=True)
|
||||
requests_per_sec = Column(Float, default=1, nullable=True)
|
||||
use_workflow = Column(Boolean, default=False, nullable=True)
|
||||
active_workflow_id = Column(UUID(as_uuid=True), ForeignKey("workflows.id"), nullable=True)
|
||||
allow_attachments = Column(Boolean, default=False, nullable=False)
|
||||
# JSON array of allowed attachment type categories: ['images', 'documents', 'office', 'text']
|
||||
# If empty or null, all types are allowed when attachments are enabled
|
||||
allowed_attachment_types = Column(JSON, default=None, nullable=True)
|
||||
require_token_auth = Column(Boolean, default=False, nullable=False)
|
||||
# Per-agent switch for native AI ticketing (org plan must also allow it).
|
||||
# Defaults on so paid orgs get ticketing on every agent out of the box.
|
||||
ticketing_enabled = Column(Boolean, default=True, nullable=False, server_default="true")
|
||||
# Optional one-line business remit for the platform guardrail's topic-scope
|
||||
# line (see app.agents.guardrail_policy). Null -> derived from the agent
|
||||
# description or the organization name/domain, so it works with zero config.
|
||||
# Only the scope DESCRIPTION is tenant-editable; the enforcement text is code.
|
||||
topic_scope = Column(Text, nullable=True)
|
||||
# Tenant-editable scope rule shown under Instructions in the dashboard.
|
||||
# NULL means "use the shipped default" (guardrail_policy.DEFAULT_GUARDRAIL_PROMPT),
|
||||
# so the wording can be improved centrally without rewriting every row.
|
||||
# Editable because a code-owned topic list is a guess about what a business
|
||||
# is NOT — it refused maths for a maths tutor, algorithms for a bootcamp.
|
||||
guardrail_prompt = Column(Text, nullable=True)
|
||||
# Off means no scope rule at all. Injection and disclosure rules are not
|
||||
# covered by this toggle; they stay code-owned.
|
||||
guardrail_enabled = Column(Boolean, default=True, nullable=False, server_default="true")
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
# Relationships
|
||||
organization = relationship("Organization", back_populates="agents")
|
||||
knowledge_links = relationship(
|
||||
"KnowledgeToAgent", back_populates="agent", cascade="all, delete-orphan")
|
||||
mcp_tool_links = relationship(
|
||||
"MCPToolToAgent", back_populates="agent", cascade="all, delete-orphan")
|
||||
customization = relationship(
|
||||
"AgentCustomization", back_populates="agent", uselist=False)
|
||||
lead_capture_config = relationship(
|
||||
"LeadCaptureConfig", back_populates="agent", uselist=False,
|
||||
cascade="all, delete-orphan")
|
||||
widgets = relationship("Widget", back_populates="agent")
|
||||
chat_histories = relationship("ChatHistory", back_populates="agent")
|
||||
session_assignments = relationship("SessionToAgent", back_populates="agent")
|
||||
ratings = relationship("Rating", back_populates="agent")
|
||||
groups = relationship(
|
||||
"UserGroup",
|
||||
secondary=agent_usergroup,
|
||||
backref="agents",
|
||||
lazy="joined"
|
||||
)
|
||||
active_workflow = relationship(
|
||||
"Workflow",
|
||||
foreign_keys=[active_workflow_id],
|
||||
backref="active_agents"
|
||||
)
|
||||
workflows = relationship(
|
||||
"Workflow",
|
||||
foreign_keys="[Workflow.agent_id]",
|
||||
back_populates="agent"
|
||||
)
|
||||
@property
|
||||
def instructions(self):
|
||||
"""Get instructions as a list"""
|
||||
if not self._instructions:
|
||||
return []
|
||||
try:
|
||||
return json.loads(self._instructions)
|
||||
except json.JSONDecodeError:
|
||||
return [self._instructions]
|
||||
|
||||
@instructions.setter
|
||||
def instructions(self, value):
|
||||
"""Set instructions, converting to JSON string if needed"""
|
||||
if isinstance(value, list):
|
||||
self._instructions = json.dumps(value)
|
||||
elif isinstance(value, str):
|
||||
try:
|
||||
# Try to parse as JSON first
|
||||
parsed = json.loads(value)
|
||||
if isinstance(parsed, list):
|
||||
self._instructions = value
|
||||
else:
|
||||
self._instructions = json.dumps([value])
|
||||
except json.JSONDecodeError:
|
||||
# If not valid JSON, treat as single instruction
|
||||
self._instructions = json.dumps([value])
|
||||
else:
|
||||
raise ValueError("Instructions must be a list or string")
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Boolean, Column, Integer, String, JSON, ForeignKey, DateTime, func, Enum as SQLEnum
|
||||
from sqlalchemy.orm import relationship
|
||||
from enum import Enum
|
||||
from app.database import Base
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
class AIModelType(str, Enum):
|
||||
OPENAI = "OPENAI"
|
||||
ANTHROPIC = "ANTHROPIC"
|
||||
DEEPSEEK = "DEEPSEEK"
|
||||
GOOGLE = "GOOGLE"
|
||||
GOOGLEVERTEX = "GOOGLEVERTEX"
|
||||
GROQ = "GROQ"
|
||||
MISTRAL = "MISTRAL"
|
||||
HUGGINGFACE = "HUGGINGFACE"
|
||||
OLLAMA = "OLLAMA"
|
||||
XAI = "XAI"
|
||||
OPENAI_COMPATIBLE = "OPENAI_COMPATIBLE"
|
||||
CHATTERMATE = "CHATTERMATE" # own model for enterprise customers
|
||||
|
||||
DEFAULT_AI_SETTINGS = {
|
||||
"instructions": [
|
||||
"You are a helpful customer service agent.",
|
||||
"Be concise and professional.",
|
||||
"If you don't know something, say so.",
|
||||
"Always maintain a friendly tone."
|
||||
],
|
||||
"tools": ["web_search"],
|
||||
"memory": True,
|
||||
"markdown": True
|
||||
}
|
||||
|
||||
|
||||
class AIConfig(Base):
|
||||
__tablename__ = "ai_configs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
organization_id = Column(UUID(as_uuid=True), ForeignKey(
|
||||
"organizations.id"), nullable=False)
|
||||
model_type = Column(SQLEnum(AIModelType), nullable=False)
|
||||
model_name = Column(String, nullable=False) # e.g. "gpt-4", "claude-3"
|
||||
encrypted_api_key = Column(String, nullable=False)
|
||||
# For additional model-specific settings
|
||||
settings = Column(JSON, nullable=True, default=DEFAULT_AI_SETTINGS)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(),
|
||||
onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
organization = relationship("Organization", back_populates="ai_configs")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
import uuid
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AuthSession(Base):
|
||||
"""One signed-in dashboard session: a login and every refresh after it.
|
||||
|
||||
Access and refresh JWTs carry the row's id as their `sid` claim. The tokens
|
||||
themselves are stateless, so this row is what makes signing out stick:
|
||||
revoking it rejects both tokens before they expire.
|
||||
"""
|
||||
|
||||
__tablename__ = "auth_sessions"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
revoked_at = Column(DateTime(timezone=True), nullable=True)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from app.models.channels.channel_account import ChannelAccount, ChannelType
|
||||
from app.models.channels.channel_conversation import ChannelConversation
|
||||
from app.models.channels.agent_channel_config import AgentChannelConfig
|
||||
|
||||
__all__ = [
|
||||
"ChannelAccount",
|
||||
"ChannelType",
|
||||
"ChannelConversation",
|
||||
"AgentChannelConfig",
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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 uuid
|
||||
|
||||
from sqlalchemy import Column, Boolean, DateTime, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AgentChannelConfig(Base):
|
||||
"""Routes a connected channel account to the AI agent that answers it.
|
||||
|
||||
One config per channel account: every inbound conversation on that
|
||||
account is handled by the mapped agent.
|
||||
"""
|
||||
__tablename__ = "agent_channel_configs"
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('channel_account_id', name='uq_agent_channel_config_account'),
|
||||
)
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
agent_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("agents.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
channel_account_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("channel_accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
agent = relationship("Agent")
|
||||
account = relationship("ChannelAccount")
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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 sqlalchemy import Column, String, Text, Boolean, DateTime, ForeignKey, JSON, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ChannelType(str, enum.Enum):
|
||||
"""Messaging channels a customer conversation can arrive from.
|
||||
|
||||
Stored as plain strings (not a DB enum) so adding a channel never
|
||||
requires a schema migration. 'web' is the embedded widget and has no
|
||||
ChannelAccount row.
|
||||
"""
|
||||
WEB = "web"
|
||||
TELEGRAM = "telegram"
|
||||
WHATSAPP = "whatsapp"
|
||||
MESSENGER = "messenger"
|
||||
INSTAGRAM = "instagram"
|
||||
SLACK = "slack"
|
||||
EMAIL = "email"
|
||||
SMS = "sms"
|
||||
LINE = "line"
|
||||
API = "api"
|
||||
|
||||
|
||||
class ChannelAccount(Base):
|
||||
"""An organization's connected account on an external messaging channel.
|
||||
|
||||
One row per connected bot/number/page. Channel-specific secrets
|
||||
(bot tokens, page access tokens, WABA ids, ...) live together in
|
||||
`encrypted_credentials` as a Fernet-encrypted JSON blob so new channels
|
||||
never need schema changes.
|
||||
"""
|
||||
__tablename__ = "channel_accounts"
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('channel_type', 'external_account_id', name='uq_channel_account_external'),
|
||||
)
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
organization_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
channel_type = Column(String, nullable=False, index=True)
|
||||
# Platform-side identifier: Telegram bot id, WhatsApp phone_number_id,
|
||||
# Facebook page id, Slack team id, inbound email address, ...
|
||||
external_account_id = Column(String, nullable=False)
|
||||
# Human-readable label shown in settings: "@acme_bot", "Acme (+44...)"
|
||||
display_name = Column(String, nullable=True)
|
||||
# Fernet-encrypted JSON of channel-specific secrets
|
||||
encrypted_credentials = Column(Text, nullable=False)
|
||||
# Per-account secret used to authenticate inbound webhooks
|
||||
webhook_secret = Column(String, nullable=False)
|
||||
# Non-secret channel settings (greeting, template defaults, storage mode, ...)
|
||||
settings = Column(JSON, default=dict)
|
||||
is_active = Column(Boolean, nullable=False, default=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")
|
||||
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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 uuid
|
||||
|
||||
from sqlalchemy import Column, String, DateTime, ForeignKey, JSON, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ChannelConversation(Base):
|
||||
"""Maps an external channel conversation to a ChatterMate session.
|
||||
|
||||
The generic analog of SlackConversation: one row per
|
||||
(connected account, platform conversation, session). A new session for
|
||||
the same platform conversation (e.g. after the previous one closes)
|
||||
gets a new row.
|
||||
"""
|
||||
__tablename__ = "channel_conversations"
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
'channel_account_id', 'external_conversation_id', 'session_id',
|
||||
name='uq_channel_conversation_session'
|
||||
),
|
||||
)
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
channel_account_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("channel_accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
channel_type = Column(String, nullable=False)
|
||||
# Platform conversation id: Telegram chat_id, WhatsApp wa_id,
|
||||
# Messenger PSID, Instagram IGSID, Slack thread key, email thread id, ...
|
||||
external_conversation_id = Column(String, nullable=False, index=True)
|
||||
# Platform user id of the customer (often equals the conversation id)
|
||||
external_user_id = Column(String, nullable=False)
|
||||
|
||||
session_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("session_to_agents.session_id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Denormalized for faster webhook-path lookups
|
||||
organization_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||
nullable=False
|
||||
)
|
||||
agent_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("agents.id", ondelete="SET NULL"),
|
||||
nullable=True
|
||||
)
|
||||
customer_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("customers.id", ondelete="SET NULL"),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Timestamp of the customer's latest inbound message; drives the
|
||||
# 24h (Meta) delivery-window checks.
|
||||
last_inbound_at = Column(DateTime(timezone=True), nullable=True)
|
||||
# Channel-specific extras (message thread ids, profile snapshots, ...)
|
||||
extra = Column(JSON, default=dict)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
account = relationship("ChannelAccount")
|
||||
session = relationship("SessionToAgent")
|
||||
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, JSON, ForeignKey, TIMESTAMP, func, UUID, Interval, Float, Index, text
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.core.encryption import EncryptedText
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ChatHistory(Base):
|
||||
__tablename__ = "chat_history"
|
||||
__table_args__ = (
|
||||
# Every read of a conversation filters by session_id and orders by
|
||||
# (created_at, id) — the inbox previews, get_session_history, the AI's
|
||||
# context window. Declared here as well as in the migration so autogenerate
|
||||
# never proposes dropping it and the sqlite test schema matches Postgres.
|
||||
Index('ix_chat_history_session_created', 'session_id',
|
||||
text('created_at DESC'), text('id DESC')),
|
||||
# Delivery receipts look their message up by platform id
|
||||
Index('ix_chat_history_org_external_message', 'organization_id', 'external_message_id',
|
||||
postgresql_where=text('external_message_id IS NOT NULL'),
|
||||
sqlite_where=text('external_message_id IS NOT NULL')),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
organization_id = Column(UUID(as_uuid=True), ForeignKey(
|
||||
"organizations.id", ondelete="SET NULL"), nullable=True)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey(
|
||||
"users.id", ondelete="SET NULL"), nullable=True)
|
||||
customer_id = Column(UUID(as_uuid=True), ForeignKey(
|
||||
"customers.id", ondelete="SET NULL"), nullable=True)
|
||||
agent_id = Column(UUID(as_uuid=True), ForeignKey(
|
||||
"agents.id", ondelete="SET NULL"), nullable=True)
|
||||
session_id = Column(UUID(as_uuid=True), ForeignKey("session_to_agents.session_id"), nullable=True)
|
||||
# Encrypted at rest — see app.core.encryption. Storage stays text, so rows
|
||||
# written before the change read back as plaintext until the backfill lands.
|
||||
message = Column(EncryptedText, nullable=False)
|
||||
# 'user', 'bot', or 'agent'
|
||||
message_type = Column(String, nullable=False)
|
||||
attributes = Column(JSON, default={})
|
||||
sentiment_label = Column(String, nullable=True) # 'positive', 'neutral', 'negative'
|
||||
sentiment_score = Column(Float, nullable=True) # -1.0 to 1.0
|
||||
# Channel messages only: the platform's id for this message (WhatsApp wamid)
|
||||
# and its latest delivery receipt — app.channels.base.DeliveryState values.
|
||||
external_message_id = Column(String, nullable=True)
|
||||
channel_status = Column(String(16), nullable=True)
|
||||
channel_status_at = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||
created_at = Column(TIMESTAMP(timezone=True), server_default=func.now())
|
||||
updated_at = Column(TIMESTAMP(timezone=True), server_default=func.now(),
|
||||
onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="chat_histories")
|
||||
customer = relationship("Customer", back_populates="chat_histories")
|
||||
agent = relationship("Agent", back_populates="chat_histories")
|
||||
organization = relationship("Organization", back_populates="chat_histories")
|
||||
session_assignment = relationship("SessionToAgent")
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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 sqlalchemy import (
|
||||
Column, Integer, String, Text, DateTime, ForeignKey, JSON,
|
||||
UniqueConstraint, Index,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class _ValueStrEnum(str, enum.Enum):
|
||||
"""str-enum whose str() is the value — in-memory enum instances and
|
||||
refreshed-from-DB plain strings render identically (plain-string columns)."""
|
||||
|
||||
def __str__(self) -> str: # pragma: no cover - trivial
|
||||
return self.value
|
||||
|
||||
|
||||
class CrmProvider(_ValueStrEnum):
|
||||
"""CRMs a lead can be pushed to.
|
||||
|
||||
Stored as plain strings (not a DB enum) so adding a provider never
|
||||
requires a schema migration — mirrors ChannelType.
|
||||
"""
|
||||
HUBSPOT = "hubspot"
|
||||
PIPEDRIVE = "pipedrive"
|
||||
|
||||
|
||||
class CrmConnectionStatus(_ValueStrEnum):
|
||||
ACTIVE = "active"
|
||||
EXPIRED = "expired" # refresh token dead — needs a reconnect
|
||||
REVOKED = "revoked" # uninstalled from the CRM side
|
||||
|
||||
|
||||
class CrmSyncJobStatus(_ValueStrEnum):
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
SKIPPED = "skipped" # intentionally not pushed (no email, plan downgrade, uninstall)
|
||||
|
||||
|
||||
class CrmConnection(Base):
|
||||
"""An organization's OAuth connection to a CRM (one per provider per org).
|
||||
|
||||
All provider secrets (access/refresh tokens, scopes, per-tenant API domain)
|
||||
live together in `encrypted_credentials` as a Fernet-encrypted JSON blob so
|
||||
new providers never need schema changes. Token expiries are mirrored into
|
||||
plain columns so background sweeps can query without decrypting.
|
||||
"""
|
||||
__tablename__ = "crm_connections"
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('organization_id', 'provider', name='uq_crm_connection_org_provider'),
|
||||
UniqueConstraint('provider', 'external_account_id', name='uq_crm_connection_external'),
|
||||
)
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
organization_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
provider = Column(String(32), nullable=False, index=True)
|
||||
# Platform-side identifier: HubSpot hub_id, Pipedrive company_id, ...
|
||||
external_account_id = Column(String, nullable=False)
|
||||
# Human-readable label shown in settings: portal / company name
|
||||
display_name = Column(String, nullable=True)
|
||||
# Fernet-encrypted JSON of provider secrets (tokens, scopes, api_domain)
|
||||
encrypted_credentials = Column(Text, nullable=False)
|
||||
status = Column(String(16), nullable=False, default=CrmConnectionStatus.ACTIVE.value,
|
||||
server_default=CrmConnectionStatus.ACTIVE.value)
|
||||
last_error = Column(Text, nullable=True)
|
||||
|
||||
access_token_expires_at = Column(DateTime(timezone=True), nullable=True)
|
||||
# Pipedrive refresh tokens have a 60-day sliding expiry; NULL for HubSpot.
|
||||
refresh_token_expires_at = Column(DateTime(timezone=True), nullable=True)
|
||||
last_refreshed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
connected_by_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), 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")
|
||||
|
||||
|
||||
class CrmCustomerSync(Base):
|
||||
"""The current CRM link for a person (one row per customer + provider).
|
||||
|
||||
Written on every successful push — auto (lead capture) and manual ("Sync
|
||||
now" on the People drawer) — so the People page can show whether and where
|
||||
a person is synced without walking the job history.
|
||||
"""
|
||||
__tablename__ = "crm_customer_syncs"
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('customer_id', 'provider', name='uq_crm_customer_sync_customer_provider'),
|
||||
)
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
organization_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
customer_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("customers.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
provider = Column(String(32), nullable=False)
|
||||
contact_id = Column(String, nullable=True) # HubSpot contact / Pipedrive person
|
||||
secondary_id = Column(String, nullable=True) # Pipedrive lead id
|
||||
record_url = Column(String, nullable=True) # deep link into the CRM
|
||||
synced_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class CrmSyncJob(Base):
|
||||
"""Queue row for pushing one captured lead to one CRM.
|
||||
|
||||
The (lead_response_id, provider) unique constraint is the idempotency
|
||||
backbone: enqueue uses ON CONFLICT DO NOTHING, so double-invocation and
|
||||
worker replays can never produce a second push for the same lead.
|
||||
"""
|
||||
__tablename__ = "crm_sync_jobs"
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('lead_response_id', 'provider', name='uq_crm_sync_job_lead_provider'),
|
||||
Index('ix_crm_sync_jobs_claim', 'status', 'next_attempt_at'),
|
||||
)
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
organization_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
lead_response_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("lead_capture_responses.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
# UI context only; the push itself resolves everything from the lead response.
|
||||
agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id", ondelete="SET NULL"), nullable=True)
|
||||
provider = Column(String(32), nullable=False)
|
||||
# Resolved at process time (not enqueue) so a reconnect between capture and
|
||||
# push still delivers; stamped for audit once the job runs.
|
||||
connection_id = Column(UUID(as_uuid=True), ForeignKey("crm_connections.id", ondelete="SET NULL"), nullable=True)
|
||||
|
||||
status = Column(String(16), nullable=False, default=CrmSyncJobStatus.PENDING.value,
|
||||
server_default=CrmSyncJobStatus.PENDING.value)
|
||||
attempts = Column(Integer, nullable=False, default=0, server_default='0')
|
||||
next_attempt_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
# Set on claim; stale-claim recovery resets rows stuck in processing.
|
||||
started_at = Column(DateTime(timezone=True), nullable=True)
|
||||
last_error = Column(Text, nullable=True)
|
||||
# {"action": "created|updated", "contact_id": ..., "secondary_id": ..., "record_url": ...}
|
||||
result = Column(JSON, 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())
|
||||
|
||||
lead_response = relationship("LeadCaptureResponse")
|
||||
connection = relationship("CrmConnection")
|
||||
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Index, JSON, Enum as SQLEnum, func, text, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
import enum
|
||||
import uuid
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class LeadStage(str, enum.Enum):
|
||||
"""Lifecycle stage of a person. Visitor -> Lead is automatic on a qualifying
|
||||
capture; Lead -> Customer is a manual transition (phase 1)."""
|
||||
VISITOR = "visitor"
|
||||
LEAD = "lead"
|
||||
CUSTOMER = "customer"
|
||||
|
||||
|
||||
class Customer(Base):
|
||||
__tablename__ = "customers"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('email', 'organization_id', name='uix_customer_email_org'),
|
||||
# Phone is the second identity key: unique per org, but only where
|
||||
# present (rows without a phone are the overwhelming majority and must
|
||||
# never collide). Declared here — not only in the migration — so
|
||||
# autogenerate never sees it as unknown and drops it, and so the
|
||||
# sqlite test schema enforces the same uniqueness Postgres does
|
||||
# (sqlite_where works, and both dialects treat NULLs as distinct).
|
||||
Index('uix_customer_phone_org', 'organization_id', 'phone', unique=True,
|
||||
postgresql_where=text('phone IS NOT NULL'),
|
||||
sqlite_where=text('phone IS NOT NULL')),
|
||||
)
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
email = Column(String, nullable=False)
|
||||
full_name = Column(String)
|
||||
# Second identity key beside email, normalized E.164 with leading '+'
|
||||
# (see app/utils/phone.py). Set by phone-bearing channels (WhatsApp, SMS,
|
||||
# Telegram share-contact) and by lead capture; uniqueness via the partial
|
||||
# index in __table_args__. Name is never an identity key.
|
||||
phone = Column(String, nullable=True)
|
||||
# Arbitrary integrator-supplied fields (e.g. student_name, center_name) passed via
|
||||
# POST /generate-token's `custom_data` and surfaced to agents in the chat inbox.
|
||||
meta_data = Column(JSON, nullable=True)
|
||||
# Widget conversation key of the browser that created this customer (see
|
||||
# conversation_token.conversation_scope). A widget token the embedding app
|
||||
# did not vouch for may only edit or merge a customer its own scope created.
|
||||
conversation_key = Column(String(64), nullable=True)
|
||||
# True when the embedding app identified this person via POST /generate-token with a
|
||||
# known identity (customer_email) — an existing/authenticated customer of the business,
|
||||
# not an organic lead the agent captured. Used to exclude them from the People/leads
|
||||
# views. Deliberately an explicit flag, NOT inferred from meta_data: meta_data can be
|
||||
# absent for an authenticated user and present for an anonymous one, and SQLAlchemy
|
||||
# persists an empty JSON value as JSON null (not SQL NULL), so `meta_data IS NULL`
|
||||
# never matched reliably.
|
||||
is_authenticated = Column(Boolean, default=False, server_default="false", nullable=False)
|
||||
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
# Lead lifecycle — denormalized onto the customer so the People page can filter/aggregate
|
||||
# by stage without joining lead_capture_responses on every list request.
|
||||
lead_stage = Column(SQLEnum(LeadStage), default=LeadStage.VISITOR, server_default="VISITOR", nullable=False)
|
||||
lead_source = Column(JSON, nullable=True) # {"page_url": ..., "channel": "widget", "captured_at": iso}
|
||||
lead_qualified_at = Column(DateTime(timezone=True), nullable=True)
|
||||
# Set when this (anonymous) customer was merged into an existing identified customer —
|
||||
# e.g. the visitor gave an email that already belongs to another record. Merged rows are
|
||||
# hidden from the People page; old device tokens carrying this id resolve to the target.
|
||||
merged_into_customer_id = Column(UUID(as_uuid=True), ForeignKey("customers.id"), nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(),
|
||||
onupdate=func.now())
|
||||
|
||||
# Define relationships
|
||||
organization = relationship("Organization", back_populates="customers")
|
||||
chat_histories = relationship("ChatHistory", back_populates="customer")
|
||||
session_assignments = relationship("SessionToAgent", back_populates="customer")
|
||||
ratings = relationship("Rating", back_populates="customer")
|
||||
lead_capture_responses = relationship(
|
||||
"LeadCaptureResponse", back_populates="customer", cascade="all, delete-orphan")
|
||||
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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 sqlalchemy import Column, DateTime, ForeignKey, Index, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
DEFAULT_FAQ_CATEGORY = "General"
|
||||
# Column widths, kept here so the API schemas bound input to exactly what the
|
||||
# columns accept instead of restating the numbers (see models.schemas.faq).
|
||||
FAQ_SLUG_MAX_LENGTH = 80
|
||||
FAQ_META_TITLE_MAX_LENGTH = 120
|
||||
FAQ_META_DESCRIPTION_MAX_LENGTH = 300
|
||||
FAQ_URL_PATH_MAX_LENGTH = 400
|
||||
FAQ_SOURCE_URL_MAX_LENGTH = 2048
|
||||
|
||||
|
||||
class FAQStatus(str, enum.Enum):
|
||||
DRAFT = "draft"
|
||||
PUBLISHED = "published"
|
||||
|
||||
|
||||
class FAQ(Base):
|
||||
"""A single question/answer pair shown on the org's public help center.
|
||||
|
||||
FAQs are a standalone projection of the knowledge base: generation only
|
||||
reads knowledge content, and editing/deleting FAQs never touches the
|
||||
knowledge tables. Drafts stay private until explicitly published.
|
||||
"""
|
||||
__tablename__ = "faqs"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
# No single-column index: both composite indexes below lead with org id.
|
||||
organization_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
question = Column(Text, nullable=False)
|
||||
# Answer is authored/stored as Markdown; rendered to sanitized HTML on the
|
||||
# public article page (app.services.help_center_content).
|
||||
answer = Column(Text, nullable=False)
|
||||
# URL slug for the public article page (/a/{slug}). Assigned from the
|
||||
# question at creation and kept stable so published article URLs don't churn
|
||||
# on edits — but editable by hand for SEO (app.api.help_center.faqs).
|
||||
# Unique per org (see ix_faqs_org_slug); NULL until assigned.
|
||||
slug = Column(String(FAQ_SLUG_MAX_LENGTH), nullable=True)
|
||||
# The original URL path of an article migrated in from another help center.
|
||||
# When set, the article is SERVED at this exact path and /a/{slug} 301s to
|
||||
# it, so the org keeps the URLs it already ranks for instead of losing that
|
||||
# SEO to a migration. Root-relative, no trailing slash, and percent-DECODED
|
||||
# because that is the form the ASGI scope hands us to match against (every
|
||||
# emitted URL re-encodes it — see help_center_seo.article_url).
|
||||
# Unique per org (see ix_faqs_org_url_path); NULL means "served at /a/{slug}",
|
||||
# which is every article that wasn't migrated.
|
||||
url_path = Column(String(FAQ_URL_PATH_MAX_LENGTH), nullable=True)
|
||||
# Where an imported article was fetched from, post-redirect. Provenance only
|
||||
# — never routed on — and kept even when url_path had to be dropped, so the
|
||||
# original URL is still recoverable.
|
||||
source_url = Column(String(FAQ_SOURCE_URL_MAX_LENGTH), nullable=True)
|
||||
# Per-article SEO overrides for the public page's <title> and meta
|
||||
# description. NULL means "derive from the content": the question and an
|
||||
# excerpt of the answer (see app.api.help_center_public). Lengths mirror
|
||||
# help_center_settings.title/description.
|
||||
meta_title = Column(String(FAQ_META_TITLE_MAX_LENGTH), nullable=True)
|
||||
meta_description = Column(String(FAQ_META_DESCRIPTION_MAX_LENGTH), nullable=True)
|
||||
# Free-form category assigned by the LLM grouping step (or the user).
|
||||
category = Column(String(100), nullable=False, default=DEFAULT_FAQ_CATEGORY)
|
||||
# Plain string (values from FAQStatus), like knowledge_queue.status — new
|
||||
# states never need an ALTER TYPE migration.
|
||||
status = Column(String, nullable=False, default=FAQStatus.DRAFT)
|
||||
# Provenance: the knowledge source it was generated from (if any) plus a
|
||||
# human-readable label ("example.com/docs", "Imported from support.x.com",
|
||||
# "Added manually") that survives source deletion.
|
||||
knowledge_id = Column(Integer, ForeignKey("knowledge.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
source_label = Column(String(255), nullable=True)
|
||||
generation_job_id = Column(
|
||||
Integer, ForeignKey("faq_generation_jobs.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
sort_order = Column(Integer, nullable=False, default=0)
|
||||
# "Was this helpful?" tallies (de-duplicated per IP/day at the API).
|
||||
helpful_yes = Column(Integer, nullable=False, default=0, server_default="0")
|
||||
helpful_no = Column(Integer, nullable=False, default=0, server_default="0")
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
organization = relationship("Organization")
|
||||
knowledge = relationship("Knowledge")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_faqs_org_status", "organization_id", "status"),
|
||||
Index("ix_faqs_org_category", "organization_id", "category"),
|
||||
# Serves the public /a/{slug} lookup and enforces per-org uniqueness.
|
||||
# Multiple NULL slugs coexist (Postgres treats NULLs as distinct).
|
||||
Index("ix_faqs_org_slug", "organization_id", "slug", unique=True),
|
||||
# Serves the preserved-path lookup made for any URL no other public
|
||||
# route claimed, and enforces per-org uniqueness. NULLs coexist.
|
||||
Index("ix_faqs_org_url_path", "organization_id", "url_path", unique=True),
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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
|
||||
|
||||
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, ForeignKey, Index, Integer, String
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.sql import expression, func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class FAQJobType(str, enum.Enum):
|
||||
# Generate from every knowledge source in the org (the "Generate" button).
|
||||
GENERATE_ALL = "generate_all"
|
||||
# Generate from one newly processed source (the auto-generation hook).
|
||||
GENERATE_SOURCE = "generate_source"
|
||||
# Extract Q&A pairs from an external FAQ page URL (migration).
|
||||
IMPORT_URL = "import_url"
|
||||
# Crawl an external help-center index and import linked articles as-is
|
||||
# (HTML→Markdown, no LLM).
|
||||
IMPORT_ARTICLES = "import_articles"
|
||||
# Extract Q&A pairs from an uploaded PDF.
|
||||
IMPORT_PDF = "import_pdf"
|
||||
|
||||
|
||||
class FAQJobStatus(str, enum.Enum):
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class FAQJobStage(str, enum.Enum):
|
||||
NOT_STARTED = "not_started"
|
||||
ANALYZING_SOURCES = "analyzing_sources"
|
||||
EXTRACTING = "extracting"
|
||||
DRAFTING = "drafting"
|
||||
GROUPING = "grouping"
|
||||
COMPLETED = "completed"
|
||||
|
||||
|
||||
# Statuses that count as "still running" for enqueue guards and UI polling —
|
||||
# derived so a future status defaults to "active" rather than silently
|
||||
# escaping the duplicate-job guard.
|
||||
TERMINAL_FAQ_JOB_STATUSES = {FAQJobStatus.COMPLETED, FAQJobStatus.FAILED}
|
||||
ACTIVE_FAQ_JOB_STATUSES = [s.value for s in FAQJobStatus if s not in TERMINAL_FAQ_JOB_STATUSES]
|
||||
|
||||
|
||||
class FAQGenerationJob(Base):
|
||||
"""Async FAQ generation/import job, processed by the FAQ worker.
|
||||
|
||||
Mirrors the KnowledgeQueue shape (string status/stage + float progress) so
|
||||
the established worker and frontend polling patterns carry over.
|
||||
"""
|
||||
__tablename__ = "faq_generation_jobs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
organization_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
# Nullable: auto-generation jobs may be enqueued without an acting user.
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
job_type = Column(String, nullable=False, default=FAQJobType.GENERATE_ALL)
|
||||
# GENERATE_SOURCE: the knowledge source to read. NULL for GENERATE_ALL/
|
||||
# IMPORT_URL. SET NULL (not CASCADE) so deleting a source mid-run doesn't
|
||||
# yank the job row out from under the worker and the UI's polling.
|
||||
knowledge_id = Column(Integer, ForeignKey("knowledge.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
# GENERATE_ALL: optional explicit narrowing to specific knowledge sources
|
||||
# (overrides the default skip-already-generated behaviour).
|
||||
knowledge_ids = Column(JSON, nullable=True)
|
||||
# IMPORT_URL: the external page to extract from. IMPORT_PDF: the stored
|
||||
# upload path/URL the worker reads the file from.
|
||||
source_url = Column(String, nullable=True)
|
||||
# IMPORT_PDF: the original client filename, used for source_label.
|
||||
source_file_name = Column(String(255), nullable=True)
|
||||
# IMPORT_ARTICLES: keep every imported article at the URL path it already
|
||||
# has on the source help center (FAQ.url_path), so migrating in doesn't
|
||||
# move — and cost the org — the URLs it already ranks for.
|
||||
preserve_source_urls = Column(
|
||||
Boolean, nullable=False, default=False, server_default=expression.false()
|
||||
)
|
||||
# LLM calls attempted by this job (retries included) — the usage unit for
|
||||
# hosted-model metering. `metered` is stamped at enqueue time from the
|
||||
# org's AI config so the period sum never depends on later config changes.
|
||||
llm_calls = Column(Integer, nullable=False, default=0, server_default="0")
|
||||
metered = Column(Boolean, nullable=False, default=True, server_default=expression.true())
|
||||
status = Column(String, nullable=False, default=FAQJobStatus.PENDING)
|
||||
stage = Column(String, nullable=False, default=FAQJobStage.NOT_STARTED)
|
||||
progress_percentage = Column(Float, nullable=False, default=0.0)
|
||||
faqs_created = Column(Integer, nullable=False, default=0)
|
||||
error = Column(String, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
# Partial index keeping the worker's poll O(active rows).
|
||||
Index(
|
||||
"ix_faq_generation_jobs_active",
|
||||
"status",
|
||||
postgresql_where=(status.in_(ACTIVE_FAQ_JOB_STATUSES)),
|
||||
),
|
||||
# NOTE: production also has uq_faq_generation_jobs_one_active — a
|
||||
# UNIQUE partial index on (organization_id, job_type,
|
||||
# COALESCE(knowledge_id, -1)) WHERE status is active, closing the
|
||||
# enqueue check-then-insert race. It lives only in the migration:
|
||||
# declaring it here would create it WITHOUT the partial WHERE on
|
||||
# sqlite (tests), wrongly blocking re-enqueues after completion.
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, String, DateTime, ForeignKey, func
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
import uuid
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class FCMToken(Base):
|
||||
"""One web-push token per signed-in browser.
|
||||
|
||||
Replaces the old single `users.fcm_token_web` column, which held one token
|
||||
for the whole account: signing in on a laptop overwrote the phone's token
|
||||
and signing out anywhere cleared it for every device, so the phone silently
|
||||
stopped receiving pushes.
|
||||
|
||||
The token is minted by the browser, not by us, and FCM returns the *same*
|
||||
value for a given browser install regardless of who is signed in — hence the
|
||||
unique constraint plus reassign-on-register in the repository, so a shared
|
||||
browser moves its token to the new owner instead of delivering that user's
|
||||
notifications to the previous one.
|
||||
"""
|
||||
|
||||
__tablename__ = "fcm_tokens"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
token = Column(String, nullable=False, unique=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
user = relationship("User", back_populates="fcm_tokens")
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, BigInteger, ForeignKey, TIMESTAMP, func, UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class FileAttachment(Base):
|
||||
__tablename__ = "file_attachments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
file_url = Column(String, nullable=False) # AWS S3 URL or local path
|
||||
filename = Column(String, nullable=False) # Original filename
|
||||
content_type = Column(String, nullable=False) # MIME type
|
||||
file_size = Column(BigInteger, nullable=False) # File size in bytes
|
||||
|
||||
# Links to chat history message
|
||||
chat_history_id = Column(Integer, ForeignKey("chat_history.id", ondelete="CASCADE"), nullable=True)
|
||||
|
||||
# Additional metadata
|
||||
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False)
|
||||
uploaded_by_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
uploaded_by_customer_id = Column(UUID(as_uuid=True), ForeignKey("customers.id", ondelete="SET NULL"), nullable=True)
|
||||
|
||||
created_at = Column(TIMESTAMP(timezone=True), server_default=func.now())
|
||||
updated_at = Column(TIMESTAMP(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
chat_history = relationship("ChatHistory", backref="attachments")
|
||||
organization = relationship("Organization")
|
||||
uploaded_by_user = relationship("User", foreign_keys=[uploaded_by_user_id])
|
||||
uploaded_by_customer = relationship("Customer", foreign_keys=[uploaded_by_customer_id])
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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 uuid
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
TIMESTAMP,
|
||||
Boolean,
|
||||
Column,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
UUID,
|
||||
func,
|
||||
)
|
||||
|
||||
from app.core.encryption import EncryptedText
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class GuardrailEvent(Base):
|
||||
"""One row per guardrail trigger across every surface (widget, channels,
|
||||
workflow, help center).
|
||||
|
||||
Append-only: this is the record that answers "is off-topic abuse actually
|
||||
material?" and "is a rule wrongly refusing real customers?" — the review
|
||||
gate before any detection rule is promoted from counting to blocking.
|
||||
|
||||
`matched` holds rule ids / short match tokens ONLY — the column is plain
|
||||
JSON (unencrypted), so raw visitor text must never land in it. The
|
||||
reviewable text lives in `excerpt`, which is encrypted at rest exactly
|
||||
like ChatHistory.message.
|
||||
"""
|
||||
|
||||
__tablename__ = "guardrail_events"
|
||||
__table_args__ = (
|
||||
# Declared here as well as in the migration so autogenerate never
|
||||
# proposes dropping them and the sqlite test schema matches Postgres.
|
||||
Index("ix_guardrail_events_org_created", "organization_id", "created_at"),
|
||||
Index("ix_guardrail_events_rule_created", "rule", "created_at"),
|
||||
)
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
organization_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
agent_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("agents.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
# No FK: a help-center /ask event has no chat session at all.
|
||||
session_id = Column(UUID(as_uuid=True), nullable=True)
|
||||
# widget | channel | workflow | help_center
|
||||
surface = Column(String(20), nullable=False)
|
||||
# inbound | output
|
||||
layer = Column(String(16), nullable=False)
|
||||
# Stable rule id, e.g. injection.frame_tokens, offtopic.model_refused
|
||||
rule = Column(String(64), nullable=False)
|
||||
# blocked | counted | replaced
|
||||
action = Column(String(16), nullable=False)
|
||||
score = Column(Float, nullable=True)
|
||||
# Rule ids / short match tokens only — NEVER raw visitor text (plain JSON).
|
||||
matched = Column(JSON, default=None, nullable=True)
|
||||
# Input length so wasted-token cost is estimable (~ char_len / 4).
|
||||
char_len = Column(Integer, nullable=True)
|
||||
# First 300 chars of the offending text, encrypted at rest; gated by
|
||||
# GUARDRAIL_STORE_EXCERPT.
|
||||
excerpt = Column(EncryptedText, nullable=True)
|
||||
# Review workflow: precision denominator for the strict-mode decision.
|
||||
reviewed = Column(Boolean, default=False, nullable=False)
|
||||
false_positive = Column(Boolean, default=False, nullable=False)
|
||||
created_at = Column(TIMESTAMP(timezone=True), server_default=func.now())
|
||||
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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 sqlalchemy import Boolean, CheckConstraint, Column, DateTime, ForeignKey, JSON, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
DEFAULT_BRAND_COLOR = "#4338CA"
|
||||
DEFAULT_CTA_TEXT = "Contact us"
|
||||
|
||||
|
||||
class DomainStatus(str, enum.Enum):
|
||||
UNVERIFIED = "unverified"
|
||||
PENDING = "pending"
|
||||
VERIFIED = "verified"
|
||||
|
||||
|
||||
class SSLStatus(str, enum.Enum):
|
||||
NONE = "none"
|
||||
PENDING = "pending"
|
||||
ACTIVE = "active"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class HelpCenterSettings(Base):
|
||||
"""Per-org public help center configuration (branding, agent mapping,
|
||||
subdomain slug and optional custom domain). One row per organization,
|
||||
created lazily the first time the org opens the FAQ admin page.
|
||||
"""
|
||||
__tablename__ = "help_center_settings"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
organization_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
)
|
||||
# Whether the public site is live at {slug}.{HELP_CENTER_BASE_DOMAIN}.
|
||||
enabled = Column(Boolean, nullable=False, default=False)
|
||||
slug = Column(String(63), unique=True, nullable=True, index=True)
|
||||
|
||||
# Branding
|
||||
title = Column(String(120), nullable=True)
|
||||
description = Column(String(300), nullable=True)
|
||||
logo_url = Column(String, nullable=True)
|
||||
favicon_url = Column(String, nullable=True)
|
||||
brand_color = Column(String(9), nullable=False, default=DEFAULT_BRAND_COLOR)
|
||||
# [{"label": str, "url": str}, ...]
|
||||
header_links = Column(JSON, nullable=False, default=lambda: [])
|
||||
cta_text = Column(String(40), nullable=True, default=DEFAULT_CTA_TEXT)
|
||||
cta_url = Column(String, nullable=True)
|
||||
# Whether the primary button (CTA) is shown in the public header.
|
||||
cta_enabled = Column(Boolean, nullable=False, default=True)
|
||||
|
||||
# Draft FAQs are auto-generated when a new knowledge source finishes.
|
||||
auto_generate = Column(Boolean, nullable=False, default=True)
|
||||
|
||||
# Which agent grounds "Ask AI" answers and whose chat widget is embedded on
|
||||
# the public page. The two surfaces toggle independently:
|
||||
# - ai_search_enabled: the inline AI quick-summary answer in search.
|
||||
# - chat_widget_enabled: the embedded chat widget ("Start a chat").
|
||||
agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id", ondelete="SET NULL"), nullable=True)
|
||||
ai_search_enabled = Column(Boolean, nullable=False, default=True)
|
||||
chat_widget_enabled = Column(Boolean, nullable=False, default=True)
|
||||
|
||||
# Custom domain. Verification state is the two per-record booleans; the
|
||||
# aggregate status is derived (see domain_status) so the pieces can never
|
||||
# disagree.
|
||||
custom_domain = Column(String(255), unique=True, nullable=True, index=True)
|
||||
domain_verification_token = Column(String(64), nullable=True)
|
||||
txt_record_verified = Column(Boolean, nullable=False, default=False)
|
||||
cname_record_verified = Column(Boolean, nullable=False, default=False)
|
||||
# Plain string (values from SSLStatus) — an external fact set by the
|
||||
# verification probe, stored like knowledge_queue's string statuses.
|
||||
ssl_status = Column(String, nullable=False, default=SSLStatus.NONE)
|
||||
domain_verified_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
organization = relationship("Organization")
|
||||
agent = relationship("Agent")
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("slug = lower(slug)", name="ck_help_center_slug_lowercase"),
|
||||
)
|
||||
|
||||
@property
|
||||
def domain_verified(self) -> bool:
|
||||
return bool(self.custom_domain) and self.txt_record_verified and self.cname_record_verified
|
||||
|
||||
@property
|
||||
def domain_status(self) -> DomainStatus:
|
||||
if not self.custom_domain:
|
||||
return DomainStatus.UNVERIFIED
|
||||
return DomainStatus.VERIFIED if self.domain_verified else DomainStatus.PENDING
|
||||
|
||||
|
||||
class HelpCenterQuery(Base):
|
||||
"""Log of public "Ask AI" questions — used for per-period metering on
|
||||
hosted models and (later) search analytics. No answer text is stored.
|
||||
"""
|
||||
__tablename__ = "help_center_queries"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
organization_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
query = Column(Text, nullable=False)
|
||||
answered = Column(Boolean, nullable=False, default=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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 sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSON, UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import expression, func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
from app.models.ticket import _ValueStrEnum
|
||||
|
||||
|
||||
class InvestigationRunType(_ValueStrEnum):
|
||||
TRIAGE = "triage"
|
||||
INVESTIGATION = "investigation"
|
||||
|
||||
|
||||
class InvestigationRunStatus(_ValueStrEnum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
BUDGET_EXCEEDED = "budget_exceeded"
|
||||
|
||||
|
||||
class InvestigationTrigger(_ValueStrEnum):
|
||||
AUTO_ON_CREATE = "auto_on_create"
|
||||
MANUAL = "manual"
|
||||
RETRY = "retry"
|
||||
REJECTION_FEEDBACK = "rejection_feedback"
|
||||
|
||||
|
||||
# Derived like ACTIVE_FAQ_JOB_STATUSES: a future status defaults to "active"
|
||||
# rather than silently escaping the worker poll and duplicate-run guards.
|
||||
TERMINAL_RUN_STATUSES = {
|
||||
InvestigationRunStatus.COMPLETED,
|
||||
InvestigationRunStatus.FAILED,
|
||||
InvestigationRunStatus.CANCELLED,
|
||||
InvestigationRunStatus.BUDGET_EXCEEDED,
|
||||
}
|
||||
ACTIVE_RUN_STATUSES = [s.value for s in InvestigationRunStatus if s not in TERMINAL_RUN_STATUSES]
|
||||
|
||||
|
||||
class InvestigationRun(Base):
|
||||
"""One AI triage/investigation pass over a ticket. Doubles as the worker
|
||||
queue table — the ticket_investigator worker polls status='pending' rows,
|
||||
mirroring the faq_generation_jobs pattern.
|
||||
"""
|
||||
__tablename__ = "investigation_runs"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
ticket_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tickets.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
organization_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
run_type = Column(String, nullable=False, default=InvestigationRunType.TRIAGE)
|
||||
status = Column(String, nullable=False, default=InvestigationRunStatus.PENDING)
|
||||
trigger = Column(String, nullable=False, default=InvestigationTrigger.AUTO_ON_CREATE)
|
||||
requested_by_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
# Free-text context for the run, e.g. the human's rejection reason feeding
|
||||
# a refined investigation.
|
||||
context_note = Column(Text, nullable=True)
|
||||
|
||||
# Budgets and actuals (cost tracking mirrors FAQGenerationJob.llm_calls).
|
||||
max_tool_calls = Column(Integer, nullable=False, default=25, server_default="25")
|
||||
max_wall_seconds = Column(Integer, nullable=False, default=600, server_default="600")
|
||||
tool_calls_used = Column(Integer, nullable=False, default=0, server_default="0")
|
||||
llm_calls = Column(Integer, nullable=False, default=0, server_default="0")
|
||||
input_tokens = Column(BigInteger, nullable=False, default=0, server_default="0")
|
||||
output_tokens = Column(BigInteger, nullable=False, default=0, server_default="0")
|
||||
model_name = Column(String, nullable=True)
|
||||
# True when this run's LLM usage counts against the org's message budget —
|
||||
# set only for the platform-hosted model (own-key orgs pay their provider
|
||||
# directly). Mirrors FAQGenerationJob.metered; summed by the enterprise
|
||||
# message-limit check.
|
||||
metered = Column(Boolean, nullable=False, default=False, server_default="false")
|
||||
|
||||
# MCP connector outcome for the run: {"configured": N, "loaded": M,
|
||||
# "failed": [{"name", "error"}]}. Lets the dashboard show "ran with 0 of N
|
||||
# configured tools" instead of a silently evidence-free investigation.
|
||||
connector_status = Column(JSON, nullable=True)
|
||||
|
||||
error = Column(Text, nullable=True)
|
||||
started_at = Column(DateTime(timezone=True), nullable=True)
|
||||
finished_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
ticket = relationship("Ticket")
|
||||
|
||||
__table_args__ = (
|
||||
# Keeps the worker's poll O(active rows) as terminal rows accumulate.
|
||||
Index(
|
||||
"ix_investigation_runs_active",
|
||||
"status",
|
||||
postgresql_where=(status.in_(ACTIVE_RUN_STATUSES)),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class HypothesisStatus(_ValueStrEnum):
|
||||
PENDING = "pending"
|
||||
TESTING = "testing"
|
||||
VALIDATED = "validated"
|
||||
INVALIDATED = "invalidated"
|
||||
INCONCLUSIVE = "inconclusive"
|
||||
|
||||
|
||||
class InvestigationHypothesis(Base):
|
||||
"""One hypothesis (H1..Hn) of an investigation run. Each is tested with
|
||||
bounded tool calls and marked validated/invalidated/inconclusive with a
|
||||
confidence — the glass-box UI renders these as cards."""
|
||||
__tablename__ = "investigation_hypotheses"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
run_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("investigation_runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
ticket_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tickets.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
# 1-based display order (H1, H2, ...).
|
||||
idx = Column(Integer, nullable=False, default=1)
|
||||
title = Column(String(300), nullable=False)
|
||||
rationale = Column(Text, nullable=True)
|
||||
status = Column(String, nullable=False, default=HypothesisStatus.PENDING)
|
||||
confidence = Column(Float, nullable=True)
|
||||
# What testing showed, written after the verdict.
|
||||
conclusion = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
|
||||
class InvestigationEventType(_ValueStrEnum):
|
||||
# A phase marker ("Generating hypotheses", "Testing H2") — powers the
|
||||
# live ticker line.
|
||||
PHASE = "phase"
|
||||
TOOL_CALL = "tool_call"
|
||||
|
||||
|
||||
class InvestigationEvent(Base):
|
||||
"""Evidence is a first-class row, never just markdown: every tool call the
|
||||
investigator makes is captured here (query, result snippet, timing) via
|
||||
agno tool_hooks. RCA citations and the glass-box UI derive from these."""
|
||||
__tablename__ = "investigation_events"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
run_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("investigation_runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
ticket_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tickets.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
hypothesis_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("investigation_hypotheses.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
# Monotonic order within the run.
|
||||
seq = Column(Integer, nullable=False, default=0)
|
||||
event_type = Column(String, nullable=False, default=InvestigationEventType.TOOL_CALL)
|
||||
# Human-readable line for phase events / the ticker.
|
||||
label = Column(String(300), nullable=True)
|
||||
tool_name = Column(String(200), nullable=True)
|
||||
# Which configured connector (MCP server) served the call.
|
||||
connector_name = Column(String(200), nullable=True)
|
||||
# PII-redacted and truncated at capture time — raw payloads never land.
|
||||
tool_input = Column(Text, nullable=True)
|
||||
tool_result = Column(Text, nullable=True)
|
||||
duration_ms = Column(Integer, nullable=True)
|
||||
error = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
__table_args__ = (Index("ix_investigation_events_run_seq", "run_id", "seq"),)
|
||||
|
||||
|
||||
class ProposalStatus(_ValueStrEnum):
|
||||
PENDING = "pending"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
SUPERSEDED = "superseded"
|
||||
|
||||
|
||||
class TicketProposal(Base):
|
||||
"""Autonomy L2: a resolution the AI proposes and a human decides on.
|
||||
Approval resolves the ticket and notifies the customer; a rejection can
|
||||
feed a refined investigation run (the reason becomes its context_note).
|
||||
ChatterMate never executes infrastructure changes — approval records the
|
||||
decision, humans/runbooks perform any mutation."""
|
||||
__tablename__ = "ticket_proposals"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
ticket_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tickets.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
organization_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
run_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("investigation_runs.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
# Internal proposal: what the AI believes resolves the ticket and why.
|
||||
summary = Column(Text, nullable=False)
|
||||
# Plain-language message sent to the customer on approval.
|
||||
customer_message = Column(Text, nullable=True)
|
||||
confidence = Column(Float, nullable=True)
|
||||
status = Column(String, nullable=False, default=ProposalStatus.PENDING)
|
||||
decided_by_user_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
decided_at = Column(DateTime(timezone=True), nullable=True)
|
||||
reject_reason = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
decided_by = relationship("User", foreign_keys=[decided_by_user_id])
|
||||
|
||||
|
||||
class RCADocument(Base):
|
||||
"""Structured, versioned root-cause analysis generated from captured
|
||||
evidence. customer_summary is the plain-language section a human edits and
|
||||
sends; reviewed_by_user_id powers the "reviewed by" byline."""
|
||||
__tablename__ = "rca_documents"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
ticket_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tickets.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
run_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("investigation_runs.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
version = Column(Integer, nullable=False, default=1)
|
||||
|
||||
summary = Column(Text, nullable=True)
|
||||
impact = Column(Text, nullable=True)
|
||||
# [{"time": "...", "event": "..."}] — ISO timestamps or relative labels.
|
||||
timeline = Column(JSON, nullable=True)
|
||||
investigation_log = Column(Text, nullable=True)
|
||||
# ["...", ...]
|
||||
contributing_factors = Column(JSON, nullable=True)
|
||||
# Cites hypotheses/evidence inline as [H1 · 0.92].
|
||||
conclusion = Column(Text, nullable=True)
|
||||
remediation = Column(Text, nullable=True)
|
||||
prevention = Column(Text, nullable=True)
|
||||
customer_summary = Column(Text, nullable=True)
|
||||
confidence = Column(Float, nullable=True)
|
||||
# Set when the investigation ended early (wall-clock/tool budget).
|
||||
is_partial = Column(Boolean, nullable=False, default=False, server_default=expression.false())
|
||||
|
||||
generated_by = Column(String, nullable=False, default="ai", server_default="ai")
|
||||
reviewed_by_user_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
reviewed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
reviewed_by = relationship("User", foreign_keys=[reviewed_by_user_id])
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("ticket_id", "version", name="uq_rca_documents_ticket_version"),
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
import uuid
|
||||
|
||||
from app.database import Base
|
||||
|
||||
class JiraToken(Base):
|
||||
"""Model for storing Jira OAuth tokens."""
|
||||
__tablename__ = "jira_tokens"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False)
|
||||
access_token = Column(String, nullable=False)
|
||||
refresh_token = Column(String, nullable=False)
|
||||
token_type = Column(String, nullable=False)
|
||||
expires_at = Column(DateTime, nullable=False)
|
||||
cloud_id = Column(String, nullable=False) # Jira Cloud instance ID
|
||||
site_url = Column(String, nullable=False) # Jira instance URL
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationship
|
||||
organization = relationship("Organization", back_populates="jira_tokens")
|
||||
|
||||
class AgentJiraConfig(Base):
|
||||
"""Model for storing agent-to-Jira configuration."""
|
||||
__tablename__ = "agent_jira_configs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
agent_id = Column(String, nullable=False, index=True)
|
||||
enabled = Column(Boolean, default=False, nullable=False)
|
||||
project_key = Column(String, nullable=True)
|
||||
issue_type_id = Column(String, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, func, Enum as SQLEnum
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
import enum
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
class SourceType(str, enum.Enum):
|
||||
DATABASE = "database"
|
||||
FILE = "file"
|
||||
API = "api"
|
||||
WEBSITE = "website"
|
||||
CUSTOM = "custom"
|
||||
|
||||
|
||||
class Knowledge(Base):
|
||||
__tablename__ = "knowledge"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
# URL, file path, or connection string
|
||||
source = Column(String, nullable=False)
|
||||
source_type = Column(SQLEnum(SourceType), nullable=False)
|
||||
schema = Column(String) # JSON schema of the data structure
|
||||
table_name = Column(String)
|
||||
organization_id = Column(UUID(as_uuid=True), ForeignKey(
|
||||
"organizations.id"), nullable=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(),
|
||||
onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
organization = relationship(
|
||||
"Organization", back_populates="knowledge_sources")
|
||||
agent_links = relationship(
|
||||
"KnowledgeToAgent", back_populates="knowledge", cascade="all, delete-orphan")
|
||||
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, JSON, DateTime, Enum, ForeignKey, Float, Index
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
import enum
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
class QueueStatus(str, enum.Enum):
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
class ProcessingStage(str, enum.Enum):
|
||||
NOT_STARTED = "not_started"
|
||||
CRAWLING = "crawling"
|
||||
EMBEDDING = "embedding"
|
||||
COMPLETED = "completed"
|
||||
|
||||
|
||||
class KnowledgeQueue(Base):
|
||||
__tablename__ = "knowledge_queue"
|
||||
# The plan's source limit counts the crawls still in flight, so this is
|
||||
# read on every add.
|
||||
__table_args__ = (
|
||||
Index("ix_knowledge_queue_org_status", "organization_id", "status"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
organization_id = Column(UUID(as_uuid=True), nullable=False)
|
||||
agent_id = Column(UUID(as_uuid=True), nullable=True)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) # Nullable for Shopify session token auth
|
||||
# 'pdf_file', 'pdf_url', 'website'
|
||||
source_type = Column(String, nullable=False)
|
||||
source = Column(String, nullable=False) # File path or URL
|
||||
status = Column(String, default=QueueStatus.PENDING)
|
||||
error = Column(String, nullable=True)
|
||||
# Renamed from metadata to queue_metadata
|
||||
queue_metadata = Column(JSON, nullable=True)
|
||||
# Priority: higher number = higher priority (default 0, explore URLs get 10)
|
||||
priority = Column(Integer, default=0, index=True)
|
||||
# Progress tracking
|
||||
processing_stage = Column(String, default=ProcessingStage.NOT_STARTED)
|
||||
progress_percentage = Column(Float, default=0.0)
|
||||
total_items = Column(Integer, default=0)
|
||||
processed_items = Column(Integer, default=0)
|
||||
crawled_urls = Column(JSON, default=lambda: [])
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Add relationship
|
||||
user = relationship("User", back_populates="knowledge_queue_items")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, ForeignKey, DateTime, func
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
class KnowledgeToAgent(Base):
|
||||
__tablename__ = "knowledge_to_agents"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
knowledge_id = Column(Integer, ForeignKey(
|
||||
"knowledge.id", ondelete="CASCADE"), nullable=False)
|
||||
agent_id = Column(UUID(as_uuid=True), ForeignKey(
|
||||
"agents.id", ondelete="CASCADE"), nullable=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(),
|
||||
onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
knowledge = relationship("Knowledge", back_populates="agent_links")
|
||||
agent = relationship("Agent", back_populates="knowledge_links")
|
||||
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from sqlalchemy import (
|
||||
Column, Integer, String, Boolean, DateTime, ForeignKey,
|
||||
Enum as SQLEnum, JSON, Text, func, false, true,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
import enum
|
||||
import uuid
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class LeadAssignmentMode(str, enum.Enum):
|
||||
"""Where a qualified lead is routed. Phase 1: stored only, not acted on."""
|
||||
NONE = "none"
|
||||
SALES_TEAM = "sales_team"
|
||||
SPECIFIC_PERSON = "specific_person"
|
||||
ROUND_ROBIN = "round_robin"
|
||||
|
||||
|
||||
class CrmSyncTarget(str, enum.Enum):
|
||||
"""Which CRM a qualified lead syncs to.
|
||||
|
||||
Stored as a plain string column (not a DB enum) so adding a CRM never
|
||||
requires a schema migration — mirrors ChannelType. str() is the value so
|
||||
in-memory enum instances and refreshed-from-DB strings render identically.
|
||||
"""
|
||||
|
||||
NONE = "none"
|
||||
HUBSPOT = "hubspot"
|
||||
PIPEDRIVE = "pipedrive"
|
||||
SALESFORCE = "salesforce"
|
||||
|
||||
def __str__(self) -> str: # pragma: no cover - trivial
|
||||
return self.value
|
||||
|
||||
|
||||
class LeadCaptureConfig(Base):
|
||||
"""Per-agent lead-capture configuration (1:1 with Agent).
|
||||
|
||||
Prompt-driven, like transfer_to_human: a simple on/off toggle. When enabled, the
|
||||
agent collects the configured fields conversationally (no form) and emits them as
|
||||
structured output plus an AI-written lead summary.
|
||||
"""
|
||||
__tablename__ = "lead_capture_configs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
agent_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("agents.id", ondelete="CASCADE"),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
)
|
||||
# Master toggle (mirrors Agent.transfer_to_human).
|
||||
enabled = Column(Boolean, default=False, server_default=false(), nullable=False)
|
||||
# GDPR: require the visitor's explicit "yes" before a lead is recorded (default on).
|
||||
require_consent = Column(Boolean, default=True, server_default=true(), nullable=False)
|
||||
|
||||
# Free-text steering appended to the agent's system prompt (e.g. "prioritise
|
||||
# pricing-page visitors, be proactive after hours"). The agent decides *when* to ask.
|
||||
guidance = Column(Text, nullable=True)
|
||||
# Which details to collect — feeds the prompt and defines the expected lead_data keys:
|
||||
# {"key": "email", "standard": true, "required": true, "enabled": true}
|
||||
# {"key": "custom_1", "standard": false, "label": "Team size", "enabled": true}
|
||||
fields = Column(JSON, nullable=True)
|
||||
|
||||
# Routing config — persisted so the shape is ready for real integration later,
|
||||
# but never acted on in phase 1 (no CRM/Slack/assignment side effects).
|
||||
assignment_mode = Column(SQLEnum(LeadAssignmentMode), default=LeadAssignmentMode.NONE, server_default="NONE", nullable=False)
|
||||
assignment_target_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
crm_sync_target = Column(String(32), default=CrmSyncTarget.NONE.value, server_default="none", nullable=False)
|
||||
slack_notify_enabled = Column(Boolean, default=False, server_default=false(), nullable=False)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
agent = relationship("Agent", back_populates="lead_capture_config", uselist=False)
|
||||
|
||||
|
||||
class LeadCaptureResponse(Base):
|
||||
"""One row per captured lead — the fields the agent extracted conversationally,
|
||||
plus an AI-written qualification summary. Kept separate from Customer.meta_data
|
||||
(integrator-supplied) so AI-captured leads have a clean home."""
|
||||
__tablename__ = "lead_capture_responses"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False)
|
||||
agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id", ondelete="SET NULL"), nullable=True)
|
||||
customer_id = Column(UUID(as_uuid=True), ForeignKey("customers.id", ondelete="CASCADE"), nullable=False)
|
||||
session_id = Column(UUID(as_uuid=True), ForeignKey("session_to_agents.session_id"), nullable=True)
|
||||
|
||||
field_values = Column(JSON, nullable=True) # {"email": ..., "company": ..., "custom_1": ...}
|
||||
# AI-written qualification narrative (e.g. "40-person fintech replacing Intercom, wants a demo").
|
||||
summary = Column(Text, nullable=True)
|
||||
# Whether the visitor gave explicit consent to be contacted (GDPR).
|
||||
consent = Column(Boolean, default=False, nullable=False)
|
||||
qualified = Column(Boolean, default=False, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
customer = relationship("Customer", back_populates="lead_capture_responses")
|
||||
agent = relationship("Agent")
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, func, Enum as SQLEnum, Text, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
import enum
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSON
|
||||
|
||||
class MCPTransportType(str, enum.Enum):
|
||||
STDIO = "stdio"
|
||||
SSE = "sse"
|
||||
HTTP = "http"
|
||||
|
||||
|
||||
class MCPTool(Base):
|
||||
__tablename__ = "mcp_tools"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False) # Display name for the MCP server
|
||||
description = Column(Text) # Optional description of what this MCP server does
|
||||
# What this source holds and how to query it. Reaches the model as agno
|
||||
# per-toolkit instructions, in the system prompt — unlike `description`,
|
||||
# which is a preset-populated UI subtitle the model never sees.
|
||||
usage_guidance = Column(Text)
|
||||
transport_type = Column(SQLEnum(MCPTransportType), nullable=False)
|
||||
enabled = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
# For STDIO transport
|
||||
command = Column(String) # e.g., "npx", "uvx", "node"
|
||||
args = Column(JSON) # List of arguments, e.g., ["-y", "@modelcontextprotocol/server-filesystem"]
|
||||
env_vars = Column(JSON) # Environment variables as key-value pairs
|
||||
|
||||
# For SSE/HTTP transports
|
||||
url = Column(String) # Server URL
|
||||
headers = Column(JSON) # HTTP headers as key-value pairs
|
||||
timeout = Column(Integer) # Connection timeout in seconds
|
||||
sse_read_timeout = Column(Integer) # SSE read timeout in seconds
|
||||
terminate_on_close = Column(Boolean, default=True) # For HTTP transport
|
||||
|
||||
# Relationships
|
||||
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
organization = relationship("Organization", back_populates="mcp_tools")
|
||||
agent_links = relationship("MCPToolToAgent", back_populates="mcp_tool", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class MCPToolToAgent(Base):
|
||||
__tablename__ = "mcp_tool_to_agent"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
mcp_tool_id = Column(Integer, ForeignKey("mcp_tools.id"), nullable=False)
|
||||
agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id"), nullable=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
mcp_tool = relationship("MCPTool", back_populates="agent_links")
|
||||
agent = relationship("Agent", back_populates="mcp_tool_links")
|
||||
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, JSON, DateTime, ForeignKey, Boolean, Enum as SQLEnum
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
import enum
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
class NotificationType(str, enum.Enum):
|
||||
KNOWLEDGE_PROCESSED = "knowledge_processed"
|
||||
KNOWLEDGE_FAILED = "knowledge_failed"
|
||||
FAQ_GENERATED = "faq_generated"
|
||||
FAQ_GENERATION_FAILED = "faq_generation_failed"
|
||||
SYSTEM = "system"
|
||||
CHAT = "chat"
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
type = Column(SQLEnum(NotificationType), nullable=False)
|
||||
title = Column(String, nullable=False)
|
||||
message = Column(String, nullable=False)
|
||||
# For additional data like queue_id, knowledge_id etc
|
||||
notification_metadata = Column(JSON, nullable=True)
|
||||
is_read = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="notifications")
|
||||
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import expression, func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
# Preference column per chat event, and the value assumed for a user who has
|
||||
# no row yet. Transfer/assignment default on so existing users keep the
|
||||
# notifications they already get; new-chat is opt-in because it fires on every
|
||||
# conversation, including the ones the AI handles alone.
|
||||
NOTIFY_NEW_CHAT = "notify_new_chat"
|
||||
NOTIFY_CHAT_TRANSFER = "notify_chat_transfer"
|
||||
NOTIFY_CHAT_ASSIGNED = "notify_chat_assigned"
|
||||
|
||||
NOTIFICATION_PREFERENCE_DEFAULTS = {
|
||||
NOTIFY_NEW_CHAT: False,
|
||||
NOTIFY_CHAT_TRANSFER: True,
|
||||
NOTIFY_CHAT_ASSIGNED: True,
|
||||
}
|
||||
|
||||
|
||||
class UserNotificationSettings(Base):
|
||||
"""Per-user chat push preferences. One row per user, created lazily with
|
||||
defaults on first read — absence of a row means the defaults above apply.
|
||||
"""
|
||||
__tablename__ = "user_notification_settings"
|
||||
|
||||
user_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
|
||||
notify_new_chat = Column(Boolean, nullable=False, default=False, server_default=expression.false())
|
||||
notify_chat_transfer = Column(Boolean, nullable=False, default=True, server_default=expression.true())
|
||||
notify_chat_assigned = Column(Boolean, nullable=False, default=True, server_default=expression.true())
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
user = relationship("User")
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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 uuid
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, JSON, func, event
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
from app.models.role import Role
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.core.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class Organization(Base):
|
||||
__tablename__ = "organizations"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, index=True,default=uuid.uuid4)
|
||||
name = Column(String(100), nullable=False)
|
||||
domain = Column(String(100), unique=True, nullable=False)
|
||||
timezone = Column(String(50), nullable=False, default='UTC')
|
||||
business_hours = Column(JSON, default=lambda: {
|
||||
'monday': {'start': '09:00', 'end': '17:00', 'enabled': True},
|
||||
'tuesday': {'start': '09:00', 'end': '17:00', 'enabled': True},
|
||||
'wednesday': {'start': '09:00', 'end': '17:00', 'enabled': True},
|
||||
'thursday': {'start': '09:00', 'end': '17:00', 'enabled': True},
|
||||
'friday': {'start': '09:00', 'end': '17:00', 'enabled': True},
|
||||
'saturday': {'start': '09:00', 'end': '17:00', 'enabled': False},
|
||||
'sunday': {'start': '09:00', 'end': '17:00', 'enabled': False}
|
||||
}) # callable: a fresh dict per row, never a shared mutable default
|
||||
settings = Column(JSON, default=dict) # callable: never a shared mutable default
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(),
|
||||
onupdate=func.now())
|
||||
|
||||
# Define relationships
|
||||
chat_histories = relationship("ChatHistory", back_populates="organization")
|
||||
users = relationship("User", back_populates="organization",
|
||||
cascade="all, delete-orphan")
|
||||
customers = relationship(
|
||||
"Customer", back_populates="organization", cascade="all, delete-orphan")
|
||||
roles = relationship("Role", back_populates="organization",
|
||||
cascade="all, delete-orphan")
|
||||
ai_configs = relationship("AIConfig", back_populates="organization",
|
||||
cascade="all, delete-orphan")
|
||||
agents = relationship("Agent", back_populates="organization")
|
||||
knowledge_sources = relationship(
|
||||
"Knowledge", back_populates="organization")
|
||||
mcp_tools = relationship("MCPTool", back_populates="organization")
|
||||
widgets = relationship("Widget", back_populates="organization")
|
||||
widget_apps = relationship("WidgetApp", back_populates="organization", cascade="all, delete-orphan")
|
||||
groups = relationship("UserGroup", back_populates="organization")
|
||||
workflows = relationship("Workflow", back_populates="organization")
|
||||
|
||||
jira_tokens = relationship("JiraToken", back_populates="organization", cascade="all, delete-orphan")
|
||||
shopify_shops = relationship("ShopifyShop", back_populates="organization", cascade="all, delete-orphan")
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
# Try to set up enterprise relationships after mapper configuration
|
||||
@event.listens_for(Organization, 'mapper_configured')
|
||||
def setup_enterprise_relationships(mapper, class_):
|
||||
try:
|
||||
from app.enterprise.models.subscription import Subscription
|
||||
from app.enterprise.models.order import PayPalOrder
|
||||
|
||||
if not hasattr(class_, 'subscription'):
|
||||
class_.subscription = relationship(
|
||||
Subscription,
|
||||
back_populates="organization",
|
||||
uselist=False
|
||||
)
|
||||
|
||||
if not hasattr(class_, 'paypal_orders'):
|
||||
class_.paypal_orders = relationship(
|
||||
PayPalOrder,
|
||||
back_populates="organization",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
logger.info("Enterprise subscription and paypal_orders relationships configured")
|
||||
except ImportError:
|
||||
logger.info("Enterprise module not available - subscription relationship not configured")
|
||||
class_.subscription = None
|
||||
|
||||
# Set up Jira relationships after mapper configuration
|
||||
@event.listens_for(Organization, 'mapper_configured')
|
||||
def setup_jira_relationships(mapper, class_):
|
||||
try:
|
||||
from app.models.jira import JiraToken
|
||||
|
||||
if not hasattr(class_, 'jira_tokens'):
|
||||
class_.jira_tokens = relationship(
|
||||
JiraToken,
|
||||
back_populates="organization",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
logger.info("Jira tokens relationship configured")
|
||||
except ImportError:
|
||||
logger.info("Jira module not available - jira_tokens relationship not configured")
|
||||
class_.jira_tokens = None
|
||||
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Table, ForeignKey
|
||||
from app.database import Base
|
||||
|
||||
# Junction table for role-permission many-to-many relationship
|
||||
role_permissions = Table(
|
||||
'role_permissions',
|
||||
Base.metadata,
|
||||
Column('role_id', Integer, ForeignKey('roles.id')),
|
||||
Column('permission_id', Integer, ForeignKey('permissions.id'))
|
||||
)
|
||||
|
||||
|
||||
# What a newly seeded "Agent" role can do: work the chats assigned to them, pick
|
||||
# up whatever the AI is still handling, and look up who they are talking to.
|
||||
#
|
||||
# Named here rather than spelled out in each signup handler because there are
|
||||
# two of them — community (api/organizations.py) and hosted (enterprise
|
||||
# signup) — and they drifted. Hosted seeded only the first two for a year, so
|
||||
# its agents could not see the unclaimed queue or open People at all.
|
||||
DEFAULT_AGENT_ROLE_PERMISSIONS = (
|
||||
"view_assigned_chats",
|
||||
"manage_assigned_chats",
|
||||
"view_unassigned_chats",
|
||||
"view_people",
|
||||
)
|
||||
|
||||
|
||||
class Permission(Base):
|
||||
__tablename__ = "permissions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, unique=True, nullable=False)
|
||||
description = Column(String)
|
||||
|
||||
# Define default permissions
|
||||
@classmethod
|
||||
def default_permissions(cls):
|
||||
return [
|
||||
("view_all", "Can view all resources"),
|
||||
("manage_users", "Can manage users"),
|
||||
("manage_roles", "Can manage roles"),
|
||||
("manage_agents", "Can manage chat agents"),
|
||||
("view_agents", "Can view chat agents"),
|
||||
("view_analytics", "Can view analytics"),
|
||||
("view_assigned_chats", "Can view assigned chats only"),
|
||||
("manage_assigned_chats", "Can manage assigned chats"),
|
||||
# The unclaimed queue: AI-handled sessions nobody has taken yet.
|
||||
# Separate from view_assigned_chats so widening an agent's inbox to
|
||||
# the queue never silently exposes another agent's conversations.
|
||||
("view_unassigned_chats", "Can view unassigned AI chats"),
|
||||
("manage_knowledge", "Can manage knowledge base"),
|
||||
("view_knowledge", "Can view knowledge base"),
|
||||
("manage_ai_config", "Can manage AI configuration"),
|
||||
("view_ai_config", "Can view AI configuration"),
|
||||
("view_all_chats", "Can view all chat history"),
|
||||
("manage_all_chats", "Can manage all chat sessions"),
|
||||
# The people directory, decoupled from chat visibility: an agent can
|
||||
# look up who they are talking to without being able to read every
|
||||
# other agent's conversations.
|
||||
("view_people", "Can view the people directory"),
|
||||
("manage_organization", "Can manage organization settings"),
|
||||
("view_organization", "Can view organization details"),
|
||||
("manage_subscription", "Can manage subscription plans and billing"),
|
||||
("view_subscription", "Can view subscription details"),
|
||||
("view_tickets", "Can view tickets"),
|
||||
("manage_tickets", "Can manage tickets"),
|
||||
("approve_ticket_actions", "Can approve AI-proposed ticket resolutions"),
|
||||
("manage_ticket_connectors", "Can manage ticket investigation connectors"),
|
||||
("super_admin", "Has all permissions")
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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 uuid
|
||||
from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.database import Base
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
|
||||
class Rating(Base):
|
||||
__tablename__ = "ratings"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, index=True,default=uuid.uuid4)
|
||||
session_id = Column(UUID(as_uuid=True), ForeignKey("session_to_agents.session_id", ondelete="CASCADE"), nullable=False)
|
||||
customer_id = Column(UUID(as_uuid=True), ForeignKey("customers.id", ondelete="SET NULL"), nullable=True)
|
||||
agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id", ondelete="SET NULL"), nullable=True)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False)
|
||||
rating = Column(Integer, nullable=False) # 1-5 star rating
|
||||
feedback = Column(Text, nullable=True) # Optional feedback text
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
session_assignments = relationship("SessionToAgent", back_populates="ratings")
|
||||
customer = relationship("Customer", back_populates="ratings")
|
||||
agent = relationship("Agent", back_populates="ratings")
|
||||
user = relationship("User", back_populates="ratings")
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, DateTime, Integer, String, ForeignKey, Boolean, func, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.database import Base
|
||||
|
||||
class Role(Base):
|
||||
__tablename__ = "roles"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('name', 'organization_id', name='uq_role_name_org'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
description = Column(String)
|
||||
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"))
|
||||
is_default = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
organization = relationship("Organization", back_populates="roles")
|
||||
permissions = relationship("Permission", secondary="role_permissions")
|
||||
users = relationship("User", back_populates="role")
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert role object to dictionary"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"permissions": [{"id": p.id, "name": p.name, "description": p.description} for p in self.permissions] if self.permissions else []
|
||||
}
|
||||
|
||||
def has_permission(self, permission_name: str) -> bool:
|
||||
"""Check if role has a specific permission"""
|
||||
return any(p.name == permission_name for p in self.permissions)
|
||||
|
||||
def is_super_admin(self) -> bool:
|
||||
"""Check if role has super admin permission"""
|
||||
return self.has_permission("super_admin")
|
||||
|
||||
def can_manage_subscription(self) -> bool:
|
||||
"""Check if role can manage subscriptions"""
|
||||
return self.is_super_admin() or self.has_permission("manage_subscription")
|
||||
|
||||
def can_view_subscription(self) -> bool:
|
||||
"""Check if role can view subscription details"""
|
||||
return self.is_super_admin() or self.has_permission("view_subscription")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from .organization import (
|
||||
OrganizationBase,
|
||||
OrganizationCreate,
|
||||
OrganizationUpdate,
|
||||
OrganizationResponse
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
'OrganizationBase',
|
||||
'OrganizationCreate',
|
||||
'OrganizationUpdate',
|
||||
'OrganizationResponse',
|
||||
'AgentSessionBase',
|
||||
'AgentSessionCreate',
|
||||
'AgentSessionUpdate',
|
||||
'AgentSessionResponse'
|
||||
]
|
||||
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict
|
||||
from enum import Enum
|
||||
from uuid import UUID
|
||||
from app.models.agent import AgentType
|
||||
from app.agents.guardrail_policy import GUARDRAIL_PROMPT_MAX
|
||||
from app.models.schemas.agent_customization import CustomizationResponse
|
||||
from app.models.schemas.user_group import UserGroupResponse
|
||||
|
||||
class KnowledgeItem(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
type: str
|
||||
|
||||
|
||||
class AgentBase(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
description: Optional[str] = None
|
||||
agent_type: AgentType
|
||||
instructions: List[str]
|
||||
tools: Optional[List[Dict]] = None
|
||||
is_active: bool = True
|
||||
is_default: bool = False
|
||||
transfer_to_human: bool = False
|
||||
ai_replies_enabled: bool = True
|
||||
channel_quick_replies: bool = False
|
||||
ask_for_rating: bool = False
|
||||
handoff_collect_email: bool = True
|
||||
handoff_collect_name: bool = True
|
||||
enable_rate_limiting: bool = False
|
||||
overall_limit_per_ip: int = Field(default=100, description="Maximum number of requests allowed per IP address")
|
||||
requests_per_sec: float = Field(default=1.0, description="Number of requests allowed per second")
|
||||
use_workflow: bool = False
|
||||
active_workflow_id: Optional[UUID] = None
|
||||
display_name: Optional[str] = None
|
||||
allow_attachments: bool = False
|
||||
allowed_attachment_types: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
description="List of allowed attachment type categories: 'images', 'documents', 'office', 'text'. If null/empty, all types allowed."
|
||||
)
|
||||
require_token_auth: bool = False
|
||||
ticketing_enabled: bool = True
|
||||
topic_scope: Optional[str] = Field(
|
||||
default=None, max_length=500,
|
||||
description="Optional one-line business remit used by the platform guardrail's topic-scope line."
|
||||
)
|
||||
guardrail_prompt: Optional[str] = Field(
|
||||
default=None, max_length=GUARDRAIL_PROMPT_MAX,
|
||||
description="Scope rule for this agent. Null uses the platform default; edit it to fit your business."
|
||||
)
|
||||
guardrail_enabled: bool = Field(
|
||||
default=True,
|
||||
description="Apply the scope rule. Injection and disclosure protections are always on."
|
||||
)
|
||||
|
||||
|
||||
class AgentCreate(AgentBase):
|
||||
organization_id: Optional[UUID] = None
|
||||
|
||||
|
||||
class AgentUpdate(BaseModel):
|
||||
display_name: Optional[str] = None
|
||||
instructions: Optional[List[str]] = None
|
||||
is_active: Optional[bool] = None
|
||||
transfer_to_human: Optional[bool] = None
|
||||
ai_replies_enabled: Optional[bool] = None
|
||||
channel_quick_replies: Optional[bool] = None
|
||||
ask_for_rating: Optional[bool] = None
|
||||
handoff_collect_email: Optional[bool] = None
|
||||
handoff_collect_name: Optional[bool] = None
|
||||
enable_rate_limiting: Optional[bool] = None
|
||||
overall_limit_per_ip: Optional[int] = None
|
||||
requests_per_sec: Optional[float] = None
|
||||
use_workflow: Optional[bool] = None
|
||||
active_workflow_id: Optional[UUID] = None
|
||||
allow_attachments: Optional[bool] = None
|
||||
allowed_attachment_types: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
description="List of allowed attachment type categories: 'images', 'documents', 'office', 'text'. If null/empty, all types allowed."
|
||||
)
|
||||
require_token_auth: Optional[bool] = None
|
||||
ticketing_enabled: Optional[bool] = None
|
||||
topic_scope: Optional[str] = Field(default=None, max_length=500)
|
||||
guardrail_prompt: Optional[str] = Field(default=None, max_length=GUARDRAIL_PROMPT_MAX)
|
||||
guardrail_enabled: Optional[bool] = None
|
||||
|
||||
|
||||
class AgentKnowledge(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
type: str
|
||||
|
||||
|
||||
class AgentRosterItem(BaseModel):
|
||||
"""An AI agent as the inbox filter needs it: a name to group chats by.
|
||||
|
||||
Deliberately none of AgentResponse's configuration — `instructions`, and
|
||||
especially `guardrail_prompt`, which tells a reader how to phrase a bypass.
|
||||
"""
|
||||
id: UUID
|
||||
name: str
|
||||
display_name: Optional[str] = None
|
||||
is_active: bool = True
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AgentResponse(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
display_name: Optional[str]
|
||||
description: Optional[str]
|
||||
agent_type: AgentType
|
||||
instructions: List[str]
|
||||
is_active: bool
|
||||
organization_id: UUID
|
||||
knowledge: List[AgentKnowledge] = []
|
||||
transfer_to_human: bool = False
|
||||
ai_replies_enabled: bool = True
|
||||
channel_quick_replies: bool = False
|
||||
ask_for_rating: bool = False
|
||||
handoff_collect_email: bool = True
|
||||
handoff_collect_name: bool = True
|
||||
enable_rate_limiting: Optional[bool] = None
|
||||
overall_limit_per_ip: Optional[int] = None
|
||||
requests_per_sec: Optional[float] = None
|
||||
use_workflow: Optional[bool] = False
|
||||
active_workflow_id: Optional[UUID] = None
|
||||
allow_attachments: bool = False
|
||||
allowed_attachment_types: Optional[List[str]] = None
|
||||
require_token_auth: bool = False
|
||||
ticketing_enabled: bool = True
|
||||
topic_scope: Optional[str] = None
|
||||
guardrail_prompt: Optional[str] = None
|
||||
guardrail_enabled: bool = True
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AgentWithCustomizationResponse(AgentResponse):
|
||||
customization: Optional[CustomizationResponse] = None
|
||||
groups: List[UserGroupResponse] = []
|
||||
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field, field_serializer, field_validator
|
||||
from typing import Optional, Dict, List
|
||||
from typing_extensions import Annotated
|
||||
from uuid import UUID
|
||||
import enum
|
||||
|
||||
from app.core.s3 import sign_s3_url, strip_s3_signature
|
||||
|
||||
# Cap launcher/welcome copy so it can't balloon the widget (the launcher nudge is
|
||||
# also clamped to 4 lines client-side). Limits match the frontend input maxlengths
|
||||
# so the UI never lets a user type something the API would reject.
|
||||
InitiationMessage = Annotated[str, Field(max_length=100)]
|
||||
|
||||
|
||||
class ChatStyle(str, enum.Enum):
|
||||
CHATBOT = "CHATBOT"
|
||||
ASK_ANYTHING = "ASK_ANYTHING"
|
||||
# Premium design presets
|
||||
GLASS = "GLASS"
|
||||
TERMINAL = "TERMINAL"
|
||||
PLAYFUL = "PLAYFUL"
|
||||
CALM_MINT = "CALM_MINT"
|
||||
AURORA = "AURORA"
|
||||
SUNRISE = "SUNRISE"
|
||||
|
||||
|
||||
class WidgetPosition(str, enum.Enum):
|
||||
FLOATING = "FLOATING"
|
||||
FIXED = "FIXED"
|
||||
|
||||
|
||||
class WidgetDisplayMode(str, enum.Enum):
|
||||
FLOATING = "floating"
|
||||
SIDEBAR_LEFT = "sidebar-left"
|
||||
SIDEBAR_RIGHT = "sidebar-right"
|
||||
SEARCH_BAR = "search-bar"
|
||||
|
||||
|
||||
class WidgetDisplaySide(str, enum.Enum):
|
||||
LEFT = "left"
|
||||
RIGHT = "right"
|
||||
|
||||
|
||||
class WidgetDisplayConfig(BaseModel):
|
||||
"""Dashboard "Widget placement" defaults, stored in customization_metadata.
|
||||
|
||||
The embed loader (chattermate.js) merges these under any options the
|
||||
installing developer set on the page. Bounds keep saved values renderable
|
||||
on any reasonable screen; the loader clamps again client-side.
|
||||
"""
|
||||
mode: Optional[WidgetDisplayMode] = None
|
||||
side: Optional[WidgetDisplaySide] = None
|
||||
launcher: Optional[bool] = None
|
||||
width: Optional[int] = Field(default=None, ge=280, le=800)
|
||||
height: Optional[int] = Field(default=None, ge=400, le=900)
|
||||
sidebar_width: Optional[int] = Field(default=None, ge=320, le=640)
|
||||
search_placeholder: Optional[str] = Field(default=None, max_length=80)
|
||||
offset_bottom: Optional[int] = Field(default=None, ge=0, le=200)
|
||||
offset_side: Optional[int] = Field(default=None, ge=0, le=200)
|
||||
z_index: Optional[int] = Field(default=None, ge=1, le=2147483647)
|
||||
|
||||
|
||||
# Predefined chat initiation messages
|
||||
DEFAULT_CHAT_INITIATIONS = [
|
||||
"👋 Hi! Need help? Ask me anything!",
|
||||
"💬 Have a question? I'm here to help!",
|
||||
"🤝 Welcome! How can I assist you today?",
|
||||
"✨ Got questions? Let's chat!",
|
||||
"👨💼 Need support? Click to chat with us!"
|
||||
]
|
||||
|
||||
# Predefined quick-action buttons (clicking sends the label as a message)
|
||||
DEFAULT_QUICK_ACTIONS = [
|
||||
"Track my order",
|
||||
"Start a return",
|
||||
"Talk to a human"
|
||||
]
|
||||
|
||||
|
||||
class CustomizationBase(BaseModel):
|
||||
photo_url: Optional[str] = None
|
||||
|
||||
@field_validator('photo_url')
|
||||
@classmethod
|
||||
def _strip_photo_url_signature(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""Keep the bare S3 URL as the stored value.
|
||||
|
||||
CustomizationResponse serializes photo_url signed, and the agent
|
||||
customization form POSTs the value it was given straight back, so
|
||||
without this the expiring signature would be written to the column.
|
||||
"""
|
||||
return strip_s3_signature(v)
|
||||
chat_background_color: Optional[str] = "#F8F9FA"
|
||||
chat_bubble_color: Optional[str] = "#E9ECEF"
|
||||
chat_text_color: Optional[str] = "#212529"
|
||||
icon_url: Optional[str] = None
|
||||
icon_color: Optional[str] = "#6C757D"
|
||||
accent_color: Optional[str] = "#f34611"
|
||||
font_family: Optional[str] = "Inter, system-ui, sans-serif"
|
||||
custom_css: Optional[str] = None
|
||||
customization_metadata: Optional[Dict] = {}
|
||||
chat_style: Optional[ChatStyle] = ChatStyle.CHATBOT
|
||||
widget_position: Optional[WidgetPosition] = WidgetPosition.FLOATING
|
||||
# NOTE: length caps live on CustomizationCreate (input), NOT here — the response
|
||||
# model also inherits this base, and existing rows may exceed a newly-lowered cap;
|
||||
# enforcing on the response would 500 the whole agent list.
|
||||
welcome_title: Optional[str] = None
|
||||
welcome_subtitle: Optional[str] = None
|
||||
welcome_message: Optional[str] = None
|
||||
chat_initiation_messages: Optional[List[str]] = None
|
||||
quick_actions: Optional[List[str]] = None
|
||||
show_citations: Optional[bool] = False
|
||||
collect_email: Optional[bool] = False
|
||||
show_ai_disclaimer: Optional[bool] = True
|
||||
allow_new_chat: Optional[bool] = False
|
||||
|
||||
|
||||
class CustomizationCreate(CustomizationBase):
|
||||
# Enforce length caps on write only, so the UI can't submit values the widget
|
||||
# can't display. Reads (CustomizationResponse) intentionally stay unconstrained.
|
||||
welcome_title: Optional[str] = Field(default=None, max_length=100)
|
||||
welcome_subtitle: Optional[str] = Field(default=None, max_length=250)
|
||||
welcome_message: Optional[str] = Field(default=None, max_length=500)
|
||||
chat_initiation_messages: Optional[List[InitiationMessage]] = None
|
||||
|
||||
@field_validator('customization_metadata')
|
||||
@classmethod
|
||||
def _validate_widget_display(cls, v: Optional[Dict]) -> Optional[Dict]:
|
||||
"""Validate and normalize the structured widget_display key on write.
|
||||
|
||||
The rest of customization_metadata stays free-form (avatar_style etc.);
|
||||
widget_display is consumed by the embed loader on customer pages, so bad
|
||||
values must be rejected here. Unknown keys inside it are dropped and
|
||||
unset keys aren't stored. An explicit null clears the settings.
|
||||
"""
|
||||
if not v or v.get('widget_display') is None:
|
||||
return v
|
||||
validated = WidgetDisplayConfig.model_validate(v['widget_display'])
|
||||
# mode='json' turns enums into plain strings — the dict goes straight into
|
||||
# a JSON column, so it must hold JSON primitives, not enum members.
|
||||
v['widget_display'] = validated.model_dump(mode='json', exclude_none=True)
|
||||
return v
|
||||
|
||||
|
||||
class CustomizationResponse(CustomizationBase):
|
||||
id: int
|
||||
agent_id: UUID
|
||||
|
||||
@field_serializer('photo_url')
|
||||
def _sign_photo_url(self, v: Optional[str]) -> Optional[str]:
|
||||
"""Sign on the way out, every response. Signing is a local HMAC (~0.05ms),
|
||||
so there is nothing to gain by caching the result in the database."""
|
||||
return sign_s3_url(v) if v else v
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, SecretStr
|
||||
from typing import Optional, Dict
|
||||
from app.models.ai_config import AIModelType
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
class AIConfigBase(BaseModel):
|
||||
model_type: AIModelType
|
||||
model_name: str
|
||||
settings: Optional[Dict] = {}
|
||||
|
||||
|
||||
class AIConfigCreate(AIConfigBase):
|
||||
api_key: SecretStr
|
||||
# Required for OPENAI_COMPATIBLE (a self-hosted or third-party endpoint);
|
||||
# ignored by providers that don't need it.
|
||||
base_url: Optional[str] = None
|
||||
|
||||
|
||||
class AIConfigUpdate(BaseModel):
|
||||
model_type: Optional[AIModelType] = None
|
||||
model_name: Optional[str] = None
|
||||
api_key: Optional[SecretStr] = None
|
||||
settings: Optional[Dict] = None
|
||||
base_url: Optional[str] = None
|
||||
|
||||
|
||||
class AIConfigResponse(BaseModel):
|
||||
id: int
|
||||
organization_id: UUID
|
||||
model_type: AIModelType
|
||||
model_name: str
|
||||
is_active: bool
|
||||
settings: Dict = {}
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AISetupResponse(BaseModel):
|
||||
message: str
|
||||
config: AIConfigResponse
|
||||
@@ -0,0 +1,229 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from app.channels.constants import DEFAULT_TEMPLATE_LANGUAGE
|
||||
|
||||
|
||||
class ChannelAccountOut(BaseModel):
|
||||
"""Connected channel account as exposed to the settings UI (no secrets)."""
|
||||
id: UUID
|
||||
channel_type: str
|
||||
external_account_id: str
|
||||
display_name: Optional[str] = None
|
||||
is_active: bool
|
||||
agent_id: Optional[UUID] = None # agent currently routed to this account
|
||||
created_at: Optional[datetime] = None
|
||||
# For channels the customer must point at us (email inbound-parse, SMS,
|
||||
# Meta accounts from the customer's own app): the exact webhook URL to
|
||||
# configure on their provider, and the verify token Meta asks for.
|
||||
webhook_url: Optional[str] = None
|
||||
# The platform refused the account's credentials; replies fail until the
|
||||
# account is reconnected
|
||||
needs_reconnect: bool = False
|
||||
webhook_verify_token: Optional[str] = None
|
||||
# WhatsApp from the customer's own app: whether Meta took our per-number
|
||||
# callback (else the customer sets the URL and token by hand)
|
||||
webhook_auto_configured: Optional[bool] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TelegramConnectRequest(BaseModel):
|
||||
bot_token: str
|
||||
|
||||
|
||||
class OwnMetaAppSecret(BaseModel):
|
||||
"""The secret of the customer's own Meta app, for manual credentials:
|
||||
Meta signs that app's webhooks with it. Blank means not given."""
|
||||
app_secret: Optional[str] = None
|
||||
|
||||
@field_validator("app_secret")
|
||||
@classmethod
|
||||
def _blank_is_none(cls, value: Optional[str]) -> Optional[str]:
|
||||
return (value or "").strip() or None
|
||||
|
||||
|
||||
class WhatsAppConnectRequest(OwnMetaAppSecret):
|
||||
"""Manual WhatsApp Cloud API credentials, from the customer's own Meta app."""
|
||||
phone_number_id: str
|
||||
access_token: str
|
||||
waba_id: Optional[str] = None
|
||||
display_name: Optional[str] = None
|
||||
|
||||
|
||||
class MessengerConnectRequest(OwnMetaAppSecret):
|
||||
page_id: str
|
||||
page_access_token: str
|
||||
display_name: Optional[str] = None
|
||||
|
||||
|
||||
class InstagramConnectRequest(BaseModel):
|
||||
ig_id: str
|
||||
page_access_token: str
|
||||
display_name: Optional[str] = None
|
||||
|
||||
|
||||
class TemplateSendRequest(BaseModel):
|
||||
"""Reopen an expired WhatsApp window with an approved template."""
|
||||
session_id: UUID
|
||||
template_name: str
|
||||
language: str = DEFAULT_TEMPLATE_LANGUAGE
|
||||
components: Optional[list] = None
|
||||
|
||||
|
||||
class OutboundConversationRequest(BaseModel):
|
||||
"""Start a WhatsApp conversation with a phone number — the customer has
|
||||
not messaged us; an approved template is the only thing Meta will deliver.
|
||||
|
||||
customer_id links the conversation to a person the agent picked in People;
|
||||
without it the customer is resolved by phone or created. customer_name
|
||||
only names a newly created person (never renames an existing one)."""
|
||||
to: str
|
||||
template_name: str
|
||||
language: str = DEFAULT_TEMPLATE_LANGUAGE
|
||||
components: Optional[list] = None
|
||||
customer_id: Optional[UUID] = None
|
||||
customer_name: Optional[str] = None
|
||||
|
||||
|
||||
class OutboundConversationOut(BaseModel):
|
||||
"""The session the outbound send created (or reused): everything else —
|
||||
the inbox thread, template resend, AI takeover on reply — keys off it."""
|
||||
session_id: UUID
|
||||
|
||||
|
||||
class EmbeddedSignupRequest(BaseModel):
|
||||
"""What the Embedded Signup JS SDK hands back: a short-lived code to trade
|
||||
for the customer's business token, plus the assets it created."""
|
||||
code: str
|
||||
waba_id: str
|
||||
phone_number_id: str
|
||||
display_name: Optional[str] = None
|
||||
|
||||
|
||||
class MessengerSignupRequest(BaseModel):
|
||||
"""The code Facebook Login for Business hands back. Unlike WhatsApp's
|
||||
Embedded Signup it names no assets — the Pages are ours to look up.
|
||||
|
||||
redirect_uri is the OAuth callback URL the login popup used; the code is
|
||||
bound to it, so the token exchange must send the exact same value back. The
|
||||
client owns it (it must also be a registered Valid OAuth Redirect URI)."""
|
||||
code: str
|
||||
redirect_uri: str
|
||||
|
||||
|
||||
class InstagramLoginRequest(BaseModel):
|
||||
"""What Instagram Business Login hands back. No Page and no asset ids — the
|
||||
token itself identifies the single account that signed in, so unlike the
|
||||
Messenger flow there is nothing to list or pick.
|
||||
|
||||
redirect_uri is the OAuth callback the popup used; the code is bound to it,
|
||||
so the token exchange must send the exact same value back."""
|
||||
code: str
|
||||
redirect_uri: str
|
||||
display_name: Optional[str] = None
|
||||
|
||||
|
||||
class MessengerPageOut(BaseModel):
|
||||
"""A Page offered in the picker. Its access token is deliberately absent:
|
||||
it travels back inside the opaque signup_token instead."""
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class MessengerSignupPagesOut(BaseModel):
|
||||
"""Step 1's result: the Pages to choose from, plus an opaque token that
|
||||
carries their access tokens (encrypted) into step 2."""
|
||||
pages: list[MessengerPageOut]
|
||||
signup_token: str
|
||||
|
||||
|
||||
class MessengerSignupConnectRequest(BaseModel):
|
||||
"""Step 2: the Page picked out of step 1's list. page_id is a selector into
|
||||
that list, not a credential — the token is the one we sealed."""
|
||||
signup_token: str
|
||||
page_id: str
|
||||
display_name: Optional[str] = None
|
||||
|
||||
|
||||
class EmbeddedSignupConfigOut(BaseModel):
|
||||
"""Tells the connect UI whether to offer Embedded Signup. Everything but
|
||||
`enabled` is None when it isn't, so nothing leaks to orgs without it."""
|
||||
enabled: bool
|
||||
config_id: Optional[str] = None
|
||||
app_id: Optional[str] = None
|
||||
graph_version: str
|
||||
|
||||
|
||||
class TemplateOut(BaseModel):
|
||||
"""A message template as it exists on the customer's WhatsApp Business
|
||||
Account. Only APPROVED templates can actually be sent."""
|
||||
id: Optional[str] = None
|
||||
name: str
|
||||
status: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
components: Optional[list] = None
|
||||
|
||||
|
||||
class TemplateLibraryOut(BaseModel):
|
||||
"""A deep link into Meta's Template Library for one WhatsApp Business
|
||||
Account — which is where templates get written. We list and send them; Meta
|
||||
authors them."""
|
||||
url: str
|
||||
|
||||
|
||||
class AgentChannelConfigRequest(BaseModel):
|
||||
agent_id: UUID
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class EmailConnectRequest(BaseModel):
|
||||
inbound_address: str # e.g. support@acme.com (the address customers write to)
|
||||
display_name: Optional[str] = None
|
||||
# Optional per-inbox outbound SMTP. When omitted, replies use the platform
|
||||
# SMTP settings. Provide these to send from the inbox's own domain with
|
||||
# correct SPF/DKIM alignment.
|
||||
smtp_host: Optional[str] = None
|
||||
smtp_port: Optional[int] = None
|
||||
smtp_username: Optional[str] = None
|
||||
smtp_password: Optional[str] = None
|
||||
from_email: Optional[str] = None # defaults to inbound_address
|
||||
smtp_use_ssl: Optional[bool] = None # None = auto (port 465 → implicit TLS)
|
||||
|
||||
|
||||
class SmsConnectRequest(BaseModel):
|
||||
provider: str # 'twilio' | 'vonage' | 'messagebird' | ...
|
||||
phone_number: str # sender number / id
|
||||
credentials: dict = {} # provider-specific secrets
|
||||
|
||||
|
||||
class SmsProviderInfo(BaseModel):
|
||||
name: str
|
||||
label: str
|
||||
fields: list # [{key,label,secret,optional}] for the UI
|
||||
|
||||
|
||||
class LineConnectRequest(BaseModel):
|
||||
channel_secret: str
|
||||
channel_access_token: str
|
||||
@@ -0,0 +1,386 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from app.models.session_to_agent import SessionStatus
|
||||
from app.channels.constants import MAX_CHOICE_LENGTH, MAX_CHOICE_OPTIONS
|
||||
|
||||
|
||||
class CustomerInfo(BaseModel):
|
||||
id: UUID
|
||||
email: Optional[str]
|
||||
full_name: Optional[str]
|
||||
# Integrator-supplied fields (e.g. student_name, center_name) set via
|
||||
# POST /generate-token's `custom_data`, shown to agents in the chat inbox.
|
||||
meta_data: Optional[Dict[str, Any]] = None
|
||||
class AgentInfo(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
display_name: Optional[str]
|
||||
# False means this agent never answers with AI, so an unclaimed chat of its
|
||||
# is waiting for a person rather than being handled.
|
||||
ai_replies_enabled: bool = True
|
||||
|
||||
class TransferReasonType(str, Enum):
|
||||
UNABLE_TO_ANSWER = "UNABLE_TO_ANSWER"
|
||||
NEED_MORE_INFO = "NEED_MORE_INFO"
|
||||
KNOWLEDGE_GAP = "KNOWLEDGE_GAP"
|
||||
NEED_TO_CALL = "NEED_TO_CALL"
|
||||
NEED_TO_EMAIL = "NEED_TO_EMAIL"
|
||||
NEED_TO_MEET = "NEED_TO_MEET"
|
||||
FRUSTRATED = "FRUSTRATED"
|
||||
REPEAT_INSTRUCTIONS = "REPEAT_INSTRUCTIONS"
|
||||
DIRECT_REQUEST = "DIRECT_REQUEST"
|
||||
HIGH_PRIORITY_ISSUE = "HIGH_PRIORITY_ISSUE"
|
||||
COMPLIANCE_ISSUE = "COMPLIANCE_ISSUE"
|
||||
|
||||
class EndChatReasonType(str, Enum):
|
||||
ISSUE_RESOLVED = "ISSUE_RESOLVED"
|
||||
CUSTOMER_REQUEST = "CUSTOMER_REQUEST"
|
||||
CONFIRMATION_RECEIVED = "CONFIRMATION_RECEIVED"
|
||||
FAREWELL = "FAREWELL"
|
||||
THANK_YOU = "THANK_YOU"
|
||||
NATURAL_CONCLUSION = "NATURAL_CONCLUSION"
|
||||
TASK_COMPLETED = "TASK_COMPLETED"
|
||||
|
||||
# Define a model for a single Shopify product image
|
||||
class ShopifyProductImage(BaseModel):
|
||||
src: Optional[str] = Field(default=None)
|
||||
alt: Optional[str] = Field(default=None)
|
||||
|
||||
class Config:
|
||||
json_encoders = {
|
||||
UUID: str # Convert UUID to string
|
||||
}
|
||||
from_attributes = True
|
||||
|
||||
# Define a model for a single Shopify product
|
||||
class ShopifyProduct(BaseModel):
|
||||
id: Optional[str] = Field(default=None)
|
||||
title: Optional[str] = Field(default=None)
|
||||
description: Optional[str] = Field(default=None)
|
||||
handle: Optional[str] = Field(default=None)
|
||||
product_type: Optional[str] = Field(default=None)
|
||||
vendor: Optional[str] = Field(default=None)
|
||||
total_inventory: Optional[int] = Field(default=None)
|
||||
price: Optional[str] = Field(default=None)
|
||||
price_max: Optional[str] = Field(default=None)
|
||||
currency: Optional[str] = Field(default=None)
|
||||
image: Optional[ShopifyProductImage] = Field(default=None)
|
||||
tags: Optional[List[str]] = Field(default_factory=list)
|
||||
created_at: Optional[str] = Field(default=None)
|
||||
updated_at: Optional[str] = Field(default=None)
|
||||
price_formatted: Optional[str] = Field(default=None)
|
||||
variant_title: Optional[str] = Field(default=None)
|
||||
|
||||
class Config:
|
||||
json_encoders = {
|
||||
UUID: str # Convert UUID to string
|
||||
}
|
||||
from_attributes = True
|
||||
|
||||
# LLM Response Model - ONLY cache key and product IDs (ultra-minimal)
|
||||
class ShopifyOutputDataLLM(BaseModel):
|
||||
"""Shopify output model for LLM responses - ONLY cache key and product IDs to minimize tokens"""
|
||||
product_cache_key: str = Field(description="Redis cache key - REQUIRED when Shopify tools return products")
|
||||
product_ids: List[str] = Field(description="List of product IDs - REQUIRED when Shopify tools return products")
|
||||
|
||||
class Config:
|
||||
json_encoders = {
|
||||
UUID: str
|
||||
}
|
||||
from_attributes = True
|
||||
|
||||
# Socket/Frontend Response Model - WITH products field (what frontend receives)
|
||||
class ShopifyOutputData(BaseModel):
|
||||
"""Shopify output model for socket/frontend responses - includes full product data"""
|
||||
products: Optional[List[ShopifyProduct]] = Field(default_factory=list)
|
||||
search_query: Optional[str] = Field(default=None)
|
||||
search_type: Optional[str] = Field(default=None)
|
||||
total_count: Optional[int] = Field(default=None)
|
||||
has_more: Optional[bool] = Field(default=None)
|
||||
shop_domain: Optional[str] = Field(default=None, description="Shopify shop domain for constructing product URLs")
|
||||
product_cache_key: Optional[str] = Field(default=None, description="Redis cache key for backend reference")
|
||||
product_ids: Optional[List[str]] = Field(default=None, description="List of product IDs for backend reference")
|
||||
|
||||
class Config:
|
||||
json_encoders = {
|
||||
UUID: str # Convert UUID to string
|
||||
}
|
||||
from_attributes = True
|
||||
|
||||
def dict(self, *args, **kwargs):
|
||||
# Override dict method to ensure proper serialization
|
||||
d = super().dict(*args, **kwargs)
|
||||
# Convert any remaining non-serializable objects to strings
|
||||
return d
|
||||
|
||||
def json(self, *args, **kwargs):
|
||||
# Override json method to ensure proper serialization
|
||||
return self.dict()
|
||||
|
||||
class Message(BaseModel):
|
||||
id: Optional[int] = None
|
||||
message: Optional[str] = None
|
||||
message_type: str
|
||||
created_at: datetime
|
||||
attributes: Optional[dict] = None
|
||||
# Channel messages: the platform's id and its latest delivery receipt
|
||||
external_message_id: Optional[str] = None
|
||||
channel_status: Optional[str] = None
|
||||
shopify_output: Optional[ShopifyOutputData] = None
|
||||
end_chat: Optional[bool] = None
|
||||
end_chat_reason: Optional[EndChatReasonType] = None
|
||||
end_chat_description: Optional[str] = None
|
||||
agent_name: Optional[str] = None
|
||||
user_name: Optional[str] = None
|
||||
attachments: Optional[list] = None # List of file attachments with signed URLs
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def validate_end_chat_reason(cls, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Validate end_chat_reason field to ensure it's a valid enum value or None."""
|
||||
if isinstance(data, dict) and 'end_chat_reason' in data and data['end_chat_reason'] is not None:
|
||||
valid_reasons = [reason.value for reason in EndChatReasonType]
|
||||
if data['end_chat_reason'] not in valid_reasons:
|
||||
# If invalid value, set to None
|
||||
data['end_chat_reason'] = None
|
||||
return data
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
json_encoders = {
|
||||
UUID: str,
|
||||
datetime: lambda v: v.isoformat()
|
||||
}
|
||||
|
||||
class ChatOverviewResponse(BaseModel):
|
||||
customer: CustomerInfo
|
||||
agent: AgentInfo
|
||||
last_message: Optional[str] = None
|
||||
updated_at: datetime
|
||||
message_count: int
|
||||
status: SessionStatus
|
||||
channel: str = 'web'
|
||||
group_id: Optional[UUID]
|
||||
# The human agent handling the chat, if one has taken it over. None means
|
||||
# the AI still has it — the inbox needs this to label the row.
|
||||
user_id: Optional[UUID] = None
|
||||
user_name: Optional[str] = None
|
||||
session_id: UUID
|
||||
|
||||
class ChatDetailResponse(BaseModel):
|
||||
customer: CustomerInfo
|
||||
agent: AgentInfo
|
||||
messages: List[Message]
|
||||
status: SessionStatus
|
||||
channel: str = 'web'
|
||||
# Which connected channel account this conversation arrived on; None for web.
|
||||
channel_account_id: Optional[UUID] = None
|
||||
group_id: Optional[UUID]
|
||||
session_id: UUID
|
||||
user_id: Optional[UUID]
|
||||
user_name: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
json_encoders = {
|
||||
UUID: str,
|
||||
datetime: lambda v: v.isoformat()
|
||||
}
|
||||
|
||||
class SourceRef(BaseModel):
|
||||
"""A knowledge-base source used to ground an answer (for citation chips)."""
|
||||
name: str
|
||||
type: str
|
||||
|
||||
|
||||
class ChatResponse(BaseModel):
|
||||
message: str = Field(description="The response from the agent. IMPORTANT: When shopify_output is present, DO NOT include product images, URLs, prices, or product details in this field - all product info should ONLY go in the shopify_output field. Keep the message conversational.")
|
||||
transfer_to_human: bool = Field(description="Whether to transfer the conversation to a human")
|
||||
end_chat: bool = Field(description="Whether to end the chat and request rating")
|
||||
transfer_reason: Optional[TransferReasonType] = Field(default=None, description="Transfer reason Type should be one of the following: UNABLE_TO_ANSWER, NEED_MORE_INFO, KNOWLEDGE_GAP, NEED_TO_CALL, NEED_TO_EMAIL, NEED_TO_MEET, FRUSTRATED, REPEAT_INSTRUCTIONS, DIRECT_REQUEST, HIGH_PRIORITY_ISSUE, COMPLIANCE_ISSUE")
|
||||
transfer_description: Optional[str] = Field(default=None, description="Detailed description for the transfer")
|
||||
end_chat_reason: Optional[EndChatReasonType] = Field(default=None, description="End chat reason Type should be one of the following: ISSUE_RESOLVED, CUSTOMER_REQUEST, CONFIRMATION_RECEIVED, FAREWELL, THANK_YOU, NATURAL_CONCLUSION, TASK_COMPLETED")
|
||||
end_chat_description: Optional[str] = Field(default=None, description="Detailed description for ending the chat")
|
||||
request_rating: bool = Field(description="Whether to request a rating from the customer")
|
||||
|
||||
# Ticket creation fields
|
||||
create_ticket: bool = Field(description="Whether to create a ticket in the integrated system (Jira, Zendesk, etc.)")
|
||||
ticket_summary: Optional[str] = Field(default=None, description="Summary/title for the ticket to be created")
|
||||
ticket_description: Optional[str] = Field(default=None, description="Detailed description for the ticket to be created")
|
||||
integration_type: Optional[str] = Field(default=None, description="Type of integration to use for ticket creation")
|
||||
ticket_id: Optional[str] = Field(default=None, description="ID of the created ticket (filled after creation)")
|
||||
ticket_status: Optional[str] = Field(default=None, description="Status of the created ticket (filled after creation)")
|
||||
ticket_priority: Optional[str] = Field(default=None, description="Priority level for the ticket (e.g., 'High', 'Medium', 'Low')")
|
||||
|
||||
# Shopify Output Field - Uses LLM model (no products field)
|
||||
shopify_output: Optional[ShopifyOutputDataLLM] = Field(default=None, description="Shopify product cache info. ONLY include: product_cache_key, product_ids, shop_domain, total_count. DO NOT include products array.")
|
||||
|
||||
# Knowledge-base citations (system-managed). The LLM must NOT populate this; it is
|
||||
# filled automatically from the knowledge search tool after the response is generated.
|
||||
sources: Optional[List[SourceRef]] = Field(default=None, description="System-managed knowledge citations. Do not populate — set automatically.")
|
||||
|
||||
# System-managed: set when a handoff/ticket happens so the widget shows the contact form
|
||||
# (even when no agents are available and transfer_to_human ends up False).
|
||||
request_contact: Optional[bool] = Field(default=False, description="System-managed. Do not populate — set automatically on handoff.")
|
||||
|
||||
# LLM-set (only when lead capture is enabled): the agent collects details
|
||||
# conversationally and reports them here, mirroring how transfer_to_human works.
|
||||
request_lead_capture: bool = Field(default=False, description="Set to true ONLY when you have collected the visitor's contact details (at least a valid email in lead_email) and — if consent is required — they have explicitly agreed to be contacted. This records the lead.")
|
||||
# Explicit scalar fields for the standard contact details. These are used INSTEAD of a
|
||||
# free-form dict because OpenAI strict structured outputs cannot emit an open-ended
|
||||
# object (additionalProperties) — a Dict field always comes back empty. Named scalar
|
||||
# fields populate reliably. The widget records the lead from these.
|
||||
lead_email: Optional[str] = Field(default=None, description="The visitor's email address exactly as they gave it, e.g. 'jane@acme.com'. Fill this as soon as they share it.")
|
||||
lead_name: Optional[str] = Field(default=None, description="The visitor's name, if they share it.")
|
||||
lead_company: Optional[str] = Field(default=None, description="The visitor's company/organization, if they share it.")
|
||||
lead_phone: Optional[str] = Field(default=None, description="The visitor's phone number, if they share it.")
|
||||
lead_data: Optional[Dict[str, Any]] = Field(default=None, description="System-managed. Do not populate — use lead_email/lead_name/lead_company/lead_phone instead.")
|
||||
lead_summary: Optional[str] = Field(default=None, description="A 1-2 sentence qualification summary of this lead from the whole conversation (e.g. '40-person fintech replacing Intercom, ~800 Shopify orders/week, wants a demo this week').")
|
||||
lead_consent: bool = Field(default=False, description="Set to true only when the visitor has explicitly agreed to be contacted / share their details.")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def normalize_fields(cls, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Normalize incoming fields and provide default values for missing fields."""
|
||||
if isinstance(data, dict):
|
||||
normalized = dict(data)
|
||||
|
||||
key_mappings = {
|
||||
'requestRating': 'request_rating',
|
||||
'requestrating': 'request_rating',
|
||||
'transferToHuman': 'transfer_to_human',
|
||||
'transfertohuman': 'transfer_to_human',
|
||||
'endChat': 'end_chat',
|
||||
'endchat': 'end_chat',
|
||||
'createTicket': 'create_ticket',
|
||||
'createticket': 'create_ticket',
|
||||
'requestLeadCapture': 'request_lead_capture',
|
||||
'requestleadcapture': 'request_lead_capture',
|
||||
'transferReason': 'transfer_reason',
|
||||
'transferreason': 'transfer_reason',
|
||||
'transferDescription': 'transfer_description',
|
||||
'transferdescription': 'transfer_description',
|
||||
'endChatReason': 'end_chat_reason',
|
||||
'endchatreason': 'end_chat_reason',
|
||||
'endChatDescription': 'end_chat_description',
|
||||
'endchatdescription': 'end_chat_description',
|
||||
'ticketSummary': 'ticket_summary',
|
||||
'ticketsummary': 'ticket_summary',
|
||||
'ticketDescription': 'ticket_description',
|
||||
'ticketdescription': 'ticket_description',
|
||||
'integrationType': 'integration_type',
|
||||
'integrationtype': 'integration_type',
|
||||
'ticketId': 'ticket_id',
|
||||
'ticketid': 'ticket_id',
|
||||
'ticketStatus': 'ticket_status',
|
||||
'ticketstatus': 'ticket_status',
|
||||
'ticketpriority': 'ticket_priority',
|
||||
'content': 'message',
|
||||
# Map incoming shopifyOutput/shopify_output to the new field name
|
||||
'shopifyOutput': 'shopify_output',
|
||||
'shopifyoutput': 'shopify_output',
|
||||
}
|
||||
|
||||
for key in list(normalized.keys()):
|
||||
if key in key_mappings:
|
||||
normalized[key_mappings[key]] = normalized.pop(key)
|
||||
|
||||
# Drop explicit nulls so each field's default applies. Models often emit
|
||||
# `"transfer_to_human": null` / `"create_ticket": null` for the flags they
|
||||
# aren't setting; without this, pydantic rejects null on the non-nullable
|
||||
# boolean fields and the whole response is discarded (losing end_chat,
|
||||
# lead_email, etc.). Optional fields default to None anyway, so dropping is safe.
|
||||
normalized = {k: v for k, v in normalized.items() if v is not None}
|
||||
|
||||
# Set default values for missing fields
|
||||
if 'message' not in normalized:
|
||||
normalized['message'] = "No response generated"
|
||||
if 'transfer_to_human' not in normalized:
|
||||
normalized['transfer_to_human'] = False
|
||||
if 'end_chat' not in normalized:
|
||||
normalized['end_chat'] = False
|
||||
if 'request_rating' not in normalized:
|
||||
normalized['request_rating'] = False
|
||||
if 'create_ticket' not in normalized:
|
||||
normalized['create_ticket'] = False
|
||||
|
||||
# Validate enum fields
|
||||
# Validate end_chat_reason
|
||||
if 'end_chat_reason' in normalized and normalized['end_chat_reason'] is not None:
|
||||
valid_reasons = [reason.value for reason in EndChatReasonType]
|
||||
if normalized['end_chat_reason'] not in valid_reasons:
|
||||
# If invalid value, set to None
|
||||
normalized['end_chat_reason'] = None
|
||||
|
||||
# Validate transfer_reason
|
||||
if 'transfer_reason' in normalized and normalized['transfer_reason'] is not None:
|
||||
valid_reasons = [reason.value for reason in TransferReasonType]
|
||||
if normalized['transfer_reason'] not in valid_reasons:
|
||||
# If invalid value, set to None
|
||||
normalized['transfer_reason'] = None
|
||||
|
||||
return normalized
|
||||
return data
|
||||
|
||||
# Keys a model might wrap a choice in when it returns objects, not strings
|
||||
_CHOICE_TEXT_KEYS = ("title", "text", "label")
|
||||
|
||||
|
||||
class ChannelChatResponse(ChatResponse):
|
||||
"""The reply schema for agents on a channel that can show tappable
|
||||
choices — the only ones whose schema offers them, so every other agent's
|
||||
output contract is unchanged."""
|
||||
|
||||
# A list of plain strings: strict structured outputs handle it on every
|
||||
# provider, unlike a list of objects.
|
||||
quick_replies: Optional[List[str]] = Field(default=None, description="Leave empty unless your instructions tell you to offer choices. The short choices the customer can tap, in order.")
|
||||
|
||||
@field_validator('quick_replies', mode='before')
|
||||
@classmethod
|
||||
def clean_quick_replies(cls, value: Any) -> Optional[List[str]]:
|
||||
"""Keep usable options only, never raise: a validation error here
|
||||
would turn the whole reply into the generic error message."""
|
||||
if not isinstance(value, list):
|
||||
return None
|
||||
options: List[str] = []
|
||||
seen = set()
|
||||
for item in value:
|
||||
text = _choice_text(item)
|
||||
if text and text.casefold() not in seen:
|
||||
seen.add(text.casefold())
|
||||
options.append(text)
|
||||
return options[:MAX_CHOICE_OPTIONS] or None
|
||||
|
||||
|
||||
def _choice_text(item: Any) -> str:
|
||||
"""One choice as clean text: a string, or the text inside an object a
|
||||
model wrapped it in; anything else is dropped rather than shown raw."""
|
||||
if isinstance(item, dict):
|
||||
item = next((item[k] for k in _CHOICE_TEXT_KEYS if isinstance(item.get(k), str)), None)
|
||||
if not isinstance(item, str):
|
||||
return ""
|
||||
return " ".join(item.split())[:MAX_CHOICE_LENGTH]
|
||||
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class CrmConnectionOut(BaseModel):
|
||||
"""A connected CRM as shown on the integrations page. Never exposes
|
||||
credentials — only identity and health."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
provider: str
|
||||
status: str
|
||||
display_name: Optional[str] = None
|
||||
external_account_id: str
|
||||
last_error: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
recent_failures: int = 0
|
||||
|
||||
|
||||
class CrmTestResult(BaseModel):
|
||||
ok: bool
|
||||
account_name: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class CrmCustomerSyncOut(BaseModel):
|
||||
"""One CRM link for a person, shown on the People drawer."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
provider: str
|
||||
contact_id: Optional[str] = None
|
||||
record_url: Optional[str] = None
|
||||
synced_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class PersonCrmStatus(BaseModel):
|
||||
"""CRM state for a person: which CRMs the org has connected, and where this
|
||||
person is already synced."""
|
||||
connected_providers: List[str]
|
||||
synced: List[CrmCustomerSyncOut]
|
||||
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Literal, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
# Reuse the ORM enums so the API contract can never drift from the DB.
|
||||
from app.models.faq import (
|
||||
DEFAULT_FAQ_CATEGORY,
|
||||
FAQ_META_DESCRIPTION_MAX_LENGTH,
|
||||
FAQ_META_TITLE_MAX_LENGTH,
|
||||
FAQ_SLUG_MAX_LENGTH,
|
||||
FAQ_URL_PATH_MAX_LENGTH,
|
||||
FAQStatus,
|
||||
)
|
||||
from app.models.schemas.pagination import Pagination
|
||||
from app.utils.urls import normalize_url
|
||||
|
||||
MAX_QUESTION_LENGTH = 300
|
||||
# Articles imported as-is (help-center pages with steps/images) routinely run
|
||||
# long; the column is TEXT and the public renderer paginates nothing, so the
|
||||
# cap only guards against runaway payloads.
|
||||
MAX_ANSWER_LENGTH = 20000
|
||||
MAX_BULK_IDS = 200
|
||||
|
||||
|
||||
class SeoFields(BaseModel):
|
||||
"""Optional per-article SEO overrides, shared by create and update.
|
||||
|
||||
Blank strings are coerced to None so clearing a field in the admin UI
|
||||
restores the derived default instead of publishing an empty tag. `slug` is
|
||||
normalized and de-duplicated server-side (see resolve_faq_slug) — validation
|
||||
here only bounds the input, to exactly what the columns accept (values that
|
||||
are too long are rejected rather than silently truncated).
|
||||
"""
|
||||
slug: Optional[str] = Field(default=None, max_length=FAQ_SLUG_MAX_LENGTH)
|
||||
# A URL path carried over from a help center the org migrated from, so the
|
||||
# article stays reachable at the URL it already ranks for. Accepts a full
|
||||
# URL or a bare path; normalized and checked server-side (normalize_url_path
|
||||
# / resolve_faq_url_path). Blank clears it, restoring /a/{slug}.
|
||||
url_path: Optional[str] = Field(default=None, max_length=FAQ_URL_PATH_MAX_LENGTH)
|
||||
meta_title: Optional[str] = Field(default=None, max_length=FAQ_META_TITLE_MAX_LENGTH)
|
||||
meta_description: Optional[str] = Field(
|
||||
default=None, max_length=FAQ_META_DESCRIPTION_MAX_LENGTH
|
||||
)
|
||||
|
||||
@field_validator("slug", "url_path", "meta_title", "meta_description")
|
||||
@classmethod
|
||||
def _blank_to_none(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is None:
|
||||
return None
|
||||
return v.strip() or None
|
||||
|
||||
|
||||
class FAQBase(BaseModel):
|
||||
question: str = Field(min_length=1, max_length=MAX_QUESTION_LENGTH)
|
||||
answer: str = Field(min_length=1, max_length=MAX_ANSWER_LENGTH)
|
||||
category: str = Field(default=DEFAULT_FAQ_CATEGORY, min_length=1, max_length=100)
|
||||
|
||||
@field_validator("question", "answer", "category")
|
||||
@classmethod
|
||||
def _strip(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("must not be blank")
|
||||
return v
|
||||
|
||||
|
||||
class FAQCreate(FAQBase, SeoFields):
|
||||
status: FAQStatus = FAQStatus.DRAFT
|
||||
|
||||
|
||||
class FAQUpdate(SeoFields):
|
||||
"""Partial update — omitted fields keep their current values (apply with
|
||||
model_dump(exclude_unset=True)), so e.g. a status-only PATCH can't silently
|
||||
reset the category to its default."""
|
||||
question: Optional[str] = Field(default=None, min_length=1, max_length=MAX_QUESTION_LENGTH)
|
||||
answer: Optional[str] = Field(default=None, min_length=1, max_length=MAX_ANSWER_LENGTH)
|
||||
category: Optional[str] = Field(default=None, min_length=1, max_length=100)
|
||||
status: Optional[FAQStatus] = None
|
||||
|
||||
@field_validator("question", "answer", "category")
|
||||
@classmethod
|
||||
def _strip(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is None:
|
||||
return None
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("must not be blank")
|
||||
return v
|
||||
|
||||
|
||||
class FAQResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
question: str
|
||||
answer: str
|
||||
category: str
|
||||
slug: Optional[str] = None
|
||||
url_path: Optional[str] = None
|
||||
source_url: Optional[str] = None
|
||||
meta_title: Optional[str] = None
|
||||
meta_description: Optional[str] = None
|
||||
status: FAQStatus
|
||||
knowledge_id: Optional[int] = None
|
||||
source_label: Optional[str] = None
|
||||
helpful_yes: int = 0
|
||||
helpful_no: int = 0
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class FAQListResponse(BaseModel):
|
||||
faqs: List[FAQResponse]
|
||||
pagination: Pagination
|
||||
|
||||
|
||||
class FAQBulkStatusRequest(BaseModel):
|
||||
faq_ids: List[UUID] = Field(min_length=1, max_length=MAX_BULK_IDS)
|
||||
status: FAQStatus
|
||||
|
||||
|
||||
class FAQBulkDeleteRequest(BaseModel):
|
||||
faq_ids: List[UUID] = Field(min_length=1, max_length=MAX_BULK_IDS)
|
||||
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
"""Optional narrowing to specific knowledge sources; empty = new sources
|
||||
only (those without FAQs yet)."""
|
||||
knowledge_ids: Optional[List[int]] = Field(default=None, max_length=MAX_BULK_IDS)
|
||||
|
||||
|
||||
class GenerationSourceResponse(BaseModel):
|
||||
"""One knowledge source in the generation picker."""
|
||||
id: int
|
||||
name: str
|
||||
source_type: str
|
||||
has_faqs: bool
|
||||
pages: int
|
||||
estimated_calls: int
|
||||
|
||||
|
||||
class GenerationEstimateResponse(BaseModel):
|
||||
"""Confirm-dialog / source-picker numbers for a generation run.
|
||||
remaining_credits is None when unlimited (OSS, no cap, or own model key)."""
|
||||
total_sources: int
|
||||
new_sources: int
|
||||
pages: int
|
||||
estimated_calls: int
|
||||
metered: bool
|
||||
remaining_credits: Optional[int] = None
|
||||
sources: List[GenerationSourceResponse] = []
|
||||
|
||||
|
||||
class ImportRequest(BaseModel):
|
||||
url: str = Field(min_length=1, max_length=2048)
|
||||
# qa = single page, LLM extracts Q&A pairs (uses credits).
|
||||
# articles = crawl the page's linked articles and import each as-is
|
||||
# (HTML -> Markdown, images re-hosted, no LLM).
|
||||
mode: Literal["qa", "articles"] = "qa"
|
||||
# articles mode only: serve each imported article at the URL path it already
|
||||
# has on the source help center, so the org's search rankings and inbound
|
||||
# links survive the migration. Ignored for qa.
|
||||
preserve_urls: bool = False
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def _https_url(cls, v: str) -> str:
|
||||
# Scheme-level validation only; the import worker re-checks the URL
|
||||
# against the SSRF guards in app.knowledge.url_safety before fetching.
|
||||
return normalize_url(v, require_https=True)
|
||||
|
||||
|
||||
class GenerationJobResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
job_type: str
|
||||
status: str
|
||||
stage: str
|
||||
progress_percentage: float
|
||||
faqs_created: int
|
||||
source_url: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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 re
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.models.help_center import DomainStatus, SSLStatus
|
||||
from app.utils.urls import normalize_url
|
||||
|
||||
MAX_HEADER_LINKS = 6
|
||||
_HEX_COLOR_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$")
|
||||
# RFC 1035-ish hostname: labels of letters/digits/hyphens, at least one dot.
|
||||
# The TLD alternation admits punycode labels (xn--...) that IDNA encoding of
|
||||
# internationalized domains produces.
|
||||
_HOSTNAME_RE = re.compile(
|
||||
r"^(?=.{4,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,}|xn--[a-z0-9-]{2,})$"
|
||||
)
|
||||
|
||||
|
||||
class HeaderLink(BaseModel):
|
||||
label: str = Field(min_length=1, max_length=40)
|
||||
url: str = Field(min_length=1, max_length=2048)
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def _url(cls, v: str) -> str:
|
||||
# Raises on blank/whitespace input rather than returning None into a
|
||||
# non-optional str field.
|
||||
return normalize_url(v)
|
||||
|
||||
|
||||
class HelpCenterSettingsUpdate(BaseModel):
|
||||
enabled: Optional[bool] = None
|
||||
title: Optional[str] = Field(default=None, max_length=120)
|
||||
description: Optional[str] = Field(default=None, max_length=300)
|
||||
brand_color: Optional[str] = None
|
||||
header_links: Optional[List[HeaderLink]] = Field(default=None, max_length=MAX_HEADER_LINKS)
|
||||
cta_text: Optional[str] = Field(default=None, max_length=40)
|
||||
cta_url: Optional[str] = Field(default=None, max_length=2048)
|
||||
cta_enabled: Optional[bool] = None
|
||||
auto_generate: Optional[bool] = None
|
||||
agent_id: Optional[UUID] = None
|
||||
ai_search_enabled: Optional[bool] = None
|
||||
chat_widget_enabled: Optional[bool] = None
|
||||
|
||||
@field_validator("brand_color")
|
||||
@classmethod
|
||||
def _hex_color(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is None:
|
||||
return None
|
||||
v = v.strip()
|
||||
if not _HEX_COLOR_RE.match(v):
|
||||
raise ValueError("brand_color must be a hex color like #4338CA")
|
||||
return v
|
||||
|
||||
@field_validator("cta_url")
|
||||
@classmethod
|
||||
def _cta_url(cls, v: Optional[str]) -> Optional[str]:
|
||||
# Blank clears the CTA link; anything else is normalized.
|
||||
if v is None or not v.strip():
|
||||
return None
|
||||
return normalize_url(v)
|
||||
|
||||
|
||||
class HelpCenterAgentOption(BaseModel):
|
||||
"""Org agents offered in the AI-search agent selector."""
|
||||
id: UUID
|
||||
name: str
|
||||
has_widget: bool
|
||||
|
||||
|
||||
class DnsRecord(BaseModel):
|
||||
"""One row of the DNS-records table in the admin UI."""
|
||||
type: str # CNAME | TXT
|
||||
host: str
|
||||
value: str
|
||||
verified: bool
|
||||
|
||||
|
||||
class DomainStatusResponse(BaseModel):
|
||||
custom_domain: Optional[str] = None
|
||||
domain_status: DomainStatus
|
||||
ssl_status: SSLStatus
|
||||
records: List[DnsRecord] = []
|
||||
domain_verified_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class HelpCenterSettingsResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
enabled: bool
|
||||
slug: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
logo_url: Optional[str] = None
|
||||
favicon_url: Optional[str] = None
|
||||
brand_color: str
|
||||
header_links: List[HeaderLink] = []
|
||||
cta_text: Optional[str] = None
|
||||
cta_url: Optional[str] = None
|
||||
cta_enabled: bool
|
||||
auto_generate: bool
|
||||
agent_id: Optional[UUID] = None
|
||||
ai_search_enabled: bool
|
||||
chat_widget_enabled: bool
|
||||
|
||||
# Enriched by the API layer (not ORM columns):
|
||||
live_url: Optional[str] = None
|
||||
published_count: int = 0
|
||||
plan_allowed: bool = True
|
||||
agents: List[HelpCenterAgentOption] = []
|
||||
domain: Optional[DomainStatusResponse] = None
|
||||
|
||||
@field_validator("header_links", mode="before")
|
||||
@classmethod
|
||||
def _coerce_null_json(cls, v):
|
||||
return v or []
|
||||
|
||||
|
||||
class DomainRequest(BaseModel):
|
||||
domain: str = Field(min_length=4, max_length=255)
|
||||
|
||||
@field_validator("domain")
|
||||
@classmethod
|
||||
def _hostname(cls, v: str) -> str:
|
||||
v = v.strip().lower().rstrip(".")
|
||||
v = re.sub(r"^https?://", "", v).split("/")[0]
|
||||
try:
|
||||
v = v.encode("idna").decode("ascii")
|
||||
except UnicodeError as exc:
|
||||
raise ValueError("domain is not a valid hostname") from exc
|
||||
if not _HOSTNAME_RE.match(v):
|
||||
raise ValueError("domain is not a valid hostname")
|
||||
return v
|
||||
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TriageResult(BaseModel):
|
||||
"""Structured output of the AI triage pass over a new ticket."""
|
||||
# Rewritten, clearer title. The original is preserved on the ticket.
|
||||
title: str = Field(max_length=500)
|
||||
priority: str = Field(description="urgent, high, medium or low")
|
||||
severity: Optional[int] = Field(default=None, ge=1, le=3)
|
||||
# Short intent classification, e.g. "billing_issue", "bug_report",
|
||||
# "feature_request", "account_access", "outage".
|
||||
intent: str = Field(max_length=100)
|
||||
# 1-3 sentence internal summary of the issue.
|
||||
summary: str = Field(max_length=2000)
|
||||
# ["payments", "ledger"] — up to two short lowercase topic tags.
|
||||
tags: List[str] = Field(default_factory=list, max_length=3)
|
||||
confidence: float = Field(ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class HypothesisSpec(BaseModel):
|
||||
"""One proposed root-cause hypothesis, before testing."""
|
||||
title: str = Field(max_length=300)
|
||||
rationale: str = Field(max_length=2000)
|
||||
|
||||
|
||||
class HypothesisPlan(BaseModel):
|
||||
"""Output of the hypothesis-generation step (H1..Hn, most likely first)."""
|
||||
hypotheses: List[HypothesisSpec] = Field(min_length=1, max_length=5)
|
||||
|
||||
|
||||
class HypothesisVerdict(BaseModel):
|
||||
"""Outcome of testing a single hypothesis with bounded tool calls."""
|
||||
status: str = Field(description="validated, invalidated or inconclusive")
|
||||
confidence: float = Field(ge=0.0, le=1.0)
|
||||
# What the evidence showed, citing the queries/results that decided it.
|
||||
conclusion: str = Field(max_length=4000)
|
||||
|
||||
|
||||
class RCATimelineEntry(BaseModel):
|
||||
time: str = Field(max_length=100)
|
||||
event: str = Field(max_length=500)
|
||||
|
||||
|
||||
class RCAResult(BaseModel):
|
||||
"""Structured root-cause analysis synthesized from the tested hypotheses
|
||||
and captured evidence."""
|
||||
summary: str = Field(max_length=4000)
|
||||
impact: Optional[str] = Field(default=None, max_length=4000)
|
||||
timeline: List[RCATimelineEntry] = Field(default_factory=list, max_length=20)
|
||||
investigation_log: Optional[str] = Field(default=None, max_length=6000)
|
||||
contributing_factors: List[str] = Field(default_factory=list, max_length=10)
|
||||
# Must cite hypotheses inline, e.g. "[H1 · 0.92]".
|
||||
conclusion: str = Field(max_length=6000)
|
||||
remediation: Optional[str] = Field(default=None, max_length=4000)
|
||||
prevention: Optional[str] = Field(default=None, max_length=4000)
|
||||
# Plain-language section a human reviews and sends to the customer.
|
||||
customer_summary: str = Field(max_length=4000)
|
||||
confidence: float = Field(ge=0.0, le=1.0)
|
||||
|
||||
|
||||
TRIAGE_JSON_TOOL_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string", "description": "Clear, specific rewritten title."},
|
||||
"priority": {"type": "string", "enum": ["urgent", "high", "medium", "low"]},
|
||||
"severity": {"type": "integer", "minimum": 1, "maximum": 3},
|
||||
"intent": {"type": "string", "description": "Short intent slug, e.g. bug_report."},
|
||||
"summary": {"type": "string", "description": "1-3 sentence internal summary."},
|
||||
"tags": {"type": "array", "items": {"type": "string"}, "description": "Up to two short lowercase topic tags."},
|
||||
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
},
|
||||
"required": ["title", "priority", "intent", "summary", "confidence"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
HYPOTHESES_JSON_TOOL_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"hypotheses": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 5,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string", "description": "Short testable hypothesis."},
|
||||
"rationale": {"type": "string", "description": "Why this is suspected and what evidence would confirm it."},
|
||||
},
|
||||
"required": ["title", "rationale"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["hypotheses"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
VERDICT_JSON_TOOL_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {"type": "string", "enum": ["validated", "invalidated", "inconclusive"]},
|
||||
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"conclusion": {"type": "string", "description": "What the evidence showed, citing queries/results."},
|
||||
},
|
||||
"required": ["status", "confidence", "conclusion"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
RCA_JSON_TOOL_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"summary": {"type": "string"},
|
||||
"impact": {"type": "string"},
|
||||
"timeline": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {"time": {"type": "string"}, "event": {"type": "string"}},
|
||||
"required": ["time", "event"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
"investigation_log": {"type": "string"},
|
||||
"contributing_factors": {"type": "array", "items": {"type": "string"}},
|
||||
"conclusion": {"type": "string", "description": "Cites hypotheses inline, e.g. [H1 · 0.92]."},
|
||||
"remediation": {"type": "string"},
|
||||
"prevention": {"type": "string"},
|
||||
"customer_summary": {"type": "string", "description": "Plain language for the customer."},
|
||||
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
},
|
||||
"required": ["summary", "conclusion", "customer_summary", "confidence"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List, Any
|
||||
from app.models.schemas.agent import AgentResponse
|
||||
|
||||
class AgentWithJiraConfig(AgentResponse):
|
||||
"""Model for agent data with Jira configuration."""
|
||||
tools: Optional[List[Any]] = None
|
||||
is_default: bool = False
|
||||
jira_enabled: bool = False
|
||||
jira_project_key: Optional[str] = None
|
||||
jira_issue_type_id: Optional[str] = None
|
||||
groups: List[Any] = []
|
||||
organization: Optional[Any] = None
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from typing import Optional, List, Dict, Any
|
||||
from uuid import UUID
|
||||
|
||||
# Reuse the ORM enums so the API contract can never drift from the DB.
|
||||
from app.models.lead_capture import LeadAssignmentMode, CrmSyncTarget
|
||||
|
||||
# Guardrails to keep the config small.
|
||||
MAX_FIELDS = 30
|
||||
|
||||
|
||||
class LeadField(BaseModel):
|
||||
"""A detail to collect. Standard fields (email/name/company/phone) carry
|
||||
standard=True; custom fields add a label and optional allowed values — which
|
||||
the agent uses to steer/normalise the answer in conversation (no form)."""
|
||||
key: str
|
||||
standard: bool = False
|
||||
enabled: bool = True
|
||||
required: bool = False
|
||||
label: Optional[str] = Field(default=None, max_length=100)
|
||||
options: Optional[List[str]] = None
|
||||
|
||||
|
||||
class LeadCaptureConfigBase(BaseModel):
|
||||
enabled: bool = False
|
||||
# GDPR: require the visitor's explicit consent before recording a lead.
|
||||
require_consent: bool = True
|
||||
# Optional free-text steering, appended to the agent's prompt.
|
||||
guidance: Optional[str] = Field(default=None, max_length=2000)
|
||||
fields: List[LeadField] = Field(default_factory=list, max_length=MAX_FIELDS)
|
||||
# Routing config — persisted only; no CRM/Slack/assignment side effects in phase 1.
|
||||
assignment_mode: LeadAssignmentMode = LeadAssignmentMode.NONE
|
||||
assignment_target_user_id: Optional[UUID] = None
|
||||
crm_sync_target: CrmSyncTarget = CrmSyncTarget.NONE
|
||||
slack_notify_enabled: bool = False
|
||||
|
||||
|
||||
class LeadCaptureConfigUpdate(LeadCaptureConfigBase):
|
||||
"""Full-replace payload for the whole Lead Capture tab (mirrors how the
|
||||
customization tab saves its entire object in one call)."""
|
||||
pass
|
||||
|
||||
|
||||
class LeadCaptureConfigResponse(LeadCaptureConfigBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
agent_id: UUID
|
||||
|
||||
@field_validator("fields", mode="before")
|
||||
@classmethod
|
||||
def _coerce_null_json(cls, v):
|
||||
# JSON column defaults to NULL in the DB; present it as an empty list.
|
||||
return v or []
|
||||
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field, field_serializer, validator
|
||||
from typing import List, Optional, Dict, Union
|
||||
from enum import Enum
|
||||
from uuid import UUID
|
||||
from app.models.mcp_tool import MCPTransportType
|
||||
|
||||
|
||||
class MCPTransportTypeEnum(str, Enum):
|
||||
STDIO = "stdio"
|
||||
SSE = "sse"
|
||||
HTTP = "http"
|
||||
|
||||
|
||||
# Stand-in sent to the client in place of stored environment/header values.
|
||||
# An update that sends it back means "keep what is stored", so correcting an
|
||||
# unrelated field never costs a credential rotation — providers typically show
|
||||
# an API key exactly once.
|
||||
SECRET_MASK = "********"
|
||||
|
||||
# Ceiling on a connector's usage guidance. Enforced here so an over-long value
|
||||
# is a clean 422, and again at injection so a row written before this existed
|
||||
# can never blow the prompt.
|
||||
MAX_USAGE_GUIDANCE_CHARS = 2000
|
||||
|
||||
|
||||
def mask_secret_values(values: Optional[Dict[str, str]]) -> Optional[Dict[str, str]]:
|
||||
"""Replace every value with the mask, keeping the keys so the operator can
|
||||
still see which variables are set."""
|
||||
if not values:
|
||||
return values
|
||||
return {key: SECRET_MASK for key in values}
|
||||
|
||||
|
||||
def unmask_secret_values(
|
||||
incoming: Optional[Dict[str, str]], stored: Optional[Dict[str, str]]
|
||||
) -> Optional[Dict[str, str]]:
|
||||
"""Resolve an incoming secret map against what is stored: a masked value
|
||||
keeps the stored one, anything else is a deliberate replacement. A masked
|
||||
key with nothing stored behind it is dropped rather than written empty."""
|
||||
if not incoming:
|
||||
return incoming
|
||||
stored = stored or {}
|
||||
resolved = {}
|
||||
for key, value in incoming.items():
|
||||
if value != SECRET_MASK:
|
||||
resolved[key] = value
|
||||
elif key in stored:
|
||||
resolved[key] = stored[key]
|
||||
return resolved
|
||||
|
||||
|
||||
class MCPToolBase(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255, description="Display name for the MCP server")
|
||||
description: Optional[str] = Field(None, description="Optional description of what this MCP server does")
|
||||
usage_guidance: Optional[str] = Field(
|
||||
None, max_length=MAX_USAGE_GUIDANCE_CHARS,
|
||||
description="What this source holds and how to query it — given to the AI as tool guidance",
|
||||
)
|
||||
transport_type: MCPTransportTypeEnum
|
||||
enabled: bool = Field(default=True, description="Whether this MCP tool is enabled")
|
||||
|
||||
# STDIO transport fields
|
||||
command: Optional[str] = Field(None, description="Command to run the MCP server (e.g., 'npx', 'uvx', 'node')")
|
||||
args: Optional[List[str]] = Field(None, description="Arguments to pass to the MCP server")
|
||||
env_vars: Optional[Dict[str, str]] = Field(None, description="Environment variables as key-value pairs")
|
||||
|
||||
# SSE/HTTP transport fields
|
||||
url: Optional[str] = Field(None, description="Server URL for SSE/HTTP transport")
|
||||
headers: Optional[Dict[str, str]] = Field(None, description="HTTP headers as key-value pairs")
|
||||
timeout: Optional[int] = Field(None, ge=1, le=300, description="Connection timeout in seconds (1-300)")
|
||||
sse_read_timeout: Optional[int] = Field(None, ge=1, le=600, description="SSE read timeout in seconds (1-600)")
|
||||
terminate_on_close: Optional[bool] = Field(default=True, description="Whether to terminate connection when client is closed (HTTP only)")
|
||||
|
||||
@validator('command')
|
||||
def validate_stdio_command(cls, v, values):
|
||||
if values.get('transport_type') == MCPTransportTypeEnum.STDIO and not v:
|
||||
raise ValueError('Command is required for STDIO transport')
|
||||
return v
|
||||
|
||||
@validator('url')
|
||||
def validate_url(cls, v, values):
|
||||
if values.get('transport_type') in [MCPTransportTypeEnum.SSE, MCPTransportTypeEnum.HTTP] and not v:
|
||||
raise ValueError('URL is required for SSE/HTTP transport')
|
||||
return v
|
||||
|
||||
|
||||
class MCPToolCreate(MCPToolBase):
|
||||
organization_id: Optional[UUID] = None
|
||||
|
||||
|
||||
class MCPToolUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
usage_guidance: Optional[str] = Field(
|
||||
None, max_length=MAX_USAGE_GUIDANCE_CHARS,
|
||||
description="What this source holds and how to query it — given to the AI as tool guidance",
|
||||
)
|
||||
transport_type: Optional[MCPTransportTypeEnum] = None
|
||||
enabled: Optional[bool] = None
|
||||
|
||||
# STDIO transport fields
|
||||
command: Optional[str] = None
|
||||
args: Optional[List[str]] = None
|
||||
env_vars: Optional[Dict[str, str]] = None
|
||||
|
||||
# SSE/HTTP transport fields
|
||||
url: Optional[str] = None
|
||||
headers: Optional[Dict[str, str]] = None
|
||||
timeout: Optional[int] = Field(None, ge=1, le=300)
|
||||
sse_read_timeout: Optional[int] = Field(None, ge=1, le=600)
|
||||
terminate_on_close: Optional[bool] = None
|
||||
|
||||
|
||||
class MCPToolResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str]
|
||||
# Deliberately not masked — guidance is prompt text, not a credential.
|
||||
usage_guidance: Optional[str]
|
||||
transport_type: MCPTransportTypeEnum
|
||||
enabled: bool
|
||||
|
||||
# STDIO transport fields
|
||||
command: Optional[str]
|
||||
args: Optional[List[str]]
|
||||
env_vars: Optional[Dict[str, str]]
|
||||
|
||||
# SSE/HTTP transport fields
|
||||
url: Optional[str]
|
||||
headers: Optional[Dict[str, str]]
|
||||
timeout: Optional[int]
|
||||
sse_read_timeout: Optional[int]
|
||||
terminate_on_close: Optional[bool]
|
||||
|
||||
organization_id: UUID
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@field_serializer("env_vars", "headers")
|
||||
def _mask_secrets(self, values: Optional[Dict[str, str]], _info):
|
||||
"""Credentials never leave the server. The edit form round-trips the
|
||||
mask, and the update endpoint resolves it back to the stored value."""
|
||||
return mask_secret_values(values)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MCPToolReferencesResponse(BaseModel):
|
||||
"""Where a connector is in use, so a delete confirmation can name what it
|
||||
is about to break instead of asking blind."""
|
||||
agents: List[str] = []
|
||||
used_in_investigations: bool = False
|
||||
|
||||
|
||||
class MCPToolToAgentCreate(BaseModel):
|
||||
mcp_tool_id: int
|
||||
agent_id: UUID
|
||||
|
||||
|
||||
class MCPToolToAgentResponse(BaseModel):
|
||||
id: int
|
||||
mcp_tool_id: int
|
||||
agent_id: UUID
|
||||
created_at: datetime
|
||||
mcp_tool: MCPToolResponse
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AgentMCPToolsResponse(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
mcp_tools: List[MCPToolResponse] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MCPToolTestResponse(BaseModel):
|
||||
"""Result of a one-off connection test against a configured tool."""
|
||||
success: bool
|
||||
functions: List[str] = []
|
||||
error: Optional[str] = None
|
||||
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict
|
||||
from app.models.notification import NotificationType
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
class NotificationResponse(BaseModel):
|
||||
id: int
|
||||
user_id: UUID
|
||||
type: NotificationType
|
||||
title: str
|
||||
message: str
|
||||
notification_metadata: Optional[Dict] = None
|
||||
is_read: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class NotificationSettingsOut(BaseModel):
|
||||
"""A user's chat push preferences."""
|
||||
notify_new_chat: bool
|
||||
notify_chat_transfer: bool
|
||||
notify_chat_assigned: bool
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class NotificationSettingsUpdate(BaseModel):
|
||||
"""Partial update — only the toggles the caller sent are applied."""
|
||||
notify_new_chat: Optional[bool] = None
|
||||
notify_chat_transfer: Optional[bool] = None
|
||||
notify_chat_assigned: Optional[bool] = None
|
||||
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional, Dict, TypedDict
|
||||
from uuid import UUID
|
||||
|
||||
from app.models.schemas.user import UserResponse
|
||||
|
||||
|
||||
class BusinessHours(TypedDict):
|
||||
start: str
|
||||
end: str
|
||||
enabled: bool
|
||||
|
||||
|
||||
class BusinessHoursDict(TypedDict):
|
||||
monday: BusinessHours
|
||||
tuesday: BusinessHours
|
||||
wednesday: BusinessHours
|
||||
thursday: BusinessHours
|
||||
friday: BusinessHours
|
||||
saturday: BusinessHours
|
||||
sunday: BusinessHours
|
||||
|
||||
|
||||
class OrganizationBase(BaseModel):
|
||||
name: str
|
||||
domain: str
|
||||
timezone: Optional[str] = 'UTC'
|
||||
business_hours: Optional[BusinessHoursDict] = {
|
||||
'monday': {'start': '09:00', 'end': '17:00', 'enabled': True},
|
||||
'tuesday': {'start': '09:00', 'end': '17:00', 'enabled': True},
|
||||
'wednesday': {'start': '09:00', 'end': '17:00', 'enabled': True},
|
||||
'thursday': {'start': '09:00', 'end': '17:00', 'enabled': True},
|
||||
'friday': {'start': '09:00', 'end': '17:00', 'enabled': True},
|
||||
'saturday': {'start': '09:00', 'end': '17:00', 'enabled': False},
|
||||
'sunday': {'start': '09:00', 'end': '17:00', 'enabled': False}
|
||||
}
|
||||
settings: Optional[Dict] = {}
|
||||
|
||||
|
||||
class OrganizationCreate(OrganizationBase):
|
||||
admin_email: EmailStr
|
||||
admin_name: str
|
||||
admin_password: str
|
||||
|
||||
|
||||
class OrganizationUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
domain: Optional[str] = None
|
||||
timezone: Optional[str] = None
|
||||
business_hours: Optional[BusinessHoursDict] = None
|
||||
settings: Optional[Dict] = None
|
||||
|
||||
|
||||
class OrganizationCreateResponse(OrganizationBase):
|
||||
id: UUID
|
||||
is_active: bool
|
||||
access_token: Optional[str] = None
|
||||
refresh_token: Optional[str] = None
|
||||
token_type: Optional[str] = None
|
||||
user: UserResponse
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class OrganizationResponse(OrganizationBase):
|
||||
id: UUID
|
||||
is_active: bool
|
||||
access_token: Optional[str] = None
|
||||
refresh_token: Optional[str] = None
|
||||
token_type: Optional[str] = None
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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 math
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Pagination(BaseModel):
|
||||
"""Standard list-endpoint pagination block (shape matches the knowledge
|
||||
API's inline dicts: total / page / page_size / total_pages)."""
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
|
||||
@classmethod
|
||||
def build(cls, total: int, page: int, page_size: int) -> "Pagination":
|
||||
return cls(
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=max(1, math.ceil(total / page_size)) if total else 0,
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.customer import LeadStage
|
||||
|
||||
|
||||
class PersonListItem(BaseModel):
|
||||
id: UUID
|
||||
name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
is_anonymous: bool = False
|
||||
lead_stage: LeadStage
|
||||
qualified: bool = False
|
||||
source: Optional[Dict[str, Any]] = None
|
||||
captured_at: Optional[datetime] = None
|
||||
last_activity: Optional[datetime] = None
|
||||
synced: bool = False # phase 1: CRM sync not implemented
|
||||
|
||||
|
||||
class PeopleListResponse(BaseModel):
|
||||
items: List[PersonListItem]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class PeopleStats(BaseModel):
|
||||
total_people: int
|
||||
# Engaged-but-unidentified browser sessions — the lead-capture funnel's
|
||||
# raw top, shown as a count beside the tabs, not as directory rows.
|
||||
anonymous: int = 0
|
||||
new_leads_7d: int
|
||||
customers: int
|
||||
synced_to_crm: int = 0 # phase 1: always 0
|
||||
|
||||
|
||||
class TimelineEntry(BaseModel):
|
||||
stage: str
|
||||
at: datetime
|
||||
|
||||
|
||||
class PersonConversation(BaseModel):
|
||||
session_id: UUID
|
||||
agent_name: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
last_message: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class PersonDetail(BaseModel):
|
||||
id: UUID
|
||||
name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
# True when something can reach/recognize this person (real email, phone,
|
||||
# or a qualifying capture). Gates Mark-as-customer and outbound actions.
|
||||
identified: bool = False
|
||||
is_anonymous: bool = False
|
||||
lead_stage: LeadStage
|
||||
qualified: bool = False
|
||||
source: Optional[Dict[str, Any]] = None
|
||||
created_at: Optional[datetime] = None
|
||||
lead_qualified_at: Optional[datetime] = None
|
||||
meta_data: Optional[Dict[str, Any]] = None
|
||||
# AI-written qualification summary from the conversation.
|
||||
summary: Optional[str] = None
|
||||
# Contact/detail fields captured across submissions.
|
||||
captured_attributes: Dict[str, Any] = {}
|
||||
timeline: List[TimelineEntry] = []
|
||||
conversations: List[PersonConversation] = []
|
||||
|
||||
|
||||
class PersonUpdateRequest(BaseModel):
|
||||
"""Drawer edit: both fields optional; phone "" clears the number."""
|
||||
full_name: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
class PermissionResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class RoleBase(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
is_default: Optional[bool] = False
|
||||
|
||||
class RoleCreate(RoleBase):
|
||||
permissions: List["PermissionResponse"]
|
||||
|
||||
class RoleUpdate(RoleBase):
|
||||
permissions: Optional[List["PermissionResponse"]] = None
|
||||
|
||||
class SimpleRoleResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class RoleResponse(RoleBase):
|
||||
id: int
|
||||
organization_id: Optional[UUID] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
permissions: Optional[List["PermissionResponse"]] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
|
||||
RoleResponse.update_forward_refs()
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from app.models.schemas.shopify.shopify_shop import (
|
||||
ShopifyShopBase,
|
||||
ShopifyShopCreate,
|
||||
ShopifyShopUpdate,
|
||||
ShopifyShopInDB,
|
||||
ShopifyShop,
|
||||
)
|
||||
from .agent_shopify_config import (
|
||||
AgentShopifyConfigBase,
|
||||
AgentShopifyConfigCreate,
|
||||
AgentShopifyConfigUpdate,
|
||||
AgentShopifyConfig
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class AgentShopifyConfigBase(BaseModel):
|
||||
"""Base Shopify agent configuration schema."""
|
||||
enabled: bool
|
||||
shop_id: Optional[str] = None
|
||||
|
||||
|
||||
class AgentShopifyConfigCreate(AgentShopifyConfigBase):
|
||||
"""Schema for creating a new Shopify agent configuration."""
|
||||
agent_id: str
|
||||
|
||||
|
||||
class AgentShopifyConfigUpdate(AgentShopifyConfigBase):
|
||||
"""Schema for updating a Shopify agent configuration."""
|
||||
pass
|
||||
|
||||
|
||||
class ShopifyShopInfo(BaseModel):
|
||||
"""Basic info about a Shopify shop."""
|
||||
id: str
|
||||
shop_domain: str
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class AgentShopifyConfigInDB(AgentShopifyConfigBase):
|
||||
"""Schema for Shopify agent configuration stored in the database."""
|
||||
id: str
|
||||
agent_id: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class AgentShopifyConfig(AgentShopifyConfigInDB):
|
||||
"""Schema for API response."""
|
||||
shop: Optional[ShopifyShopInfo] = None
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
class ShopifyShopBase(BaseModel):
|
||||
"""Base schema for ShopifyShop"""
|
||||
shop_domain: str = Field(..., description="Shopify shop domain")
|
||||
organization_id: Optional[str] = Field(None, description="ID of the organization this shop belongs to")
|
||||
|
||||
class ShopifyShopCreate(ShopifyShopBase):
|
||||
"""Schema for creating a shopify shop"""
|
||||
access_token: Optional[str] = Field(None, description="Shopify access token")
|
||||
scope: Optional[str] = Field(None, description="Shopify OAuth scopes")
|
||||
is_installed: bool = Field(False, description="Whether the app is installed on this shop")
|
||||
oauth_state: Optional[str] = Field(None, description="CSRF protection state parameter")
|
||||
oauth_state_expiry: Optional[datetime] = Field(None, description="CSRF protection state parameter expiry")
|
||||
|
||||
class ShopifyShopUpdate(BaseModel):
|
||||
"""Schema for updating a shopify shop"""
|
||||
access_token: Optional[str] = None
|
||||
scope: Optional[str] = None
|
||||
is_installed: Optional[bool] = None
|
||||
organization_id: Optional[str] = None
|
||||
oauth_state: Optional[str] = None
|
||||
oauth_state_expiry: Optional[datetime] = None
|
||||
|
||||
class ShopifyShopInDB(ShopifyShopBase):
|
||||
"""Schema for shopify shop from database"""
|
||||
id: str
|
||||
scope: Optional[str] = None
|
||||
is_installed: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Add JSON encoders to convert UUID to string
|
||||
json_encoders = {
|
||||
UUID: str # Convert UUID to string
|
||||
}
|
||||
|
||||
class ShopifyShop(ShopifyShopInDB):
|
||||
"""Schema for shopify shop response"""
|
||||
pass
|
||||
@@ -0,0 +1,390 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
|
||||
|
||||
# Reuse the ORM enums so the API contract can never drift from the DB.
|
||||
from app.models.investigation import InvestigationRunStatus, InvestigationRunType
|
||||
from app.models.ticket import ResolutionOutcome, TicketPriority, TicketSource, TicketStatus
|
||||
from app.models.ticket_activity import TicketActivityType, TicketActorType
|
||||
from app.models.schemas.pagination import Pagination
|
||||
|
||||
MAX_TITLE_LENGTH = 500
|
||||
MAX_DESCRIPTION_LENGTH = 20000
|
||||
MAX_COMMENT_LENGTH = 10000
|
||||
MAX_TAGS = 5
|
||||
|
||||
|
||||
class TicketCreate(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=MAX_TITLE_LENGTH)
|
||||
description: Optional[str] = Field(default=None, max_length=MAX_DESCRIPTION_LENGTH)
|
||||
priority: TicketPriority = TicketPriority.MEDIUM
|
||||
severity: Optional[int] = Field(default=None, ge=1, le=3)
|
||||
tags: Optional[List[str]] = Field(default=None, max_length=MAX_TAGS)
|
||||
customer_id: Optional[UUID] = None
|
||||
# Manual tickets: resolve/create the customer by email so direct-email
|
||||
# notifications have somewhere to go. Ignored when customer_id is set.
|
||||
customer_email: Optional[EmailStr] = None
|
||||
customer_name: Optional[str] = Field(default=None, max_length=200)
|
||||
session_id: Optional[UUID] = None
|
||||
assignee_user_id: Optional[UUID] = None
|
||||
group_id: Optional[UUID] = None
|
||||
|
||||
@field_validator("title")
|
||||
@classmethod
|
||||
def _strip_title(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("must not be blank")
|
||||
return v
|
||||
|
||||
|
||||
class TicketUpdate(BaseModel):
|
||||
"""Partial update — omitted fields keep their current values (apply with
|
||||
model_dump(exclude_unset=True))."""
|
||||
title: Optional[str] = Field(default=None, min_length=1, max_length=MAX_TITLE_LENGTH)
|
||||
description: Optional[str] = Field(default=None, max_length=MAX_DESCRIPTION_LENGTH)
|
||||
# Set/replace the ticket's customer by email (find-or-create in the org).
|
||||
customer_email: Optional[EmailStr] = None
|
||||
customer_name: Optional[str] = Field(default=None, max_length=200)
|
||||
status: Optional[TicketStatus] = None
|
||||
priority: Optional[TicketPriority] = None
|
||||
severity: Optional[int] = Field(default=None, ge=1, le=3)
|
||||
tags: Optional[List[str]] = Field(default=None, max_length=MAX_TAGS)
|
||||
assignee_user_id: Optional[UUID] = None
|
||||
group_id: Optional[UUID] = None
|
||||
resolution_outcome: Optional[ResolutionOutcome] = None
|
||||
resolution_summary: Optional[str] = Field(default=None, max_length=MAX_DESCRIPTION_LENGTH)
|
||||
customer_resolution_message: Optional[str] = Field(default=None, max_length=MAX_DESCRIPTION_LENGTH)
|
||||
|
||||
|
||||
class TicketCommentCreate(BaseModel):
|
||||
body: str = Field(min_length=1, max_length=MAX_COMMENT_LENGTH)
|
||||
is_internal: bool = True
|
||||
|
||||
@field_validator("body")
|
||||
@classmethod
|
||||
def _strip_body(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("must not be blank")
|
||||
return v
|
||||
|
||||
|
||||
class TicketReopen(BaseModel):
|
||||
reason: Optional[str] = Field(default=None, max_length=MAX_COMMENT_LENGTH)
|
||||
|
||||
|
||||
class TicketResolve(BaseModel):
|
||||
outcome: ResolutionOutcome = ResolutionOutcome.FIXED
|
||||
resolution_summary: Optional[str] = Field(default=None, max_length=MAX_DESCRIPTION_LENGTH)
|
||||
customer_message: Optional[str] = Field(default=None, max_length=MAX_DESCRIPTION_LENGTH)
|
||||
|
||||
|
||||
class TicketUserOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
full_name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
|
||||
|
||||
class TicketCustomerOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
email: Optional[str] = None
|
||||
full_name: Optional[str] = None
|
||||
|
||||
|
||||
class TicketActivityOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
activity_type: TicketActivityType
|
||||
actor_type: TicketActorType
|
||||
actor_user_id: Optional[UUID] = None
|
||||
actor_name: Optional[str] = None
|
||||
body: Optional[str] = None
|
||||
is_internal: bool = True
|
||||
activity_metadata: Optional[dict] = None
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class InvestigationRunOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
run_type: InvestigationRunType
|
||||
status: InvestigationRunStatus
|
||||
trigger: str
|
||||
error: Optional[str] = None
|
||||
tool_calls_used: int = 0
|
||||
max_tool_calls: int = 25
|
||||
llm_calls: int = 0
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
metered: bool = False
|
||||
model_name: Optional[str] = None
|
||||
# {"configured": N, "loaded": M, "failed": [{"name", "error"}]}
|
||||
connector_status: Optional[dict] = None
|
||||
started_at: Optional[datetime] = None
|
||||
finished_at: Optional[datetime] = None
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class HypothesisOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
idx: int
|
||||
title: str
|
||||
rationale: Optional[str] = None
|
||||
status: str
|
||||
confidence: Optional[float] = None
|
||||
conclusion: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class InvestigationEventOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
hypothesis_id: Optional[UUID] = None
|
||||
seq: int
|
||||
event_type: str
|
||||
label: Optional[str] = None
|
||||
tool_name: Optional[str] = None
|
||||
connector_name: Optional[str] = None
|
||||
tool_input: Optional[str] = None
|
||||
tool_result: Optional[str] = None
|
||||
duration_ms: Optional[int] = None
|
||||
error: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class RCADocumentOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
run_id: Optional[UUID] = None
|
||||
version: int
|
||||
summary: Optional[str] = None
|
||||
impact: Optional[str] = None
|
||||
timeline: Optional[list] = None
|
||||
investigation_log: Optional[str] = None
|
||||
contributing_factors: Optional[list] = None
|
||||
conclusion: Optional[str] = None
|
||||
remediation: Optional[str] = None
|
||||
prevention: Optional[str] = None
|
||||
customer_summary: Optional[str] = None
|
||||
confidence: Optional[float] = None
|
||||
is_partial: bool = False
|
||||
generated_by: str = "ai"
|
||||
reviewed_by_user_id: Optional[UUID] = None
|
||||
reviewed_by_name: Optional[str] = None
|
||||
reviewed_at: Optional[datetime] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class ProposalOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
run_id: Optional[UUID] = None
|
||||
summary: str
|
||||
customer_message: Optional[str] = None
|
||||
confidence: Optional[float] = None
|
||||
status: str
|
||||
decided_by_user_id: Optional[UUID] = None
|
||||
decided_by_name: Optional[str] = None
|
||||
decided_at: Optional[datetime] = None
|
||||
reject_reason: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class ProposalRejectRequest(BaseModel):
|
||||
reason: Optional[str] = Field(default=None, max_length=MAX_COMMENT_LENGTH)
|
||||
# Re-enqueue a refined investigation with the reason as context.
|
||||
reinvestigate: bool = False
|
||||
|
||||
|
||||
class InvestigationDetailResponse(BaseModel):
|
||||
"""Glass-box payload for the ticket detail page: the (latest or requested)
|
||||
investigation run with its hypotheses, evidence events and RCA document."""
|
||||
run: Optional[InvestigationRunOut] = None
|
||||
hypotheses: List[HypothesisOut] = []
|
||||
events: List[InvestigationEventOut] = []
|
||||
rca: Optional[RCADocumentOut] = None
|
||||
# Latest proposal (any status) — drives the approval banner.
|
||||
proposal: Optional[ProposalOut] = None
|
||||
|
||||
|
||||
class RcaUpdate(BaseModel):
|
||||
customer_summary: Optional[str] = Field(default=None, max_length=MAX_DESCRIPTION_LENGTH)
|
||||
# Stamp the caller as reviewer ("generated by AI · reviewed by …").
|
||||
mark_reviewed: bool = False
|
||||
|
||||
|
||||
class InvestigateRequest(BaseModel):
|
||||
run_type: InvestigationRunType = InvestigationRunType.INVESTIGATION
|
||||
# Free-text context for the run, e.g. what to focus on this time.
|
||||
context_note: Optional[str] = Field(default=None, max_length=4000)
|
||||
|
||||
|
||||
class TicketOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
ticket_number: int
|
||||
display_number: str
|
||||
organization_id: UUID
|
||||
customer_id: Optional[UUID] = None
|
||||
title: str
|
||||
original_title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: TicketStatus
|
||||
priority: TicketPriority
|
||||
severity: Optional[int] = None
|
||||
source: TicketSource
|
||||
intent: Optional[str] = None
|
||||
triage_confidence: Optional[float] = None
|
||||
ai_summary: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
assignee_user_id: Optional[UUID] = None
|
||||
group_id: Optional[UUID] = None
|
||||
agent_id: Optional[UUID] = None
|
||||
duplicate_of_ticket_id: Optional[UUID] = None
|
||||
resolution_outcome: Optional[ResolutionOutcome] = None
|
||||
resolution_summary: Optional[str] = None
|
||||
customer_resolution_message: Optional[str] = None
|
||||
first_response_at: Optional[datetime] = None
|
||||
resolved_at: Optional[datetime] = None
|
||||
closed_at: Optional[datetime] = None
|
||||
confirmation_requested_at: Optional[datetime] = None
|
||||
reopened_count: int = 0
|
||||
csat_requested_at: Optional[datetime] = None
|
||||
csat_score: Optional[int] = None
|
||||
csat_responded_at: Optional[datetime] = None
|
||||
external_ref_type: Optional[str] = None
|
||||
external_ref_id: Optional[str] = None
|
||||
external_ref_url: Optional[str] = None
|
||||
created_by_user_id: Optional[UUID] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
# Derived UI fields.
|
||||
ai_state: Optional[str] = None
|
||||
sla_due_at: Optional[datetime] = None
|
||||
assignee: Optional[TicketUserOut] = None
|
||||
customer: Optional[TicketCustomerOut] = None
|
||||
|
||||
|
||||
class TicketListItem(BaseModel):
|
||||
"""Slim row for the list view."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
ticket_number: int
|
||||
display_number: str
|
||||
title: str
|
||||
status: TicketStatus
|
||||
priority: TicketPriority
|
||||
tags: Optional[List[str]] = None
|
||||
assignee_user_id: Optional[UUID] = None
|
||||
assignee_name: Optional[str] = None
|
||||
ai_state: Optional[str] = None
|
||||
sla_due_at: Optional[datetime] = None
|
||||
resolved_at: Optional[datetime] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class TicketListResponse(BaseModel):
|
||||
tickets: List[TicketListItem]
|
||||
pagination: Pagination
|
||||
|
||||
|
||||
class TicketDetailResponse(BaseModel):
|
||||
ticket: TicketOut
|
||||
activities: List[TicketActivityOut]
|
||||
runs: List[InvestigationRunOut]
|
||||
linked_session_ids: List[UUID]
|
||||
possible_duplicates: List[TicketListItem] = []
|
||||
# Whether any outbound path to the customer exists (linked conversation or
|
||||
# customer email) — gates the "send to customer" affordances in the UI.
|
||||
can_notify_customer: bool = False
|
||||
|
||||
|
||||
class TicketStats(BaseModel):
|
||||
open: int
|
||||
awaiting_approval: int
|
||||
sla_breaching: int
|
||||
ai_resolved_pct_7d: Optional[float] = None
|
||||
# CSAT over the trailing csat_window_days, overall and split by resolver.
|
||||
csat_avg: Optional[float] = None
|
||||
csat_ai_avg: Optional[float] = None
|
||||
csat_human_avg: Optional[float] = None
|
||||
csat_responses: int = 0
|
||||
csat_window_days: int = 30
|
||||
|
||||
|
||||
class SlaTarget(BaseModel):
|
||||
first_response_minutes: int = Field(gt=0, le=60 * 24 * 30)
|
||||
resolution_minutes: int = Field(gt=0, le=60 * 24 * 90)
|
||||
|
||||
|
||||
class TicketSettingsOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
autonomy_level: int
|
||||
auto_investigate_on_create: bool
|
||||
min_confidence_to_auto_resolve: float
|
||||
confirmation_timeout_hours: int
|
||||
csat_enabled: bool
|
||||
sla_targets: Optional[dict] = None
|
||||
created_template: Optional[str] = None
|
||||
resolved_template: Optional[str] = None
|
||||
jira_escalation_enabled: bool
|
||||
jira_escalation_priority: Optional[str] = None
|
||||
investigation_mcp_tool_ids: Optional[List[int]] = None
|
||||
alert_webhook_enabled: bool
|
||||
# Only exposed to settings managers; forms the intake URL path.
|
||||
alert_webhook_secret: Optional[str] = None
|
||||
max_tool_calls_per_run: int
|
||||
max_runs_per_ticket: int
|
||||
|
||||
|
||||
class TicketSettingsUpdate(BaseModel):
|
||||
autonomy_level: Optional[int] = Field(default=None, ge=1, le=3)
|
||||
auto_investigate_on_create: Optional[bool] = None
|
||||
min_confidence_to_auto_resolve: Optional[float] = Field(default=None, ge=0.0, le=1.0)
|
||||
confirmation_timeout_hours: Optional[int] = Field(default=None, ge=1, le=24 * 30)
|
||||
csat_enabled: Optional[bool] = None
|
||||
sla_targets: Optional[dict] = None
|
||||
created_template: Optional[str] = Field(default=None, max_length=2000)
|
||||
resolved_template: Optional[str] = Field(default=None, max_length=2000)
|
||||
jira_escalation_enabled: Optional[bool] = None
|
||||
jira_escalation_priority: Optional[TicketPriority] = None
|
||||
investigation_mcp_tool_ids: Optional[List[int]] = None
|
||||
alert_webhook_enabled: Optional[bool] = None
|
||||
max_tool_calls_per_run: Optional[int] = Field(default=None, ge=1, le=100)
|
||||
max_runs_per_ticket: Optional[int] = Field(default=None, ge=1, le=10)
|
||||
|
||||
@field_validator("sla_targets")
|
||||
@classmethod
|
||||
def _validate_sla(cls, v: Optional[dict]) -> Optional[dict]:
|
||||
if v is None:
|
||||
return v
|
||||
for priority, target in v.items():
|
||||
if priority not in [p.value for p in TicketPriority]:
|
||||
raise ValueError(f"unknown priority '{priority}'")
|
||||
SlaTarget.model_validate(target)
|
||||
return v
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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 re
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
ENGINES = ("postgresql", "mysql")
|
||||
ROW_SCOPE_KEYS = ("email", "phone")
|
||||
|
||||
|
||||
class DBConnectorColumnOut(BaseModel):
|
||||
name: str
|
||||
type: str
|
||||
|
||||
|
||||
class DBConnectorTableOut(BaseModel):
|
||||
schema_name: str = Field(alias="schema")
|
||||
table: str
|
||||
columns: List[DBConnectorColumnOut]
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class SSHTunnelFields(BaseModel):
|
||||
"""Optional SSH bastion/jump-host tunnel. Auth is by private key
|
||||
(preferred) or password; secrets are write-only."""
|
||||
ssh_enabled: bool = False
|
||||
ssh_host: Optional[str] = Field(default=None, max_length=500)
|
||||
ssh_port: int = Field(default=22, ge=1, le=65535)
|
||||
ssh_username: Optional[str] = Field(default=None, max_length=200)
|
||||
ssh_password: Optional[str] = Field(default=None, max_length=1000)
|
||||
ssh_private_key: Optional[str] = Field(default=None, max_length=20000)
|
||||
ssh_private_key_passphrase: Optional[str] = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class DBConnectorDiscoverRequest(SSHTunnelFields):
|
||||
"""Pre-save connection test: credentials go over TLS, are used once for
|
||||
discovery, and are never persisted by this endpoint."""
|
||||
engine: str = Field(pattern=f"^({'|'.join(ENGINES)})$")
|
||||
host: str = Field(min_length=1, max_length=500)
|
||||
port: int = Field(ge=1, le=65535)
|
||||
database: str = Field(min_length=1, max_length=200)
|
||||
username: str = Field(min_length=1, max_length=200)
|
||||
password: str = Field(min_length=1, max_length=500)
|
||||
|
||||
|
||||
class DBConnectorDiscoverResponse(BaseModel):
|
||||
ok: bool
|
||||
error: Optional[str] = None
|
||||
tables: List[DBConnectorTableOut] = []
|
||||
|
||||
|
||||
_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_$]*$")
|
||||
_TABLE_REF = re.compile(r"^[A-Za-z_][A-Za-z0-9_$]*(\.[A-Za-z_][A-Za-z0-9_$]*)?$")
|
||||
|
||||
ROW_SCOPE_DESCRIPTION = (
|
||||
'Tables restricted to the ticket customer\'s own rows, as '
|
||||
'{"schema.table": "customer_column"}. Listed tables are rewritten into a '
|
||||
"pre-filtered subquery; tables left out are readable in full."
|
||||
)
|
||||
|
||||
|
||||
def validate_row_scope(value: Optional[Dict[str, str]]) -> Optional[Dict[str, str]]:
|
||||
"""Both halves land in generated SQL as identifiers. sqlglot quotes them,
|
||||
but a bad value should be refused at the edge rather than quoted and
|
||||
executed — a typo'd column silently scopes nothing useful."""
|
||||
if value is None:
|
||||
return None
|
||||
if len(value) > 500:
|
||||
raise ValueError("Too many row-scope entries")
|
||||
cleaned: Dict[str, str] = {}
|
||||
for table, column in value.items():
|
||||
table = (table or "").strip()
|
||||
column = (column or "").strip()
|
||||
if not table or not column:
|
||||
continue
|
||||
if not _TABLE_REF.match(table):
|
||||
raise ValueError(f"Invalid table reference in row scope: {table!r}")
|
||||
if not _IDENTIFIER.match(column):
|
||||
raise ValueError(f"Invalid column name in row scope: {column!r}")
|
||||
cleaned[table.lower()] = column
|
||||
return cleaned
|
||||
|
||||
|
||||
class DBConnectorCreate(SSHTunnelFields):
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
engine: str = Field(pattern=f"^({'|'.join(ENGINES)})$")
|
||||
host: str = Field(min_length=1, max_length=500)
|
||||
port: int = Field(ge=1, le=65535)
|
||||
database: str = Field(min_length=1, max_length=200)
|
||||
username: str = Field(min_length=1, max_length=200)
|
||||
password: str = Field(min_length=1, max_length=500)
|
||||
enabled: bool = True
|
||||
# Nothing is queryable unless explicitly listed ("schema.table").
|
||||
allowed_tables: List[str] = Field(default_factory=list, max_length=500)
|
||||
masked_columns: List[str] = Field(default_factory=list, max_length=500)
|
||||
row_scope: Dict[str, str] = Field(
|
||||
default_factory=dict, description=ROW_SCOPE_DESCRIPTION
|
||||
)
|
||||
row_scope_key: str = Field(
|
||||
default="email", pattern=f"^({'|'.join(ROW_SCOPE_KEYS)})$"
|
||||
)
|
||||
max_rows: int = Field(default=100, ge=1, le=1000)
|
||||
statement_timeout_ms: int = Field(default=5000, ge=100, le=30000)
|
||||
|
||||
@field_validator("row_scope")
|
||||
@classmethod
|
||||
def _check_row_scope(cls, value):
|
||||
return validate_row_scope(value) or {}
|
||||
|
||||
|
||||
class DBConnectorUpdate(BaseModel):
|
||||
name: Optional[str] = Field(default=None, min_length=1, max_length=200)
|
||||
host: Optional[str] = Field(default=None, min_length=1, max_length=500)
|
||||
port: Optional[int] = Field(default=None, ge=1, le=65535)
|
||||
database: Optional[str] = Field(default=None, min_length=1, max_length=200)
|
||||
username: Optional[str] = Field(default=None, min_length=1, max_length=200)
|
||||
# Omit to keep the stored password.
|
||||
password: Optional[str] = Field(default=None, min_length=1, max_length=500)
|
||||
enabled: Optional[bool] = None
|
||||
allowed_tables: Optional[List[str]] = Field(default=None, max_length=500)
|
||||
masked_columns: Optional[List[str]] = Field(default=None, max_length=500)
|
||||
row_scope: Optional[Dict[str, str]] = Field(
|
||||
default=None, description=ROW_SCOPE_DESCRIPTION
|
||||
)
|
||||
row_scope_key: Optional[str] = Field(
|
||||
default=None, pattern=f"^({'|'.join(ROW_SCOPE_KEYS)})$"
|
||||
)
|
||||
max_rows: Optional[int] = Field(default=None, ge=1, le=1000)
|
||||
statement_timeout_ms: Optional[int] = Field(default=None, ge=100, le=30000)
|
||||
# SSH tunnel — omit a secret to keep the stored one.
|
||||
ssh_enabled: Optional[bool] = None
|
||||
ssh_host: Optional[str] = Field(default=None, max_length=500)
|
||||
ssh_port: Optional[int] = Field(default=None, ge=1, le=65535)
|
||||
ssh_username: Optional[str] = Field(default=None, max_length=200)
|
||||
ssh_password: Optional[str] = Field(default=None, max_length=1000)
|
||||
ssh_private_key: Optional[str] = Field(default=None, max_length=20000)
|
||||
ssh_private_key_passphrase: Optional[str] = Field(default=None, max_length=1000)
|
||||
|
||||
@field_validator("row_scope")
|
||||
@classmethod
|
||||
def _check_row_scope(cls, value):
|
||||
return validate_row_scope(value)
|
||||
|
||||
|
||||
class DBConnectorOut(BaseModel):
|
||||
"""Never includes the password in any form."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
name: str
|
||||
engine: str
|
||||
host: str
|
||||
port: int
|
||||
database: str
|
||||
username: str
|
||||
enabled: bool
|
||||
allowed_tables: Optional[List[str]] = None
|
||||
masked_columns: Optional[List[str]] = None
|
||||
row_scope: Optional[Dict[str, str]] = None
|
||||
row_scope_key: str = "email"
|
||||
max_rows: int
|
||||
statement_timeout_ms: int
|
||||
# SSH tunnel — connection details only, never the key/password.
|
||||
ssh_enabled: bool = False
|
||||
ssh_host: Optional[str] = None
|
||||
ssh_port: int = 22
|
||||
ssh_username: Optional[str] = None
|
||||
last_test_at: Optional[datetime] = None
|
||||
last_test_ok: Optional[bool] = None
|
||||
created_at: Optional[datetime] = None
|
||||
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, EmailStr, field_serializer, field_validator
|
||||
from typing import Optional, List
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.s3 import sign_s3_url
|
||||
from app.core.security import validate_password_strength
|
||||
|
||||
from app.models.schemas.role import RoleResponse
|
||||
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
email: EmailStr
|
||||
full_name: str
|
||||
is_active: bool = True
|
||||
profile_pic: Optional[str] = None
|
||||
is_online: bool = False
|
||||
last_seen: Optional[datetime] = None
|
||||
|
||||
|
||||
class ChatScopeFields(BaseModel):
|
||||
"""The two chat-scope toggles on the user form.
|
||||
|
||||
Both default to None, meaning "whatever the chosen role already grants".
|
||||
A client that predates these fields keeps its old behaviour; the form
|
||||
sends explicit values because it renders the role's current scope.
|
||||
"""
|
||||
see_all_ai_chats: Optional[bool] = None
|
||||
see_all_org_chats: Optional[bool] = None
|
||||
|
||||
|
||||
class UserCreate(UserBase, ChatScopeFields):
|
||||
password: str
|
||||
role_id: int
|
||||
|
||||
@field_validator('password')
|
||||
@classmethod
|
||||
def _check_strength(cls, value: str) -> str:
|
||||
# The password an admin picks for a new teammate clears the same bar as
|
||||
# one set by a reset — otherwise the policy is one invite away from
|
||||
# being bypassed.
|
||||
return validate_password_strength(value)
|
||||
|
||||
|
||||
class UserUpdate(ChatScopeFields):
|
||||
email: Optional[EmailStr] = None
|
||||
full_name: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
current_password: Optional[str] = None
|
||||
role_id: Optional[int] = None
|
||||
profile_pic: Optional[str] = None
|
||||
is_online: Optional[bool] = None
|
||||
|
||||
class TeammateResponse(BaseModel):
|
||||
"""A colleague as the inbox needs them: enough to pick one and render them.
|
||||
|
||||
No role, no permissions, no timestamps — an agent listing who they can hand
|
||||
a chat to has no business reading the org's permission matrix, which is
|
||||
what UserResponse would give them.
|
||||
"""
|
||||
id: UUID
|
||||
full_name: Optional[str] = None
|
||||
email: EmailStr
|
||||
profile_pic: Optional[str] = None
|
||||
is_online: Optional[bool] = False
|
||||
|
||||
@field_serializer('profile_pic')
|
||||
def _sign_profile_pic(self, v: Optional[str]) -> Optional[str]:
|
||||
return sign_s3_url(v) if v else v
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AdminPasswordReset(BaseModel):
|
||||
"""An admin setting a new password for someone else in their organization.
|
||||
|
||||
No current_password: the admin does not know it — that is the point of a
|
||||
reset. The policy check lives here because nobody is around to see the
|
||||
form's strength meter when the call arrives from a script.
|
||||
"""
|
||||
new_password: str
|
||||
|
||||
@field_validator('new_password')
|
||||
@classmethod
|
||||
def _check_strength(cls, value: str) -> str:
|
||||
return validate_password_strength(value)
|
||||
|
||||
|
||||
class UserStatusUpdate(BaseModel):
|
||||
is_online: bool
|
||||
|
||||
class UserGroupResponse(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
class UserResponse(UserBase):
|
||||
id: UUID
|
||||
organization_id: Optional[UUID] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
is_online: Optional[bool] = None
|
||||
last_seen: Optional[datetime] = None
|
||||
profile_pic: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
groups: Optional[List[UserGroupResponse]] = None
|
||||
role: Optional[RoleResponse] = None
|
||||
|
||||
@field_serializer('profile_pic')
|
||||
def _sign_profile_pic(self, v: Optional[str]) -> Optional[str]:
|
||||
"""Sign on the way out, every response — never stored signed."""
|
||||
return sign_s3_url(v) if v else v
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str
|
||||
user: UserResponse
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from uuid import UUID
|
||||
from app.models.schemas.user import UserResponse
|
||||
|
||||
class UserGroupBase(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
class UserGroupCreate(UserGroupBase):
|
||||
pass
|
||||
|
||||
class UserGroupUpdate(UserGroupBase):
|
||||
name: Optional[str] = None
|
||||
|
||||
class UserGroupResponse(UserGroupBase):
|
||||
id: UUID
|
||||
organization_id: UUID
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class UserGroupWithUsers(UserGroupResponse):
|
||||
users: List[UserResponse]
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, field_serializer
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from app.core.s3 import sign_s3_url
|
||||
|
||||
|
||||
class WidgetBase(BaseModel):
|
||||
name: str
|
||||
|
||||
# Optional agent ID for widget configuration
|
||||
agent_id: Optional[UUID] = None
|
||||
|
||||
|
||||
class WidgetCreate(WidgetBase):
|
||||
pass
|
||||
|
||||
|
||||
class AgentCustomizationResponse(BaseModel):
|
||||
chat_background_color: Optional[str] = None
|
||||
chat_text_color: Optional[str] = None
|
||||
chat_bubble_color: Optional[str] = None
|
||||
accent_color: Optional[str] = None
|
||||
font_family: Optional[str] = None
|
||||
photo_url: Optional[str] = None
|
||||
chat_style: Optional[str] = "CHATBOT"
|
||||
widget_position: Optional[str] = "FLOATING"
|
||||
welcome_title: Optional[str] = None
|
||||
welcome_subtitle: Optional[str] = None
|
||||
welcome_message: Optional[str] = None
|
||||
chat_initiation_messages: Optional[List[str]] = None
|
||||
quick_actions: Optional[List[str]] = None
|
||||
show_citations: Optional[bool] = None
|
||||
collect_email: Optional[bool] = None
|
||||
show_ai_disclaimer: Optional[bool] = None
|
||||
allow_new_chat: Optional[bool] = None
|
||||
# Free-form extras (avatar_style, widget_display placement defaults, ...)
|
||||
customization_metadata: Optional[dict] = None
|
||||
|
||||
@field_serializer('photo_url')
|
||||
def _sign_photo_url(self, v: Optional[str]) -> Optional[str]:
|
||||
"""Sign on the way out, every response — never stored signed."""
|
||||
return sign_s3_url(v) if v else v
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AgentResponse(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
display_name: Optional[str] = None
|
||||
customization: Optional[AgentCustomizationResponse] = None
|
||||
workflow: bool = False
|
||||
|
||||
|
||||
class HumanAgentResponse(BaseModel):
|
||||
human_agent_name: Optional[str] = None
|
||||
human_agent_profile_pic: Optional[str] = None
|
||||
|
||||
@field_serializer('human_agent_profile_pic')
|
||||
def _sign_profile_pic(self, v: Optional[str]) -> Optional[str]:
|
||||
"""Sign on the way out, every response — never stored signed."""
|
||||
return sign_s3_url(v) if v else v
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class WidgetResponse(BaseModel):
|
||||
id: str
|
||||
organization_id: UUID
|
||||
agent: AgentResponse
|
||||
human_agent: Optional[HumanAgentResponse] = None
|
||||
# Include agent ID in response if set
|
||||
agent_id: Optional[UUID] = None
|
||||
token: Optional[str] = None
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class WidgetAppBase(BaseModel):
|
||||
"""Base schema with common fields"""
|
||||
name: str = Field(..., min_length=1, max_length=100, description="App name (e.g., 'Production Widget')")
|
||||
description: Optional[str] = Field(None, max_length=500, description="Optional description")
|
||||
|
||||
|
||||
class WidgetAppCreate(WidgetAppBase):
|
||||
"""Schema for creating a new widget app"""
|
||||
pass
|
||||
|
||||
|
||||
class WidgetAppUpdate(BaseModel):
|
||||
"""Schema for updating widget app"""
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class WidgetAppResponse(WidgetAppBase):
|
||||
"""Schema for widget app response (without API key)"""
|
||||
id: UUID
|
||||
organization_id: UUID
|
||||
created_by: UUID
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class WidgetAppWithKeyResponse(WidgetAppResponse):
|
||||
"""
|
||||
Schema for newly created app (includes API key ONCE).
|
||||
|
||||
IMPORTANT: Only use this for create/regenerate responses.
|
||||
Never log or persist the api_key field.
|
||||
"""
|
||||
api_key: str = Field(..., description="API key - SAVE THIS! It won't be shown again.")
|
||||
|
||||
|
||||
class WidgetAppListResponse(BaseModel):
|
||||
"""Schema for list of widget apps"""
|
||||
total: int
|
||||
apps: list[WidgetAppResponse]
|
||||
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from typing import Optional, List, Dict, Any, Union, Literal
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
from app.models.workflow import WorkflowStatus
|
||||
from app.models.workflow_node import NodeType, ActionType
|
||||
|
||||
|
||||
# ==============================
|
||||
# Workflow schemas
|
||||
# ==============================
|
||||
|
||||
class WorkflowBase(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
status: Optional[WorkflowStatus] = WorkflowStatus.DRAFT
|
||||
is_template: bool = False
|
||||
default_language: str = "en"
|
||||
canvas_data: Dict[str, Any] = Field(default_factory=dict)
|
||||
settings: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WorkflowCreate(WorkflowBase):
|
||||
agent_id: UUID
|
||||
|
||||
|
||||
# ==============================
|
||||
# Operation schemas for batch updates
|
||||
# ==============================
|
||||
|
||||
class WorkflowNodeOperation(BaseModel):
|
||||
operation: Literal["create", "update", "delete"]
|
||||
id: Optional[UUID] = None # Required for update/delete, optional for create
|
||||
node_type: Optional[NodeType] = None
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
position_x: Optional[float] = None
|
||||
position_y: Optional[float] = None
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Landing Page node fields
|
||||
landing_page_heading: Optional[str] = None
|
||||
landing_page_content: Optional[str] = None
|
||||
|
||||
|
||||
class WorkflowConnectionOperation(BaseModel):
|
||||
operation: Literal["create", "update", "delete"]
|
||||
id: Optional[UUID] = None # Required for update/delete, optional for create
|
||||
source_node_id: Optional[UUID] = None
|
||||
target_node_id: Optional[UUID] = None
|
||||
label: Optional[str] = None
|
||||
condition: Optional[str] = None
|
||||
priority: Optional[int] = None
|
||||
connection_metadata: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class WorkflowUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: Optional[WorkflowStatus] = None
|
||||
is_template: Optional[bool] = None
|
||||
default_language: Optional[str] = None
|
||||
canvas_data: Optional[Dict[str, Any]] = None
|
||||
settings: Optional[Dict[str, Any]] = None
|
||||
agent_id: Optional[UUID] = None
|
||||
nodes: Optional[List[WorkflowNodeOperation]] = None
|
||||
connections: Optional[List[WorkflowConnectionOperation]] = None
|
||||
|
||||
|
||||
class WorkflowResponse(WorkflowBase):
|
||||
id: UUID
|
||||
organization_id: UUID
|
||||
agent_id: UUID
|
||||
created_by: UUID
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
# ==============================
|
||||
# WorkflowNode schemas
|
||||
# ==============================
|
||||
|
||||
class WorkflowNodeBase(BaseModel):
|
||||
workflow_id: UUID
|
||||
node_type: NodeType
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
position_x: float = 0
|
||||
position_y: float = 0
|
||||
config: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
# Message node fields
|
||||
message_text: Optional[str] = None
|
||||
|
||||
# Condition node fields
|
||||
condition_expression: Optional[str] = None
|
||||
|
||||
# Action node fields
|
||||
action_type: Optional[ActionType] = None
|
||||
action_config: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Transfer node fields
|
||||
transfer_rules: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Wait node fields
|
||||
wait_duration: Optional[int] = None # Duration in seconds
|
||||
wait_until_condition: Optional[str] = None
|
||||
|
||||
# Landing Page node fields
|
||||
landing_page_heading: Optional[str] = None
|
||||
landing_page_content: Optional[str] = None
|
||||
|
||||
|
||||
class WorkflowNodeCreate(WorkflowNodeBase):
|
||||
pass
|
||||
|
||||
|
||||
class WorkflowNodeUpdate(BaseModel):
|
||||
node_type: Optional[NodeType] = None
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
position_x: Optional[float] = None
|
||||
position_y: Optional[float] = None
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Landing Page node fields
|
||||
landing_page_heading: Optional[str] = None
|
||||
landing_page_content: Optional[str] = None
|
||||
|
||||
|
||||
class WorkflowNodeResponse(WorkflowNodeBase):
|
||||
id: UUID
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
# ==============================
|
||||
# WorkflowConnection schemas
|
||||
# ==============================
|
||||
|
||||
class WorkflowConnectionBase(BaseModel):
|
||||
workflow_id: UUID
|
||||
source_node_id: UUID
|
||||
target_node_id: UUID
|
||||
label: Optional[str] = None
|
||||
condition: Optional[str] = None
|
||||
priority: int = 0
|
||||
connection_metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WorkflowConnectionCreate(WorkflowConnectionBase):
|
||||
pass
|
||||
|
||||
|
||||
class WorkflowConnectionUpdate(BaseModel):
|
||||
label: Optional[str] = None
|
||||
condition: Optional[str] = None
|
||||
priority: Optional[int] = None
|
||||
connection_metadata: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class WorkflowConnectionResponse(WorkflowConnectionBase):
|
||||
id: UUID
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
# ==============================
|
||||
# Additional composite schemas
|
||||
# ==============================
|
||||
|
||||
class WorkflowDetailResponse(WorkflowResponse):
|
||||
nodes: List[WorkflowNodeResponse]
|
||||
connections: List[WorkflowConnectionResponse]
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, String, DateTime, ForeignKey, Enum as SQLEnum, JSON, Float
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.core.encryption import EncryptedText
|
||||
from app.database import Base
|
||||
import enum
|
||||
from sqlalchemy.sql import func
|
||||
from app.models.user import UserGroup
|
||||
|
||||
|
||||
class SessionStatus(str, enum.Enum):
|
||||
OPEN = "open"
|
||||
TRANSFERRED = "transferred"
|
||||
CLOSED = "closed"
|
||||
|
||||
|
||||
class EndChatReasonType(str, enum.Enum):
|
||||
ISSUE_RESOLVED = "ISSUE_RESOLVED"
|
||||
CUSTOMER_REQUEST = "CUSTOMER_REQUEST"
|
||||
CONFIRMATION_RECEIVED = "CONFIRMATION_RECEIVED"
|
||||
FAREWELL = "FAREWELL"
|
||||
THANK_YOU = "THANK_YOU"
|
||||
NATURAL_CONCLUSION = "NATURAL_CONCLUSION"
|
||||
TASK_COMPLETED = "TASK_COMPLETED"
|
||||
|
||||
|
||||
# A conversation the visitor can still talk in.
|
||||
ACTIVE_STATUSES = (SessionStatus.OPEN, SessionStatus.TRANSFERRED)
|
||||
|
||||
|
||||
class SessionToAgent(Base):
|
||||
__tablename__ = "session_to_agents"
|
||||
|
||||
session_id = Column(UUID(as_uuid=True), primary_key=True)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id", ondelete="SET NULL"), nullable=True)
|
||||
customer_id = Column(UUID(as_uuid=True), ForeignKey("customers.id", ondelete="SET NULL"), nullable=False, index=True)
|
||||
group_id = Column(UUID(as_uuid=True), ForeignKey("groups.id", ondelete="SET NULL"), nullable=True)
|
||||
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False)
|
||||
status = Column(SQLEnum(SessionStatus), nullable=False, default=SessionStatus.OPEN)
|
||||
# Messaging channel the session belongs to ('web' = widget, 'telegram',
|
||||
# 'whatsapp', ...). Routing key for outbound delivery of replies.
|
||||
channel = Column(String, nullable=False, server_default='web', index=True)
|
||||
# Which widget conversation token may resume this session (see
|
||||
# conversation_token.conversation_scope). NULL: only a token the embedding app
|
||||
# vouched for - and every non-widget channel, which never resumes by token.
|
||||
conversation_key = Column(String(64), nullable=True)
|
||||
assigned_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
transfer_reason = Column(String, nullable=True)
|
||||
# The free-text fields below are AI-written summaries of the conversation, so
|
||||
# they are encrypted at rest alongside the messages themselves.
|
||||
transfer_description = Column(EncryptedText, nullable=True)
|
||||
end_chat_reason = Column(SQLEnum(EndChatReasonType), nullable=True)
|
||||
end_chat_description = Column(EncryptedText, nullable=True)
|
||||
|
||||
# Ticket-related fields
|
||||
ticket_id = Column(String, nullable=True)
|
||||
ticket_status = Column(String, nullable=True)
|
||||
ticket_summary = Column(EncryptedText, nullable=True)
|
||||
ticket_description = Column(EncryptedText, nullable=True)
|
||||
integration_type = Column(String, nullable=True)
|
||||
ticket_priority = Column(String, nullable=True)
|
||||
|
||||
# Sentiment fields
|
||||
sentiment_label = Column(String, nullable=True) # Overall session sentiment: 'positive', 'neutral', 'negative'
|
||||
sentiment_score = Column(Float, nullable=True) # Overall average sentiment score: -1.0 to 1.0
|
||||
|
||||
# Workflow-related fields
|
||||
workflow_id = Column(UUID(as_uuid=True), ForeignKey("workflows.id", ondelete="SET NULL"), nullable=True)
|
||||
current_node_id = Column(UUID(as_uuid=True), ForeignKey("workflow_nodes.id", ondelete="SET NULL"), nullable=True)
|
||||
|
||||
workflow_state = Column(JSON, default={}) # Store workflow execution state
|
||||
workflow_history = Column(JSON, default=[]) # Store form submissions and workflow interactions
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="session_assignments")
|
||||
agent = relationship("Agent", back_populates="session_assignments")
|
||||
customer = relationship("Customer", back_populates="session_assignments")
|
||||
group = relationship("UserGroup", back_populates="session_assignments")
|
||||
ratings = relationship("Rating", back_populates="session_assignments")
|
||||
workflow = relationship("Workflow", back_populates="sessions")
|
||||
current_node = relationship("WorkflowNode")
|
||||
|
||||
|
||||
# Add back-reference in UserGroup model if not already present
|
||||
if not hasattr(UserGroup, 'session_assignments'):
|
||||
UserGroup.session_assignments = relationship(
|
||||
"SessionToAgent",
|
||||
back_populates="group",
|
||||
uselist=True
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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 models
|
||||
from .shopify_shop import ShopifyShop
|
||||
from .agent_shopify_config import AgentShopifyConfig
|
||||
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AgentShopifyConfig(Base):
|
||||
"""Model for storing agent-to-Shopify configuration."""
|
||||
__tablename__ = "agent_shopify_configs"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
agent_id = Column(String, nullable=False, index=True)
|
||||
|
||||
# Link to ShopifyShop
|
||||
shop_id = Column(String, ForeignKey("shopify_shops.id"), nullable=True)
|
||||
shop = relationship("ShopifyShop", backref="agent_configs")
|
||||
|
||||
enabled = Column(Boolean, default=False, nullable=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, Integer, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
import uuid
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ShopifyShop(Base):
|
||||
"""Shopify shop model for storing shop information and credentials"""
|
||||
__tablename__ = "shopify_shops"
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
shop_domain = Column(String, nullable=False, unique=True, index=True)
|
||||
access_token = Column(String, nullable=True)
|
||||
scope = Column(String, nullable=True)
|
||||
is_installed = Column(Boolean, default=False)
|
||||
|
||||
# OAuth state for CSRF protection
|
||||
oauth_state = Column(String, nullable=True)
|
||||
oauth_state_expiry = Column(DateTime, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Use UUID type to match the database schema from Alembic migration
|
||||
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=True)
|
||||
organization = relationship("Organization", back_populates="shopify_shops")
|
||||
@@ -0,0 +1,287 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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")
|
||||
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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 sqlalchemy import JSON, Boolean, Column, DateTime, ForeignKey, Index, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import expression, func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
from app.models.ticket import _ValueStrEnum
|
||||
|
||||
|
||||
class TicketActivityType(_ValueStrEnum):
|
||||
COMMENT = "comment"
|
||||
STATUS_CHANGE = "status_change"
|
||||
ASSIGNMENT = "assignment"
|
||||
PRIORITY_CHANGE = "priority_change"
|
||||
AI_TRIAGE = "ai_triage"
|
||||
AI_INVESTIGATION_STARTED = "ai_investigation_started"
|
||||
AI_INVESTIGATION_COMPLETED = "ai_investigation_completed"
|
||||
AI_RESOLUTION_PROPOSED = "ai_resolution_proposed"
|
||||
AI_RESOLUTION_APPROVED = "ai_resolution_approved"
|
||||
AI_RESOLUTION_REJECTED = "ai_resolution_rejected"
|
||||
CUSTOMER_NOTIFIED = "customer_notified"
|
||||
CUSTOMER_REPLIED = "customer_replied"
|
||||
CUSTOMER_LINKED = "customer_linked"
|
||||
CSAT_REQUESTED = "csat_requested"
|
||||
CSAT_RECEIVED = "csat_received"
|
||||
REOPENED = "reopened"
|
||||
JIRA_ESCALATED = "jira_escalated"
|
||||
|
||||
|
||||
class TicketActorType(_ValueStrEnum):
|
||||
USER = "user"
|
||||
AI = "ai"
|
||||
CUSTOMER = "customer"
|
||||
SYSTEM = "system"
|
||||
|
||||
|
||||
class TicketActivity(Base):
|
||||
"""Append-only ticket feed — doubles as comments and the audit trail of
|
||||
every AI/system/human action. Never updated or deleted in place.
|
||||
"""
|
||||
__tablename__ = "ticket_activities"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
ticket_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tickets.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
activity_type = Column(String, nullable=False)
|
||||
actor_type = Column(String, nullable=False, default=TicketActorType.SYSTEM)
|
||||
actor_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
# Comment text or the human-readable line for audit events.
|
||||
body = Column(Text, nullable=True)
|
||||
# Internal note vs customer-visible comment.
|
||||
is_internal = Column(Boolean, nullable=False, default=True, server_default=expression.true())
|
||||
# Structured context: {"from": "open", "to": "investigating"},
|
||||
# {"run_id": ...}, {"channel": "web"}, ...
|
||||
activity_metadata = Column(JSON, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
ticket = relationship("Ticket", back_populates="activities")
|
||||
actor_user = relationship("User")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_ticket_activities_ticket_created", "ticket_id", "created_at"),
|
||||
)
|
||||
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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 uuid
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSON, UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import expression, func
|
||||
|
||||
from app.database import Base
|
||||
from app.models.ticket import _ValueStrEnum
|
||||
|
||||
|
||||
class DBConnectorEngine(_ValueStrEnum):
|
||||
POSTGRESQL = "postgresql"
|
||||
MYSQL = "mysql"
|
||||
|
||||
|
||||
# Which value of the ticket's customer is matched against the scope column.
|
||||
# Email is the default because it is the identifier a support database is most
|
||||
# likely to share with ChatterMate; phone suits channel-first businesses.
|
||||
ROW_SCOPE_KEYS = ("email", "phone")
|
||||
|
||||
|
||||
class TicketDBConnector(Base):
|
||||
"""A guardrailed, read-only database connection the ticket investigation
|
||||
agent may query. Guardrails are structural, enforced outside the model:
|
||||
SELECT-only AST validation against allowed_tables, forced row limits,
|
||||
column masking, read-only transaction + statement timeout, full audit.
|
||||
"""
|
||||
__tablename__ = "ticket_db_connectors"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
organization_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
name = Column(String(200), nullable=False)
|
||||
engine = Column(String, nullable=False, default=DBConnectorEngine.POSTGRESQL)
|
||||
host = Column(String(500), nullable=False)
|
||||
port = Column(Integer, nullable=False, default=5432)
|
||||
database = Column(String(200), nullable=False)
|
||||
username = Column(String(200), nullable=False)
|
||||
# Encrypted with app.core.security.encrypt_api_key — never stored plain.
|
||||
encrypted_password = Column(Text, nullable=False)
|
||||
|
||||
enabled = Column(Boolean, nullable=False, default=True, server_default=expression.true())
|
||||
|
||||
# SSH tunnel (bastion / jump host). Production databases are rarely
|
||||
# directly reachable — the connection is forwarded through an SSH host.
|
||||
# host/port above are then resolved from the SSH host's perspective.
|
||||
ssh_enabled = Column(Boolean, nullable=False, default=False, server_default=expression.false())
|
||||
ssh_host = Column(String(500), nullable=True)
|
||||
ssh_port = Column(Integer, nullable=False, default=22, server_default="22")
|
||||
ssh_username = Column(String(200), nullable=True)
|
||||
# Exactly one auth method is used: private key preferred, else password.
|
||||
# All encrypted with app.core.security.encrypt_api_key.
|
||||
encrypted_ssh_password = Column(Text, nullable=True)
|
||||
encrypted_ssh_private_key = Column(Text, nullable=True)
|
||||
encrypted_ssh_key_passphrase = Column(Text, nullable=True)
|
||||
|
||||
# ["schema.table", ...] — nothing is queryable unless listed here.
|
||||
allowed_tables = Column(JSON, nullable=True)
|
||||
# ["email", "phone", ...] — masked before the AI ever sees them; the AST
|
||||
# validator also rejects any query referencing them.
|
||||
masked_columns = Column(JSON, nullable=True)
|
||||
# {"schema.table": "customer_email", ...} — row-level scoping. A listed
|
||||
# table is rewritten into a subquery pre-filtered to the ticket customer's
|
||||
# own rows, so the agent cannot read across customers even with a query
|
||||
# that tries to. Tables left out are unscoped: reference data (products,
|
||||
# error codes) that belongs to nobody in particular.
|
||||
row_scope = Column(JSON, nullable=True)
|
||||
# Which customer value fills the predicate — see ROW_SCOPE_KEYS.
|
||||
row_scope_key = Column(
|
||||
String(20), nullable=False, default="email", server_default="email"
|
||||
)
|
||||
max_rows = Column(Integer, nullable=False, default=100, server_default="100")
|
||||
statement_timeout_ms = Column(Integer, nullable=False, default=5000, server_default="5000")
|
||||
|
||||
last_test_at = Column(DateTime(timezone=True), nullable=True)
|
||||
last_test_ok = Column(Boolean, nullable=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
organization = relationship("Organization")
|
||||
|
||||
|
||||
class DBConnectorAuditOutcome(_ValueStrEnum):
|
||||
ALLOWED = "allowed"
|
||||
BLOCKED = "blocked"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class DBConnectorAuditLog(Base):
|
||||
"""Append-only audit of every query the AI attempted against a
|
||||
connector: the raw model SQL, what actually executed (canonical form),
|
||||
and the outcome. Result rows are never stored."""
|
||||
__tablename__ = "db_connector_audit_logs"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
connector_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("ticket_db_connectors.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
organization_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
ticket_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("tickets.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
run_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("investigation_runs.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
raw_sql = Column(Text, nullable=False)
|
||||
validated_sql = Column(Text, nullable=True)
|
||||
outcome = Column(String, nullable=False)
|
||||
block_reason = Column(Text, nullable=True)
|
||||
row_count = Column(Integer, nullable=True)
|
||||
duration_ms = Column(Integer, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_db_connector_audit_logs_created", "organization_id", "created_at"),
|
||||
)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import expression, func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
# Staged autonomy levels — enforced in code (TicketService/worker), never just
|
||||
# in the prompt.
|
||||
AUTONOMY_INVESTIGATE_ONLY = 1 # L1: investigate + summarize
|
||||
AUTONOMY_PROPOSE_APPROVE = 2 # L2: propose resolution, human approves
|
||||
AUTONOMY_AUTO_RESOLVE = 3 # L3: auto-resolve + notify customer
|
||||
|
||||
# Default customer message templates. [customer]/[ticket] placeholders are
|
||||
# substituted at send time.
|
||||
DEFAULT_CREATED_TEMPLATE = (
|
||||
"Hi [customer] — we've opened ticket [ticket] about your issue and our "
|
||||
"team is on it. We'll keep you posted."
|
||||
)
|
||||
DEFAULT_RESOLVED_TEMPLATE = (
|
||||
"Good news [customer] — ticket [ticket] is resolved. Here's what happened "
|
||||
"and what we did to fix it. Reply if anything's still off."
|
||||
)
|
||||
|
||||
# Default SLA targets in minutes, keyed by priority value.
|
||||
DEFAULT_SLA_TARGETS = {
|
||||
"urgent": {"first_response_minutes": 15, "resolution_minutes": 120},
|
||||
"high": {"first_response_minutes": 30, "resolution_minutes": 240},
|
||||
"medium": {"first_response_minutes": 120, "resolution_minutes": 1440},
|
||||
"low": {"first_response_minutes": 480, "resolution_minutes": 4320},
|
||||
}
|
||||
|
||||
|
||||
class OrganizationTicketSettings(Base):
|
||||
"""Per-org AI ticketing policy: autonomy level, SLA targets, customer
|
||||
comms templates and investigation budgets. One row per organization,
|
||||
created lazily with defaults on first read.
|
||||
"""
|
||||
__tablename__ = "organization_ticket_settings"
|
||||
|
||||
organization_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
|
||||
autonomy_level = Column(Integer, nullable=False, default=AUTONOMY_INVESTIGATE_ONLY, server_default="1")
|
||||
auto_investigate_on_create = Column(Boolean, nullable=False, default=True, server_default=expression.true())
|
||||
min_confidence_to_auto_resolve = Column(Float, nullable=False, default=0.85)
|
||||
# resolved_pending_confirmation -> closed after this many hours without a
|
||||
# customer reply.
|
||||
confirmation_timeout_hours = Column(Integer, nullable=False, default=72, server_default="72")
|
||||
csat_enabled = Column(Boolean, nullable=False, default=True, server_default=expression.true())
|
||||
|
||||
# {"urgent": {"first_response_minutes": 15, "resolution_minutes": 120}, ...}
|
||||
sla_targets = Column(JSON, nullable=True)
|
||||
# Customer message templates with [customer]/[ticket] placeholders.
|
||||
created_template = Column(Text, nullable=True)
|
||||
resolved_template = Column(Text, nullable=True)
|
||||
|
||||
# Jira escalation (one-way sync). jira_escalation_priority is the minimum
|
||||
# priority value that also syncs to Jira.
|
||||
jira_escalation_enabled = Column(Boolean, nullable=False, default=False, server_default=expression.false())
|
||||
jira_escalation_priority = Column(String, nullable=True)
|
||||
|
||||
# Ticket-scoped MCP connector selection (list of mcp_tools.id) —
|
||||
# deliberately separate from the chat-facing MCPToolToAgent m2m so
|
||||
# observability connectors are never handed to the customer-facing agent.
|
||||
investigation_mcp_tool_ids = Column(JSON, nullable=True)
|
||||
# Alert-webhook intake: Grafana/Datadog/CloudWatch alerts auto-open
|
||||
# tickets via POST /tickets/webhooks/alerts/{org_id}/{secret}.
|
||||
alert_webhook_enabled = Column(Boolean, nullable=False, default=False, server_default=expression.false())
|
||||
# URL-path token authenticating alert posts; rotated by re-enabling.
|
||||
alert_webhook_secret = Column(String(64), nullable=True)
|
||||
|
||||
# Investigation budgets.
|
||||
max_tool_calls_per_run = Column(Integer, nullable=False, default=25, server_default="25")
|
||||
max_runs_per_ticket = Column(Integer, nullable=False, default=3, server_default="3")
|
||||
monthly_investigation_cap = Column(Integer, nullable=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
organization = relationship("Organization")
|
||||
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, func, Table, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
import uuid
|
||||
from app.database import Base
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
# Association table for many-to-many relationship between users and groups
|
||||
user_groups = Table(
|
||||
'user_groups',
|
||||
Base.metadata,
|
||||
Column('user_id', UUID(as_uuid=True), ForeignKey('users.id'), primary_key=True),
|
||||
Column('group_id', UUID(as_uuid=True), ForeignKey('groups.id'), primary_key=True)
|
||||
)
|
||||
|
||||
class UserGroup(Base):
|
||||
__tablename__ = "groups"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name = Column(String, nullable=False)
|
||||
description = Column(String)
|
||||
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
organization = relationship("Organization", back_populates="groups")
|
||||
users = relationship("User", secondary=user_groups, back_populates="groups")
|
||||
session_assignments = relationship("SessionToAgent", back_populates="group")
|
||||
|
||||
# agents relationship will be created by backref from Agent model
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert user group object to dictionary"""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"agents": [{"id": str(agent.id), "name": agent.name} for agent in self.agents]
|
||||
}
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
email = Column(String, unique=True, index=True)
|
||||
full_name = Column(String)
|
||||
hashed_password = Column(String)
|
||||
is_active = Column(Boolean, default=True)
|
||||
profile_pic = Column(String, nullable=True)
|
||||
is_online = Column(Boolean, default=False)
|
||||
last_seen = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=True)
|
||||
role_id = Column(Integer, ForeignKey("roles.id"), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Define relationships
|
||||
organization = relationship("Organization", back_populates="users")
|
||||
role = relationship("Role", back_populates="users")
|
||||
knowledge_queue_items = relationship(
|
||||
"KnowledgeQueue",
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
notifications = relationship(
|
||||
"Notification", back_populates="user", cascade="all, delete-orphan")
|
||||
# One row per signed-in browser; see app/models/fcm_token.py
|
||||
fcm_tokens = relationship(
|
||||
"FCMToken", back_populates="user", cascade="all, delete-orphan")
|
||||
chat_histories = relationship("ChatHistory", back_populates="user")
|
||||
|
||||
# Add groups relationship
|
||||
groups = relationship("UserGroup", secondary=user_groups, back_populates="users")
|
||||
|
||||
# Add this new relationship
|
||||
session_assignments = relationship("SessionToAgent", back_populates="user")
|
||||
ratings = relationship("Rating", back_populates="user")
|
||||
def to_dict(self):
|
||||
"""Convert user object to dictionary"""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"email": self.email,
|
||||
"full_name": self.full_name,
|
||||
"is_active": self.is_active,
|
||||
"organization_id": str(self.organization_id),
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
"role": self.role.to_dict() if self.role else None
|
||||
}
|
||||
|
||||
def verify_password(self, password: str) -> bool:
|
||||
return pwd_context.verify(password, self.hashed_password)
|
||||
|
||||
@staticmethod
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, String, ForeignKey, Integer
|
||||
from sqlalchemy.orm import relationship
|
||||
from uuid import uuid4
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Widget(Base):
|
||||
__tablename__ = "widgets"
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid4()))
|
||||
name = Column(String, nullable=False)
|
||||
organization_id = Column(UUID(as_uuid=True), ForeignKey(
|
||||
"organizations.id"), nullable=False)
|
||||
|
||||
# Agent relationship
|
||||
agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id"))
|
||||
agent = relationship("Agent", back_populates="widgets")
|
||||
|
||||
# Relationships
|
||||
organization = relationship("Organization", back_populates="widgets")
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Boolean, Column, String, Text, ForeignKey, DateTime, func
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
import uuid
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class WidgetApp(Base):
|
||||
"""
|
||||
Widget App model for managing API keys for widget authentication.
|
||||
|
||||
Each organization can create multiple widget apps, each with a unique API key
|
||||
for generating conversation tokens. API keys are stored as bcrypt hashes for security.
|
||||
"""
|
||||
__tablename__ = "widget_apps"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||
name = Column(String, nullable=False) # User-friendly name like "Production Widget"
|
||||
description = Column(Text, nullable=True) # Optional description
|
||||
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True)
|
||||
api_key_hash = Column(String, nullable=False) # bcrypt hash of the API key (one-way)
|
||||
api_key_lookup = Column(String(64), nullable=True, unique=True, index=True) # SHA-256 of the key; NULL for keys issued before it
|
||||
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) # Audit trail
|
||||
is_active = Column(Boolean, default=True, index=True) # Soft delete support
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
organization = relationship("Organization", back_populates="widget_apps")
|
||||
creator = relationship("User", foreign_keys=[created_by])
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, ForeignKey, JSON, DateTime, Enum
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.database import Base
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class WorkflowStatus(str, enum.Enum):
|
||||
DRAFT = "draft"
|
||||
PUBLISHED = "published"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
class Workflow(Base):
|
||||
__tablename__ = "workflows"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name = Column(String(100), nullable=False)
|
||||
description = Column(Text)
|
||||
status = Column(Enum(WorkflowStatus), default=WorkflowStatus.DRAFT)
|
||||
version = Column(Integer, default=1)
|
||||
is_template = Column(Boolean, default=False)
|
||||
default_language = Column(String(10), default="en")
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"))
|
||||
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"))
|
||||
agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id"))
|
||||
canvas_data = Column(JSON, default={}) # Stores canvas view state (zoom, position)
|
||||
settings = Column(JSON, default={}) # General workflow settings
|
||||
|
||||
# Relationships
|
||||
organization = relationship("Organization", back_populates="workflows")
|
||||
agent = relationship("Agent", foreign_keys=[agent_id], back_populates="workflows")
|
||||
user = relationship("User")
|
||||
nodes = relationship("WorkflowNode", back_populates="workflow", cascade="all, delete-orphan")
|
||||
connections = relationship("WorkflowConnection", back_populates="workflow", cascade="all, delete-orphan")
|
||||
|
||||
sessions = relationship("SessionToAgent", back_populates="workflow")
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Text, ForeignKey, JSON, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
import uuid
|
||||
|
||||
|
||||
class WorkflowConnection(Base):
|
||||
__tablename__ = "workflow_connections"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
workflow_id = Column(UUID(as_uuid=True), ForeignKey("workflows.id", ondelete="CASCADE"), nullable=False)
|
||||
source_node_id = Column(UUID(as_uuid=True), ForeignKey("workflow_nodes.id", ondelete="CASCADE"), nullable=False)
|
||||
target_node_id = Column(UUID(as_uuid=True), ForeignKey("workflow_nodes.id", ondelete="CASCADE"), nullable=False)
|
||||
label = Column(String(100)) # Optional label for the connection
|
||||
condition = Column(Text) # Optional condition for conditional routing
|
||||
priority = Column(Integer, default=0) # Priority for multiple outgoing connections
|
||||
connection_metadata = Column(JSON, default={}) # Additional connection properties
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
workflow = relationship("Workflow", back_populates="connections")
|
||||
source_node = relationship("WorkflowNode", foreign_keys=[source_node_id], back_populates="outgoing_connections")
|
||||
target_node = relationship("WorkflowNode", foreign_keys=[target_node_id], back_populates="incoming_connections")
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, String, Text, ForeignKey, JSON, Float, DateTime, Enum
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
|
||||
class NodeType(str, enum.Enum):
|
||||
MESSAGE = "message"
|
||||
LLM = "llm"
|
||||
CONDITION = "condition"
|
||||
FORM = "form"
|
||||
ACTION = "action"
|
||||
HUMAN_TRANSFER = "human_transfer"
|
||||
WAIT = "wait"
|
||||
END = "end"
|
||||
LANDING_PAGE = "landing_page"
|
||||
USER_INPUT = "user_input"
|
||||
GUARDRAILS = "guardrails"
|
||||
|
||||
|
||||
class ActionType(str, enum.Enum):
|
||||
WEBHOOK = "webhook"
|
||||
DATABASE = "database"
|
||||
EMAIL = "email"
|
||||
NOTIFICATION = "notification"
|
||||
CUSTOM = "custom"
|
||||
|
||||
|
||||
class ExitCondition(str, enum.Enum):
|
||||
SINGLE_EXECUTION = "single_execution"
|
||||
CONTINUOUS_EXECUTION = "continuous_execution"
|
||||
|
||||
|
||||
class WorkflowNode(Base):
|
||||
__tablename__ = "workflow_nodes"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
workflow_id = Column(UUID(as_uuid=True), ForeignKey("workflows.id", ondelete="CASCADE"), nullable=False)
|
||||
node_type = Column(Enum(NodeType), nullable=False)
|
||||
name = Column(String(100), nullable=False)
|
||||
description = Column(Text)
|
||||
position_x = Column(Float, default=0) # X coordinate on canvas
|
||||
position_y = Column(Float, default=0) # Y coordinate on canvas
|
||||
config = Column(JSON, default={}) # Node-specific configuration
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
|
||||
# Relationships
|
||||
workflow = relationship("Workflow", back_populates="nodes")
|
||||
outgoing_connections = relationship(
|
||||
"WorkflowConnection",
|
||||
foreign_keys="[WorkflowConnection.source_node_id]",
|
||||
back_populates="source_node",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
incoming_connections = relationship(
|
||||
"WorkflowConnection",
|
||||
foreign_keys="[WorkflowConnection.target_node_id]",
|
||||
back_populates="target_node",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
Reference in new issue
Block a user