feat(models): 组织、用户、角色、客户、会话与消息模型

按组织边界定义数据关系,补齐仓储层与通用工具,迁移脚本可重复执行。
This commit is contained in:
WangShen007 committed 2026-09-23 22:05:00 +08:00
1 parent 2aee6286d6
commit 5e0615f36d
126 files changed
+17436

No files matched your search

+150
View File
@@ -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",
]
+217
View File
@@ -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
+67
View File
@@ -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")
+42
View File
@@ -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)
+26
View File
@@ -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")
+71
View File
@@ -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")
+189
View File
@@ -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")
+92
View File
@@ -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")
+119
View File
@@ -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),
)
+125
View File
@@ -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.
)
+55
View File
@@ -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")
+46
View File
@@ -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])
+89
View File
@@ -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())
+134
View File
@@ -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)
+326
View File
@@ -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"),
)
+53
View File
@@ -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)
+50
View File
@@ -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")
+68
View File
@@ -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")
+36
View File
@@ -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")
+118
View File
@@ -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")
+74
View File
@@ -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")
+47
View File
@@ -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")
+114
View File
@@ -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
+86
View File
@@ -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")
]
+42
View File
@@ -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")
+64
View File
@@ -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")
+34
View File
@@ -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'
]
+163
View File
@@ -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
+58
View File
@@ -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
+229
View File
@@ -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
+386
View File
@@ -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]
+57
View File
@@ -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]
+202
View File
@@ -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
+151
View File
@@ -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
+154
View File
@@ -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,
}
+32
View File
@@ -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 []
+202
View File
@@ -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
+37
View File
@@ -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,
)
+95
View File
@@ -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
+59
View File
@@ -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
+390
View File
@@ -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
+145
View File
@@ -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
+40
View File
@@ -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]
+95
View File
@@ -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
+67
View File
@@ -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]
+202
View File
@@ -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
+109
View File
@@ -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
)
+19
View File
@@ -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")
+287
View File
@@ -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")
+88
View File
@@ -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"),
)
+148
View File
@@ -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"),
)
+100
View File
@@ -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")
+116
View File
@@ -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)
+37
View File
@@ -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")
+46
View File
@@ -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])
+60
View File
@@ -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
+45
View File
@@ -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
+85
View File
@@ -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