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
+18
View File
@@ -0,0 +1,18 @@
"""
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 . import shopify_shop_repository
from . import agent_shopify_config_repository
+182
View File
@@ -0,0 +1,182 @@
"""
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.orm import Session
from typing import List, Optional
from app.models.agent import Agent, AgentType
import json
from sqlalchemy.orm import joinedload
from uuid import UUID
from app.core.logger import get_logger
logger = get_logger(__name__)
class AgentRepository:
def __init__(self, db: Session):
self.db = db
def create_agent(self, **kwargs) -> Agent:
"""Create a new agent"""
# Handle instructions
# We don't need to do any conversion here, as the Agent model will handle it
# through its property setter
# Handle tools if present
if 'tools' in kwargs and kwargs['tools']:
if not isinstance(kwargs['tools'], str):
kwargs['tools'] = json.dumps(kwargs['tools'])
# Handle org_id to organization_id conversion for backward compatibility
if 'org_id' in kwargs:
kwargs['organization_id'] = kwargs.pop('org_id')
# Create new agent
agent = Agent(**kwargs)
self.db.add(agent)
self.db.commit()
self.db.refresh(agent)
return agent
def get_by_name(self, name: str, organization_id: UUID) -> Optional[Agent]:
"""Get agent by name within an organization"""
return self.db.query(Agent).filter(
Agent.name == name,
Agent.organization_id == organization_id
).first()
def get_agent(self, agent_id: UUID) -> Optional[Agent]:
"""Get template by ID"""
return self.db.query(Agent).filter(
Agent.id == agent_id
).first()
def get_agent_in_org(self, agent_id, org_id) -> Optional[Agent]:
"""Get an agent only if it belongs to org_id.
The single choke point for "is this agent mine?" — endpoints that take
an agent_id from the caller use this so an agent from another tenant
can never be read or reconfigured.
"""
return self.db.query(Agent).filter(
Agent.id == agent_id,
Agent.organization_id == org_id
).first()
def get_org_agents(self, org_id: UUID, active_only: bool = True) -> List[Agent]:
"""Get all templates for an organization"""
query = self.db.query(Agent).filter(
Agent.organization_id == org_id
)
if active_only:
query = query.filter(Agent.is_active == True)
return query.all()
def get_default_agents(self, org_id: UUID) -> Optional[Agent]:
"""Get default template for an organization"""
return self.db.query(Agent).filter(
Agent.organization_id == org_id,
Agent.is_default == True,
Agent.is_active == True
).first()
def update_agent(self, agent_id: UUID, **kwargs) -> Optional[Agent]:
"""Update an existing template"""
agent = self.get_agent(agent_id)
if not agent:
return None
# Handle special fields
if 'instructions' in kwargs:
instructions = kwargs['instructions']
if isinstance(instructions, str):
try:
# Try to parse if it's a JSON string
parsed = json.loads(instructions)
if isinstance(parsed, list):
kwargs['instructions'] = parsed
else:
# If it's a single instruction, wrap in list
kwargs['instructions'] = [instructions]
except json.JSONDecodeError:
# If not JSON, treat as single instruction
kwargs['instructions'] = [instructions]
if 'tools' in kwargs:
kwargs['tools'] = json.dumps(kwargs['tools'])
# Handle default flag
if kwargs.get('is_default'):
existing_defaults = self.db.query(Agent).filter(
Agent.organization_id == agent.organization_id,
Agent.is_default == True,
Agent.id != agent_id
).all()
for existing in existing_defaults:
existing.is_default = False
for key, value in kwargs.items():
setattr(agent, key, value)
self.db.commit()
self.db.refresh(agent)
return agent
def delete_agent(self, agent_id: str) -> bool:
"""Soft delete a template by setting is_active to False"""
template = self.get_agent(agent_id)
if not template:
return False
template.is_active = False
self.db.commit()
return True
def get_active_agents(self, org_id: UUID) -> List[Agent]:
"""Get the active agent template for an organization"""
return self.db.query(Agent)\
.filter(Agent.organization_id == org_id)\
.filter(Agent.is_active == True)\
.all()
def get_by_agent_id(self, agent_id: str):
"""Get agent by ID with relationships loaded"""
if isinstance(agent_id, str):
agent_id = UUID(agent_id)
return self.db.query(Agent)\
.options(
joinedload(Agent.groups),
joinedload(Agent.organization)
)\
.filter(Agent.id == agent_id)\
.first()
def get_all(self) -> list[Agent]:
return self.db.query(Agent).all()
def get_by_id(self, agent_id: str) -> Agent | None:
return self.db.query(Agent).filter(Agent.id == agent_id).first()
def get_all_agents(self, org_id: UUID) -> List[Agent]:
"""Get all agents for organization"""
return self.db.query(Agent)\
.filter(Agent.organization_id == org_id)\
.all()
def count_by_organization(self, org_id: UUID) -> int:
"""Count agents for an organization"""
from sqlalchemy import func
return self.db.query(func.count(Agent.id))\
.filter(Agent.organization_id == org_id)\
.scalar() or 0
@@ -0,0 +1,157 @@
"""
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.orm import Session
from typing import Optional, List
from app.models.shopify.agent_shopify_config import AgentShopifyConfig
from app.models.schemas.shopify.agent_shopify_config import (
AgentShopifyConfigCreate,
AgentShopifyConfigUpdate
)
from app.core.logger import get_logger
from sqlalchemy import cast, String
logger = get_logger(__name__)
class AgentShopifyConfigRepository:
def __init__(self, db: Session):
self.db = db
def get_agent_shopify_config(self, agent_id: str) -> Optional[AgentShopifyConfig]:
"""Get Shopify configuration for an agent."""
return self.db.query(AgentShopifyConfig).filter(AgentShopifyConfig.agent_id == agent_id).first()
def get_config_by_agent_and_shop(self, agent_id: str, shop_id: str) -> Optional[AgentShopifyConfig]:
"""Get Shopify configuration for a specific agent and shop combination."""
return self.db.query(AgentShopifyConfig).filter(
AgentShopifyConfig.agent_id == agent_id,
AgentShopifyConfig.shop_id == shop_id
).first()
def get_configs_by_shop(self, shop_id: str, enabled_only: bool = False) -> List[AgentShopifyConfig]:
"""Get all agent configurations for a specific shop.
Args:
shop_id: The shop ID to filter by
enabled_only: If True, only return enabled configurations (default: False)
Returns:
List of AgentShopifyConfig objects
"""
query = self.db.query(AgentShopifyConfig).filter(AgentShopifyConfig.shop_id == shop_id)
if enabled_only:
query = query.filter(AgentShopifyConfig.enabled == True)
return query.all()
def get_enabled_configs_for_org(self, organization_id: str) -> List[AgentShopifyConfig]:
"""
Get all enabled Shopify configurations for agents belonging to a specific organization.
Args:
organization_id: The ID of the organization to check
Returns:
List of AgentShopifyConfig objects that are enabled and belong to the organization
"""
# We need to join with the User/Agent table to filter by organization_id
from app.models.user import User
return (self.db.query(AgentShopifyConfig)
.join(User, User.id.cast(String) == AgentShopifyConfig.agent_id)
.filter(
AgentShopifyConfig.enabled == True,
User.organization_id == organization_id
)
.all())
def create_agent_shopify_config(self, config: AgentShopifyConfigCreate) -> AgentShopifyConfig:
"""Create a new Shopify configuration for an agent."""
db_config = AgentShopifyConfig(
id=str(uuid.uuid4()),
agent_id=config.agent_id,
shop_id=config.shop_id,
enabled=config.enabled
)
self.db.add(db_config)
self.db.commit()
self.db.refresh(db_config)
return db_config
def create_or_update_agent_shopify_config(self, config: AgentShopifyConfigCreate) -> AgentShopifyConfig:
"""Create or update a Shopify configuration for an agent.
One agent can only be connected to one shop at a time.
If a configuration already exists for this agent (regardless of shop),
it will be updated with the new shop_id and enabled status.
Otherwise, a new configuration will be created.
"""
# Check if config already exists for this agent (one agent = one shop only)
existing_config = self.get_agent_shopify_config(config.agent_id)
if existing_config:
# Update existing config with new shop_id and enabled status
logger.info(f"Updating existing Shopify config for agent {config.agent_id}: "
f"changing shop from {existing_config.shop_id} to {config.shop_id}, enabled={config.enabled}")
existing_config.shop_id = config.shop_id
existing_config.enabled = config.enabled
self.db.commit()
self.db.refresh(existing_config)
return existing_config
else:
# Create new config
logger.info(f"Creating new Shopify config for agent {config.agent_id} and shop {config.shop_id}")
db_config = AgentShopifyConfig(
id=str(uuid.uuid4()),
agent_id=config.agent_id,
shop_id=config.shop_id,
enabled=config.enabled
)
self.db.add(db_config)
self.db.commit()
self.db.refresh(db_config)
return db_config
def update_agent_shopify_config(self, agent_id: str, config: AgentShopifyConfigUpdate) -> Optional[AgentShopifyConfig]:
"""Update an existing Shopify configuration for an agent."""
db_config = self.get_agent_shopify_config(agent_id)
if not db_config:
return None
for key, value in config.dict(exclude_unset=True).items():
setattr(db_config, key, value)
self.db.commit()
self.db.refresh(db_config)
return db_config
def delete_agent_shopify_config(self, agent_id: str) -> bool:
"""Delete a Shopify configuration for an agent."""
db_config = self.get_agent_shopify_config(agent_id)
if not db_config:
return False
self.db.delete(db_config)
self.db.commit()
return True
def update(self, config: AgentShopifyConfig) -> AgentShopifyConfig:
"""Update an existing Shopify configuration object."""
self.db.commit()
self.db.refresh(config)
return config
+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.orm import Session
from app.models.ai_config import AIConfig, AIModelType, DEFAULT_AI_SETTINGS
from app.core.security import encrypt_api_key, decrypt_api_key
from typing import Optional
class AIConfigRepository:
def __init__(self, db: Session):
self.db = db
def create_config(self, org_id: str, model_type: str, model_name: str, api_key: str, base_url: Optional[str] = None) -> AIConfig:
"""Create a new AI configuration"""
try:
# Encrypt API key before storing
encrypted_key = encrypt_api_key(api_key)
# Create new config
config = AIConfig(
organization_id=org_id,
model_type=AIModelType(model_type.upper()),
model_name=model_name,
encrypted_api_key=encrypted_key,
is_active=True
)
if base_url:
# No dedicated column: base_url lives in the JSON settings blob.
# The column default is only applied by SQLAlchemy at flush time,
# so start from the same default explicitly rather than {}.
config.settings = {**DEFAULT_AI_SETTINGS, "base_url": base_url}
# Deactivate any existing configs
existing_configs = self.db.query(AIConfig).filter(
AIConfig.organization_id == org_id,
AIConfig.is_active == True
).all()
for existing in existing_configs:
existing.is_active = False
self.db.add(config)
self.db.commit()
self.db.refresh(config)
return config
except Exception as e:
self.db.rollback()
raise e
def get_active_config(self, org_id: str) -> Optional[AIConfig]:
"""Get active AI configuration for an organization"""
config = self.db.query(AIConfig).filter(
AIConfig.organization_id == org_id,
AIConfig.is_active == True
).first()
if not config:
return None
# Return dict with decrypted API key
return config
def update_config(self, config_id: int, **kwargs) -> Optional[AIConfig]:
"""Update an existing AI configuration"""
config = self.db.query(AIConfig).filter(
AIConfig.id == config_id).first()
if not config:
return None
for key, value in kwargs.items():
if key == 'api_key':
if value is not None:
config.encrypted_api_key = encrypt_api_key(value)
elif key == 'base_url':
# None means "no change" (mirrors api_key above). An explicit
# empty string clears it. Merged into settings JSON — no
# dedicated column — so the rest of settings survives.
if value is None:
continue
current_settings = dict(config.settings or DEFAULT_AI_SETTINGS)
if value:
current_settings['base_url'] = value
else:
current_settings.pop('base_url', None)
config.settings = current_settings
else:
setattr(config, key, value)
self.db.commit()
self.db.refresh(config)
return config
def deactivate_config(self, config_id: int) -> bool:
"""Deactivate an AI configuration"""
config = self.db.query(AIConfig).filter(
AIConfig.id == config_id).first()
if not config:
return False
config.is_active = False
self.db.commit()
return True
@@ -0,0 +1,25 @@
"""
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.repositories.channels.accounts import ChannelAccountRepository
from app.repositories.channels.conversations import ChannelConversationRepository
from app.repositories.channels.agent_config import AgentChannelConfigRepository
__all__ = [
"ChannelAccountRepository",
"ChannelConversationRepository",
"AgentChannelConfigRepository",
]
@@ -0,0 +1,232 @@
"""
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 json
import secrets
from datetime import datetime, timezone
from typing import Iterable, Optional, List
from uuid import UUID
from sqlalchemy.orm import Session
from app.channels.base import SendResult
from app.models.channels import ChannelAccount, ChannelConversation
from app.models.session_to_agent import SessionToAgent, SessionStatus
from app.core.security import decrypt_credentials, encrypt_api_key
from app.core.logger import get_logger
logger = get_logger(__name__)
# settings key set while the platform rejects the account's credentials:
# {"error": <platform message>, "at": <ISO time>}. Absent means healthy.
AUTH_FAILURE_SETTING = "auth_failure"
class ChannelAccountRepository:
"""CRUD for connected channel accounts. Credentials are always stored
Fernet-encrypted; use get_credentials()/create_account() so plaintext
secrets never touch the table."""
def __init__(self, db: Session):
self.db = db
def create_account(
self,
organization_id: UUID,
channel_type: str,
external_account_id: str,
credentials: dict,
display_name: Optional[str] = None,
settings: Optional[dict] = None,
) -> ChannelAccount:
try:
account = ChannelAccount(
organization_id=organization_id,
channel_type=channel_type,
external_account_id=external_account_id,
display_name=display_name,
encrypted_credentials=encrypt_api_key(json.dumps(credentials)),
webhook_secret=secrets.token_urlsafe(32),
settings=settings or {},
)
self.db.add(account)
self.db.commit()
self.db.refresh(account)
logger.info(f"Created {channel_type} channel account for org {organization_id}")
return account
except Exception as e:
logger.error(f"Error creating channel account: {str(e)}")
self.db.rollback()
raise
def get_by_id(self, account_id: UUID) -> Optional[ChannelAccount]:
try:
if isinstance(account_id, str):
account_id = UUID(account_id)
return self.db.query(ChannelAccount).filter(
ChannelAccount.id == account_id
).first()
except Exception as e:
logger.error(f"Error getting channel account: {str(e)}")
return None
def get_by_external_id(self, channel_type: str, external_account_id: str) -> Optional[ChannelAccount]:
"""Resolve the account a webhook payload belongs to."""
try:
return self.db.query(ChannelAccount).filter(
ChannelAccount.channel_type == channel_type,
ChannelAccount.external_account_id == external_account_id,
).first()
except Exception as e:
logger.error(f"Error getting channel account by external id: {str(e)}")
return None
def list_by_org(self, organization_id: UUID, channel_type: Optional[str] = None) -> List[ChannelAccount]:
try:
query = self.db.query(ChannelAccount).filter(
ChannelAccount.organization_id == organization_id
)
if channel_type:
query = query.filter(ChannelAccount.channel_type == channel_type)
return query.order_by(ChannelAccount.created_at).all()
except Exception as e:
logger.error(f"Error listing channel accounts: {str(e)}")
return []
def get_credentials(self, account: ChannelAccount) -> dict:
"""Decrypt an account's credential blob."""
return decrypt_credentials(account.encrypted_credentials)
def update_settings(self, account: ChannelAccount, updates: dict,
remove: Iterable[str] = ()) -> ChannelAccount:
"""Merge non-secret settings into the account (and drop `remove`)."""
merged = {**(account.settings or {}), **updates}
for key in remove:
merged.pop(key, None)
try:
account.settings = merged
self.db.commit()
self.db.refresh(account)
return account
except Exception as e:
logger.error(f"Error updating channel account settings: {str(e)}")
self.db.rollback()
raise
def update_credentials(self, account: ChannelAccount, credentials: dict) -> ChannelAccount:
try:
account.encrypted_credentials = encrypt_api_key(json.dumps(credentials))
# New credentials are a fresh chance; the next send proves them
account.settings = _without_auth_failure(account.settings)
self.db.commit()
self.db.refresh(account)
return account
except Exception as e:
logger.error(f"Error updating channel account credentials: {str(e)}")
self.db.rollback()
raise
def track_send_result(self, account: ChannelAccount, result: SendResult) -> None:
"""Keep the account's needs-reconnect flag in step with what the
platform just said about its credentials."""
if result.auth_failed:
self.record_auth_failure(account, result.error)
elif result.ok:
self.clear_auth_failure(account)
def record_auth_failure(self, account: ChannelAccount, error: Optional[str]) -> None:
"""Mark the account as needing reconnection after the platform refused
its credentials. Keeps the first failure's time so the UI can say since
when."""
if needs_reconnect(account):
return
logger.warning(f"{account.channel_type} account {account.id} needs reconnecting: {error}")
self._update_auth_failure(account, {AUTH_FAILURE_SETTING: {
"error": error, "at": datetime.now(timezone.utc).isoformat()}})
def clear_auth_failure(self, account: ChannelAccount) -> None:
"""A send went through, so the credentials work again."""
if needs_reconnect(account):
self._update_auth_failure(account, {}, remove=(AUTH_FAILURE_SETTING,))
def _update_auth_failure(self, account: ChannelAccount, updates: dict,
remove: Iterable[str] = ()) -> None:
# Bookkeeping beside a send: failing to record it must not fail the send
try:
self.update_settings(account, updates, remove=remove)
except Exception:
pass
def set_active(self, account: ChannelAccount, is_active: bool) -> ChannelAccount:
try:
account.is_active = is_active
self.db.commit()
self.db.refresh(account)
return account
except Exception as e:
logger.error(f"Error updating channel account state: {str(e)}")
self.db.rollback()
raise
def _close_open_sessions(self, account: ChannelAccount) -> None:
"""Close the sessions belonging to this account's conversations.
Deleting the account cascade-deletes its channel_conversations, but the
sessions they point at are not owned by the account and would survive as
open-but-unreachable: an agent could take one over while the customer's
next message — finding no conversation — starts a fresh thread that the
AI answers. Closing them first keeps the two from diverging.
"""
session_ids = [
row.session_id
for row in self.db.query(ChannelConversation.session_id)
.filter(ChannelConversation.channel_account_id == account.id)
.all()
]
if not session_ids:
return
closed = (
self.db.query(SessionToAgent)
.filter(
SessionToAgent.session_id.in_(session_ids),
SessionToAgent.status != SessionStatus.CLOSED,
)
.update({SessionToAgent.status: SessionStatus.CLOSED},
synchronize_session=False)
)
if closed:
logger.info(f"Closed {closed} open session(s) for disconnected "
f"{account.channel_type} account {account.id}")
def delete(self, account: ChannelAccount) -> bool:
try:
self._close_open_sessions(account)
self.db.delete(account)
self.db.commit()
return True
except Exception as e:
logger.error(f"Error deleting channel account: {str(e)}")
self.db.rollback()
return False
def needs_reconnect(account: ChannelAccount) -> bool:
return bool((account.settings or {}).get(AUTH_FAILURE_SETTING))
def _without_auth_failure(settings: Optional[dict]) -> dict:
# A new dict, not an in-place pop: the JSON column only notices reassignment
return {k: v for k, v in (settings or {}).items() if k != AUTH_FAILURE_SETTING}
@@ -0,0 +1,103 @@
"""
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 Optional, List
from uuid import UUID
from sqlalchemy.orm import Session
from app.models.channels import AgentChannelConfig
from app.core.logger import get_logger
logger = get_logger(__name__)
class AgentChannelConfigRepository:
def __init__(self, db: Session):
self.db = db
def get_by_account(self, channel_account_id: UUID) -> Optional[AgentChannelConfig]:
try:
return self.db.query(AgentChannelConfig).filter(
AgentChannelConfig.channel_account_id == channel_account_id
).first()
except Exception as e:
logger.error(f"Error getting agent channel config: {str(e)}")
return None
def get_active_agent_id(self, channel_account_id: UUID) -> Optional[UUID]:
"""Agent that answers inbound messages on this account, if enabled."""
config = self.get_by_account(channel_account_id)
if config and config.is_active:
return config.agent_id
return None
def map_by_accounts(self, channel_account_ids: List[UUID]) -> dict:
"""Configs for many accounts in one query, keyed by channel_account_id."""
if not channel_account_ids:
return {}
try:
configs = self.db.query(AgentChannelConfig).filter(
AgentChannelConfig.channel_account_id.in_(channel_account_ids)
).all()
return {config.channel_account_id: config for config in configs}
except Exception as e:
logger.error(f"Error mapping agent channel configs: {str(e)}")
return {}
def list_by_agent(self, agent_id: UUID) -> List[AgentChannelConfig]:
try:
return self.db.query(AgentChannelConfig).filter(
AgentChannelConfig.agent_id == agent_id
).all()
except Exception as e:
logger.error(f"Error listing agent channel configs: {str(e)}")
return []
def set_agent(self, channel_account_id: UUID, agent_id: UUID, is_active: bool = True) -> AgentChannelConfig:
"""Upsert the single agent mapping for a channel account."""
try:
config = self.get_by_account(channel_account_id)
if config:
config.agent_id = agent_id
config.is_active = is_active
else:
config = AgentChannelConfig(
channel_account_id=channel_account_id,
agent_id=agent_id,
is_active=is_active,
)
self.db.add(config)
self.db.commit()
self.db.refresh(config)
return config
except Exception as e:
logger.error(f"Error setting agent channel config: {str(e)}")
self.db.rollback()
raise
def delete_by_account(self, channel_account_id: UUID) -> bool:
try:
config = self.get_by_account(channel_account_id)
if not config:
return False
self.db.delete(config)
self.db.commit()
return True
except Exception as e:
logger.error(f"Error deleting agent channel config: {str(e)}")
self.db.rollback()
return False
@@ -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, timezone
from typing import Iterable, Optional
from uuid import UUID
from sqlalchemy.orm import Session
from app.models.channels import ChannelConversation
from app.models.session_to_agent import SessionToAgent, SessionStatus
from app.core.logger import get_logger
logger = get_logger(__name__)
class ChannelConversationRepository:
def __init__(self, db: Session):
self.db = db
def get_active(self, channel_account_id: UUID, external_conversation_id: str) -> Optional[ChannelConversation]:
"""Latest conversation row for a platform conversation whose session
is still open/transferred. Returns None when a new session is needed."""
try:
return (
self.db.query(ChannelConversation)
.join(SessionToAgent, ChannelConversation.session_id == SessionToAgent.session_id)
.filter(
ChannelConversation.channel_account_id == channel_account_id,
ChannelConversation.external_conversation_id == external_conversation_id,
SessionToAgent.status != SessionStatus.CLOSED,
)
.order_by(ChannelConversation.created_at.desc())
.first()
)
except Exception as e:
logger.error(f"Error getting active channel conversation: {str(e)}")
return None
def get_by_session(self, session_id: UUID) -> Optional[ChannelConversation]:
"""Conversation row for a session — the outbound dispatcher's lookup."""
try:
if isinstance(session_id, str):
session_id = UUID(session_id)
return (
self.db.query(ChannelConversation)
.filter(ChannelConversation.session_id == session_id)
.order_by(ChannelConversation.created_at.desc())
.first()
)
except Exception as e:
logger.error(f"Error getting channel conversation by session: {str(e)}")
return None
def get_latest(self, channel_account_id: UUID, external_conversation_id: str) -> Optional[ChannelConversation]:
"""Most recent conversation row regardless of session status — used for
interactions (rating taps, feedback) that arrive after the chat closed."""
try:
return (
self.db.query(ChannelConversation)
.filter(
ChannelConversation.channel_account_id == channel_account_id,
ChannelConversation.external_conversation_id == external_conversation_id,
)
.order_by(ChannelConversation.created_at.desc())
.first()
)
except Exception as e:
logger.error(f"Error getting latest channel conversation: {str(e)}")
return None
def merge_extra(self, conversation: ChannelConversation, updates: dict,
remove: Iterable[str] = ()) -> Optional[dict]:
"""Set and drop keys in the conversation's extra JSON (per-conversation
state such as email threading headers). Re-reads the row under a lock
first, so an inbound and a send updating different keys at once can't
overwrite each other's. Returns the extra as it was just before this
update (None on failure) — what a caller checks to claim a flag."""
try:
# Anything pending on the instance would be discarded by the re-read
self.db.flush()
current = (
self.db.query(ChannelConversation)
.filter(ChannelConversation.id == conversation.id)
.with_for_update()
.populate_existing()
.one()
)
previous = dict(current.extra or {})
extra = {**previous, **updates}
for key in remove:
extra.pop(key, None)
current.extra = extra
self.db.commit()
return previous
except Exception as e:
logger.error(f"Error updating conversation extra: {str(e)}")
self.db.rollback()
return None
# Sentinel: "created in response to an inbound message" (the default).
# None is a meaningful value — an outbound-started conversation has no
# inbound yet, so no 24h delivery window is open — so it can't be the default.
_INBOUND_NOW = object()
def create(
self,
channel_account_id: UUID,
channel_type: str,
external_conversation_id: str,
external_user_id: str,
session_id: UUID,
organization_id: UUID,
agent_id: Optional[UUID] = None,
customer_id: Optional[UUID] = None,
extra: Optional[dict] = None,
last_inbound_at: Optional[datetime] = _INBOUND_NOW,
) -> ChannelConversation:
try:
if last_inbound_at is self._INBOUND_NOW:
last_inbound_at = datetime.now(timezone.utc)
conversation = ChannelConversation(
channel_account_id=channel_account_id,
channel_type=channel_type,
external_conversation_id=external_conversation_id,
external_user_id=external_user_id,
session_id=session_id,
organization_id=organization_id,
agent_id=agent_id,
customer_id=customer_id,
last_inbound_at=last_inbound_at,
extra=extra or {},
)
self.db.add(conversation)
self.db.commit()
self.db.refresh(conversation)
return conversation
except Exception as e:
logger.error(f"Error creating channel conversation: {str(e)}")
self.db.rollback()
raise
def touch_inbound(self, conversation: ChannelConversation) -> None:
"""Record a fresh customer message; drives delivery-window checks."""
try:
conversation.last_inbound_at = datetime.now(timezone.utc)
self.db.commit()
except Exception as e:
logger.error(f"Error updating conversation inbound timestamp: {str(e)}")
self.db.rollback()
+714
View File
@@ -0,0 +1,714 @@
"""
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.orm import Session
from typing import Any, Dict, Iterable, List, Optional
from app.models.channels import ChannelConversation
from app.models.chat_history import ChatHistory
from app.models.customer import Customer
from uuid import UUID
from sqlalchemy import func, or_, select, text, and_
from sqlalchemy.sql import case
from app.models.agent import Agent
from app.models.session_to_agent import SessionToAgent
from app.repositories.channels import ChannelConversationRepository
from app.core.logger import get_logger
from app.channels.constants import is_widget_channel, MessageSource
from app.channels.sent_choices import SENT_CHOICES_ATTRIBUTE, decode_sent_choices
from app.models.user import User
from app.repositories.customer import CustomerRepository
from sqlalchemy.orm import joinedload
from sqlalchemy.orm.attributes import set_committed_value
from datetime import datetime
from pydantic import BaseModel
from app.services.file_storage import resolve_public_url
from app.services.sentiment import analyze_sentiment, compute_session_sentiment
logger = get_logger(__name__)
def _readable_attributes(attributes: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""A message's attributes for the inbox: the encrypted record of the
choices it was sent with, decrypted."""
if not attributes or SENT_CHOICES_ATTRIBUTE not in attributes:
return attributes
return {**attributes,
SENT_CHOICES_ATTRIBUTE: decode_sent_choices(attributes[SENT_CHOICES_ATTRIBUTE])}
class ChatRepository:
def __init__(self, db: Session):
self.db = db
def has_customer_messages(self, session_id: UUID | str) -> bool:
"""Whether the customer has said anything in this session yet.
The widget opens its session on socket connect, before the visitor
types, so this is what distinguishes a real new chat from someone
merely opening the chat window.
"""
if isinstance(session_id, str):
session_id = UUID(session_id)
return self.db.query(ChatHistory.id).filter(
ChatHistory.session_id == session_id,
ChatHistory.message_type == 'user'
).first() is not None
def get_message_count_for_period(
self,
org_id: UUID | str,
start_date: datetime,
end_date: datetime
) -> int:
"""
Get the total number of bot messages for an organization within a specific date range.
Only counts messages with message_type='bot', and not ones an external
app sent (attributes.source == 'app'): they cost no AI.
Args:
org_id: Organization ID
start_date: Start date of the period
end_date: End date of the period
Returns:
int: Total number of bot messages in the period
"""
if isinstance(org_id, str):
org_id = UUID(org_id)
try:
# Build filter conditions
source = ChatHistory.attributes['source'].as_string()
conditions = [
ChatHistory.organization_id == org_id,
ChatHistory.message_type == 'bot', # Only count bot messages
# NULL-safe: nearly every row has no source at all
or_(source.is_(None), source != MessageSource.APP.value),
]
# Add date range conditions if they are not None
if start_date is not None:
conditions.append(ChatHistory.created_at >= start_date)
if end_date is not None:
conditions.append(ChatHistory.created_at <= end_date)
count = self.db.query(func.count(ChatHistory.id))\
.filter(and_(*conditions))\
.scalar()
return count or 0
except Exception as e:
logger.error(f"Error getting message count: {str(e)}")
return 0
def create_message(self, message_data: Dict[str, Any]) -> ChatHistory:
"""Create a new chat message."""
try:
# Convert any Pydantic models in attributes to dict
if 'attributes' in message_data:
attributes = message_data['attributes']
if 'shopify_output' in attributes and attributes['shopify_output'] is not None:
if isinstance(attributes['shopify_output'], BaseModel):
attributes['shopify_output'] = attributes['shopify_output'].dict()
elif isinstance(attributes['shopify_output'], dict):
# If it's already a dict, ensure all nested objects are serialized
if 'products' in attributes['shopify_output']:
products = attributes['shopify_output']['products']
attributes['shopify_output']['products'] = [
p.dict() if isinstance(p, BaseModel) else p
for p in products
]
# Convert string UUIDs to UUID objects
for field in ['organization_id', 'user_id', 'customer_id', 'agent_id', 'session_id']:
if field in message_data and message_data[field] is not None:
if isinstance(message_data[field], str):
message_data[field] = UUID(message_data[field])
# Analyze sentiment for customer messages ('user' type)
if message_data.get('message_type') == 'user' and message_data.get('message'):
try:
label, score = analyze_sentiment(message_data['message'])
message_data['sentiment_label'] = label
message_data['sentiment_score'] = score
except Exception as e:
logger.error(f"Error analyzing message sentiment: {str(e)}")
message = ChatHistory(**message_data)
self.db.add(message)
self.db.commit()
self.db.refresh(message)
# Update session-level sentiment after saving a customer message
if message_data.get('message_type') == 'user' and message_data.get('session_id'):
try:
self._update_session_sentiment(message_data['session_id'])
except Exception as e:
logger.error(f"Error updating session sentiment: {str(e)}")
return message
except Exception as e:
logger.error(f"Error creating message: {str(e)}")
self.db.rollback()
raise
def update_message_attributes(self, message_id: int, attributes: Dict[str, Any]) -> None:
"""Merge keys into a message's attributes JSON (e.g. delivery_status)."""
try:
message = self.db.query(ChatHistory).filter(ChatHistory.id == message_id).first()
if not message:
return
message.attributes = {**(message.attributes or {}), **attributes}
self.db.commit()
except Exception as e:
logger.error(f"Error updating message attributes: {str(e)}")
self.db.rollback()
def mark_delivery_failed(self, message_id: int, reason: Optional[str]) -> None:
"""Flag a stored message as never delivered to the customer.
Sole writer of delivery_status, which therefore only ever holds a
failure reason — the inbox treats its presence as "not delivered".
"""
self.update_message_attributes(message_id, {'delivery_status': reason or 'failed'})
def set_external_message_id(self, message_id: int, external_message_id: str) -> Optional[ChatHistory]:
"""Record the platform's id for a message we sent, so its delivery
receipts and taps on its choices can find it. Returns the row, or None
if it is gone. Some platforms (Telegram) number messages per chat:
look those up within the chat (in_channel_chat_by_external_message_id),
never organization-wide."""
try:
message = self.db.query(ChatHistory).filter(ChatHistory.id == message_id).first()
if message is None:
return None
message.external_message_id = external_message_id
self.db.commit()
return message
except Exception as e:
logger.error(f"Error setting external message id: {str(e)}")
self.db.rollback()
return None
def get_by_external_message_id(self, organization_id: UUID | str,
external_message_id: str) -> Optional[ChatHistory]:
"""The message a platform id belongs to, within one organization —
a receipt can never touch another tenant's row."""
if isinstance(organization_id, str):
organization_id = UUID(organization_id)
return (
self.db.query(ChatHistory)
.filter(
ChatHistory.organization_id == organization_id,
ChatHistory.external_message_id == external_message_id,
)
.first()
)
def in_channel_chat_by_external_message_id(self, channel_account_id: UUID, external_conversation_id: str,
external_message_id: str) -> Optional[ChatHistory]:
"""The message a platform id belongs to, within one customer's chat on
one channel account — across its sessions (a chat that closed and
reopened is a new session), and never another chat's: some platforms
number messages per chat, not globally."""
return (
self._in_channel_chat(channel_account_id, external_conversation_id)
.filter(ChatHistory.external_message_id == external_message_id)
.first()
)
def latest_replies_in_channel_chat(self, channel_account_id: UUID, external_conversation_id: str,
limit: int) -> List[ChatHistory]:
"""That chat's most recent replies to the customer, the AI's or a
person's, newest first."""
return (
self._in_channel_chat(channel_account_id, external_conversation_id)
.filter(ChatHistory.message_type.in_(('bot', 'agent')))
.order_by(ChatHistory.created_at.desc(), ChatHistory.id.desc())
.limit(limit)
.all()
)
def _in_channel_chat(self, channel_account_id: UUID, external_conversation_id: str):
return (
self.db.query(ChatHistory)
.join(ChannelConversation, ChannelConversation.session_id == ChatHistory.session_id)
.filter(ChannelConversation.channel_account_id == channel_account_id,
ChannelConversation.external_conversation_id == external_conversation_id)
)
def advance_channel_status(self, message_id: int, status: str, at: datetime,
replaceable: Iterable[str]) -> bool:
"""Set a message's delivery receipt only if it currently has none or
one of `replaceable` — a single conditional UPDATE, so two receipts
racing on different workers can't overwrite a newer one with an older.
Returns whether the row changed."""
try:
changed = (
self.db.query(ChatHistory)
.filter(
ChatHistory.id == message_id,
or_(ChatHistory.channel_status.is_(None),
ChatHistory.channel_status.in_(list(replaceable))),
)
.update({ChatHistory.channel_status: status,
ChatHistory.channel_status_at: at},
synchronize_session=False)
)
self.db.commit()
return changed > 0
except Exception as e:
logger.error(f"Error setting channel status: {str(e)}")
self.db.rollback()
return False
def _channel_account_id(self, channel: Optional[str], session_id) -> Optional[str]:
"""Which connected account a channel conversation belongs to, so the
inbox can call account-scoped endpoints (e.g. sending a template).
Looked up separately rather than joined into the chat-detail query:
session_id is not unique on channel_conversations, so an outer join
would multiply that query's groups. Only fires for channel sessions,
and get_chat_detail fetches one session at a time.
"""
if is_widget_channel(channel):
return None
conversation = ChannelConversationRepository(self.db).get_by_session(session_id)
return str(conversation.channel_account_id) if conversation else None
def _update_session_sentiment(self, session_id) -> None:
"""Recompute and update the overall session sentiment from customer messages."""
try:
if isinstance(session_id, str):
session_id = UUID(session_id)
# Get all customer message sentiments for this session
messages = self.db.query(
ChatHistory.sentiment_score,
ChatHistory.sentiment_label
).filter(
ChatHistory.session_id == session_id,
ChatHistory.message_type == 'user',
ChatHistory.sentiment_score.isnot(None)
).all()
scores = [m.sentiment_score for m in messages]
labels = [m.sentiment_label for m in messages]
overall_label, overall_score = compute_session_sentiment(scores, labels)
if overall_label is not None:
session = self.db.query(SessionToAgent).filter(
SessionToAgent.session_id == session_id
).first()
if session:
session.sentiment_label = overall_label
session.sentiment_score = overall_score
self.db.commit()
except Exception as e:
logger.error(f"Error updating session sentiment: {str(e)}")
self.db.rollback()
async def get_session_history(self, session_id: str | UUID) -> List[ChatHistory]:
"""Get chat history for a session with joined relationships"""
if isinstance(session_id, str):
session_id = UUID(session_id)
messages = (
self.db.query(ChatHistory)
.options(
joinedload(ChatHistory.user),
joinedload(ChatHistory.agent),
joinedload(ChatHistory.attachments)
)
.filter(ChatHistory.session_id == session_id)
# id breaks ties — see get_last_messages.
.order_by(ChatHistory.created_at.asc(), ChatHistory.id.asc())
.all()
)
# Hand out signed URLs. set_committed_value, not assignment: a plain
# assignment marks the row dirty, and the next commit on this session
# would store a link that expires in place of the file's real path.
for message in messages:
for attachment in message.attachments or []:
if attachment.file_url:
set_committed_value(
attachment, "file_url", await resolve_public_url(attachment.file_url)
)
return messages
def get_last_messages(self, session_ids: List[UUID]) -> Dict[UUID, str]:
"""Map each session to its most recent message, decrypted.
One query for the whole page rather than one per session, and the single
definition of "most recent" — created_at is transaction time, so messages
written together share it exactly and id has to break the tie.
"""
if not session_ids:
return {}
ranked = (
select(
ChatHistory.session_id,
ChatHistory.message,
func.row_number().over(
partition_by=ChatHistory.session_id,
order_by=(ChatHistory.created_at.desc(), ChatHistory.id.desc()),
).label('rank')
)
.where(ChatHistory.session_id.in_(session_ids))
.subquery()
)
rows = self.db.execute(
select(ranked.c.session_id, ranked.c.message).where(ranked.c.rank == 1)
).all()
return {session_id: message for session_id, message in rows}
def get_user_history(self, user_id: str | UUID) -> List[ChatHistory]:
"""Get chat history for a user"""
if isinstance(user_id, str):
user_id = UUID(user_id)
return self.db.query(ChatHistory).filter(
ChatHistory.user_id == user_id
).order_by(ChatHistory.created_at.desc()).all()
def get_recent_chats(
self,
skip: int = 0,
limit: int = 20,
agent_id: Optional[str | UUID] = None,
status: Optional[str] = None,
user_id: Optional[str | UUID] = None,
user_groups: Optional[List[str]] = None,
include_unassigned: bool = False,
organization_id: Optional[str | UUID] = None,
user_name: Optional[str] = None,
filter_user_id: Optional[str | UUID] = None,
customer_email: Optional[str] = None,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None
) -> List[dict]:
"""Get recent chat overviews grouped by conversation"""
# Convert string IDs to UUID if needed
if agent_id and isinstance(agent_id, str):
agent_id = UUID(agent_id)
if user_id and isinstance(user_id, str):
user_id = UUID(user_id)
if organization_id and isinstance(organization_id, str):
organization_id = UUID(organization_id)
if user_groups:
user_groups = [UUID(g) if isinstance(g, str) else g for g in user_groups]
query = self.db.query(
Customer.id.label('customer_id'),
Customer.email.label('customer_email'),
Customer.full_name.label('customer_full_name'),
Agent.id.label('agent_id'),
Agent.name.label('agent_name'),
Agent.display_name.label('agent_display_name'),
Agent.ai_replies_enabled.label('agent_ai_replies_enabled'),
SessionToAgent.status.label('status'),
SessionToAgent.channel.label('channel'),
SessionToAgent.group_id.label('group_id'),
# Who is handling the chat. Without these the list cannot tell an
# AI-handled conversation from one a human has taken over — the
# detail view has always known, the overview never did.
SessionToAgent.user_id.label('user_id'),
User.full_name.label('user_name'),
func.max(ChatHistory.created_at).label('updated_at'),
func.count(ChatHistory.id).label('message_count'),
SessionToAgent.session_id.label('session_id')
).join(
Agent, ChatHistory.agent_id == Agent.id
).join(
Customer, ChatHistory.customer_id == Customer.id
).join(
SessionToAgent, ChatHistory.session_id == SessionToAgent.session_id
).outerjoin(
User, SessionToAgent.user_id == User.id
)
# Filter conditions
if agent_id:
query = query.filter(Agent.id == agent_id)
# Filter by status if provided
if status and status != 'all':
# Handle comma-separated status values
if ',' in status:
status_values = [s.strip() for s in status.split(',')]
query = query.filter(SessionToAgent.status.in_(status_values))
else:
query = query.filter(SessionToAgent.status == status)
# Filter by organization
if organization_id:
query = query.filter(SessionToAgent.organization_id == organization_id)
# Filter by user name
if user_name:
query = query.filter(User.full_name.ilike(f'%{user_name}%'))
# Filter by specific user ID (for agent dropdown)
if filter_user_id:
if isinstance(filter_user_id, str):
filter_user_id = UUID(filter_user_id)
query = query.filter(SessionToAgent.user_id == filter_user_id)
# Filter by customer email
if customer_email:
query = query.filter(Customer.email.ilike(f'%{customer_email}%'))
# Filter by date range
if date_from:
query = query.filter(ChatHistory.created_at >= date_from)
if date_to:
query = query.filter(ChatHistory.created_at <= date_to)
# Scope to what this user may see: their own sessions, their groups'
# queue, and — with view_unassigned_chats — the unclaimed AI queue.
# Callers who may see everything pass none of these.
visibility = []
if user_id:
visibility.append(SessionToAgent.user_id == user_id)
if user_groups:
visibility.append(SessionToAgent.group_id.in_(user_groups))
if include_unassigned:
# Nobody has taken it yet — the AI is still handling it. group_id
# is irrelevant: a chat transferred to another group is still
# claimable, and it is exactly what "unassigned" means.
visibility.append(SessionToAgent.user_id.is_(None))
if visibility:
query = query.filter(or_(*visibility))
# Group by and order
query = query.group_by(
Customer.id,
Customer.email,
Customer.full_name,
Agent.id,
Agent.name,
Agent.display_name,
Agent.ai_replies_enabled,
SessionToAgent.status,
SessionToAgent.channel,
SessionToAgent.group_id,
SessionToAgent.user_id,
User.full_name,
SessionToAgent.session_id
).order_by(
# Create a custom ordering to prioritize transferred conversations
# Using direct SQL expression for the CASE statement with uppercase status values
text("CASE WHEN session_to_agents.status = 'TRANSFERRED' THEN 0 "
"WHEN session_to_agents.status = 'OPEN' THEN 1 "
"ELSE 2 END"),
# Then order by most recent activity
func.max(ChatHistory.created_at).desc()
).offset(skip).limit(limit)
results = query.all()
# Previews are fetched for the returned page only. Inlining them as a
# correlated subquery would evaluate once per session in the org, since the
# LIMIT applies after grouping.
last_messages = self.get_last_messages([r.session_id for r in results])
return [{
'customer': {
'id': r.customer_id,
'email': CustomerRepository.display_email(r.customer_email),
'full_name': r.customer_full_name
},
'agent': {
'id': r.agent_id,
'name': r.agent_name,
'display_name': r.agent_display_name,
'ai_replies_enabled': r.agent_ai_replies_enabled
},
'last_message': last_messages.get(r.session_id),
'updated_at': r.updated_at,
'message_count': r.message_count,
'status': r.status,
'channel': r.channel,
'group_id': str(r.group_id) if r.group_id else None,
'user_id': r.user_id,
'user_name': r.user_name,
'session_id': r.session_id
} for r in results]
async def check_session_access(
self,
session_id: str | UUID,
user_id: Optional[str | UUID],
user_groups: Optional[List[str]],
include_unassigned: bool = False
) -> bool:
"""Check if user has access to a chat session.
Mirrors the visibility filter in get_recent_chats — the two must agree,
or a session shows in the list and 404s when opened. A None user_id
means "this caller has no assigned-chat grant"; it must never be
compared against session.user_id, since both being NULL would silently
hand over every unclaimed session.
"""
if isinstance(session_id, str):
session_id = UUID(session_id)
if isinstance(user_id, str):
user_id = UUID(user_id)
session = (
self.db.query(SessionToAgent)
.filter(SessionToAgent.session_id == session_id)
.first()
)
if not session:
return False
if include_unassigned and session.user_id is None:
return True
if user_id is not None and session.user_id == user_id:
return True
return bool(
session.group_id and user_groups and str(session.group_id) in user_groups
)
async def get_chat_detail(
self,
session_id: str | UUID,
org_id: str | UUID
) -> Optional[dict]:
"""Get detailed chat information for a session"""
if isinstance(session_id, str):
session_id = UUID(session_id)
if isinstance(org_id, str):
org_id = UUID(org_id)
result = (
self.db.query(
Customer.id.label('customer_id'),
Customer.email.label('customer_email'),
Customer.full_name.label('customer_full_name'),
Customer.meta_data.label('customer_meta_data'),
Agent.id.label('agent_id'),
Agent.name.label('agent_name'),
Agent.display_name.label('agent_display_name'),
Agent.ai_replies_enabled.label('agent_ai_replies_enabled'),
SessionToAgent.status.label('status'),
SessionToAgent.channel.label('channel'),
SessionToAgent.group_id.label('group_id'),
SessionToAgent.session_id.label('session_id'),
SessionToAgent.user_id.label('user_id'),
User.full_name.label('user_name'),
func.min(ChatHistory.created_at).label('created_at'),
func.max(ChatHistory.created_at).label('updated_at')
)
.join(Agent, ChatHistory.agent_id == Agent.id)
.join(Customer, ChatHistory.customer_id == Customer.id)
.join(SessionToAgent, ChatHistory.session_id == SessionToAgent.session_id)
.outerjoin(User, SessionToAgent.user_id == User.id)
.filter(
ChatHistory.session_id == session_id,
SessionToAgent.organization_id == org_id
)
.group_by(
# Customer.meta_data is deliberately NOT grouped: Postgres' `json` type
# has no equality operator, so grouping by it raises "could not identify
# an equality operator for type json". Customer.id (the PK) is already
# grouped, so Postgres' functional-dependency rule lets other columns of
# the same table (email, full_name, meta_data) be selected ungrouped.
Customer.id,
Customer.email,
Customer.full_name,
Agent.id,
Agent.name,
Agent.display_name,
Agent.ai_replies_enabled,
SessionToAgent.status,
SessionToAgent.channel,
SessionToAgent.group_id,
SessionToAgent.session_id,
SessionToAgent.user_id,
User.full_name
)
.first()
)
if not result:
return None
# Get messages for the session
messages = await self.get_session_history(session_id)
# Build messages list with attachments
messages_list = []
for msg in messages:
msg_dict = {
'id': msg.id,
'message': msg.message,
'message_type': msg.message_type,
'created_at': msg.created_at,
'attributes': _readable_attributes(msg.attributes),
# Live delivery receipts are matched to the message by either
'external_message_id': msg.external_message_id,
'channel_status': msg.channel_status,
}
# Add attachments with file info if they exist
if msg.attachments:
attachments = []
for attachment in msg.attachments:
att_dict = {
'id': attachment.id,
'filename': attachment.filename,
'file_url': attachment.file_url,
'content_type': attachment.content_type,
'file_size': attachment.file_size
}
attachments.append(att_dict)
msg_dict['attachments'] = attachments
messages_list.append(msg_dict)
# Convert result to dict
return {
'customer': {
'id': result.customer_id,
'email': CustomerRepository.display_email(result.customer_email),
'full_name': result.customer_full_name,
'meta_data': result.customer_meta_data
},
'agent': {
'id': result.agent_id,
'name': result.agent_name,
'display_name': result.agent_display_name,
'ai_replies_enabled': result.agent_ai_replies_enabled
},
'status': result.status,
'channel': result.channel,
'channel_account_id': self._channel_account_id(result.channel, result.session_id),
'group_id': str(result.group_id) if result.group_id else None,
'session_id': result.session_id,
'user_id': result.user_id,
'user_name': result.user_name,
'created_at': result.created_at,
'updated_at': result.updated_at,
'messages': messages_list
}
+428
View File
@@ -0,0 +1,428 @@
"""
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 json
from datetime import datetime, timedelta, timezone
from typing import Optional, List
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.models.crm import (
CrmConnection, CrmConnectionStatus, CrmSyncJob, CrmSyncJobStatus, CrmCustomerSync,
)
from app.core.security import encrypt_api_key, decrypt_api_key
from app.core.logger import get_logger
from app.utils.backoff import next_attempt_delay
logger = get_logger(__name__)
# Retry policy: exponential backoff capped at an hour, terminal after MAX_ATTEMPTS.
MAX_ATTEMPTS = 7
BASE_BACKOFF_SECONDS = 60
MAX_BACKOFF_SECONDS = 3600
BACKOFF_JITTER_SECONDS = 30
# A job stuck in `processing` longer than this is presumed orphaned by a worker
# crash and is reclaimed.
STALE_CLAIM_MINUTES = 10
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class CrmConnectionRepository:
"""CRUD for CRM OAuth connections. Credentials are always stored
Fernet-encrypted; use get_credentials()/save_credentials() so plaintext
tokens never touch the table."""
def __init__(self, db: Session):
self.db = db
def get_by_org_provider(self, organization_id: UUID, provider: str) -> Optional[CrmConnection]:
return self.db.query(CrmConnection).filter(
CrmConnection.organization_id == organization_id,
CrmConnection.provider == provider,
).first()
def get_active(self, organization_id: UUID, provider: str) -> Optional[CrmConnection]:
return self.db.query(CrmConnection).filter(
CrmConnection.organization_id == organization_id,
CrmConnection.provider == provider,
CrmConnection.status == CrmConnectionStatus.ACTIVE.value,
).first()
def get_by_external_id(self, provider: str, external_account_id: str) -> Optional[CrmConnection]:
"""Resolve which org a CRM portal is connected to (one portal ↔ one org)."""
return self.db.query(CrmConnection).filter(
CrmConnection.provider == provider,
CrmConnection.external_account_id == external_account_id,
).first()
def list_by_org(self, organization_id: UUID) -> List[CrmConnection]:
return self.db.query(CrmConnection).filter(
CrmConnection.organization_id == organization_id
).order_by(CrmConnection.created_at).all()
def create_or_update(
self,
organization_id: UUID,
provider: str,
external_account_id: str,
credentials: dict,
display_name: Optional[str] = None,
access_token_expires_at: Optional[datetime] = None,
refresh_token_expires_at: Optional[datetime] = None,
connected_by_user_id: Optional[UUID] = None,
) -> CrmConnection:
"""Upsert the org's connection for a provider (OAuth callback path).
A reconnect replaces credentials, reactivates the row and clears any
stored error.
"""
try:
connection = self.get_by_org_provider(organization_id, provider)
if connection is None:
connection = CrmConnection(organization_id=organization_id, provider=provider)
self.db.add(connection)
connection.external_account_id = external_account_id
connection.display_name = display_name
connection.encrypted_credentials = encrypt_api_key(json.dumps(credentials))
connection.status = CrmConnectionStatus.ACTIVE.value
connection.last_error = None
connection.access_token_expires_at = access_token_expires_at
connection.refresh_token_expires_at = refresh_token_expires_at
connection.last_refreshed_at = _utcnow()
if connected_by_user_id is not None:
connection.connected_by_user_id = connected_by_user_id
self.db.commit()
self.db.refresh(connection)
logger.info(f"Connected {provider} CRM for org {organization_id}")
return connection
except Exception as e:
logger.error(f"Error saving CRM connection: {str(e)}")
self.db.rollback()
raise
def get_credentials(self, connection: CrmConnection) -> dict:
"""Decrypt a connection's credential blob."""
return json.loads(decrypt_api_key(connection.encrypted_credentials))
def save_credentials(
self,
connection: CrmConnection,
credentials: dict,
access_token_expires_at: Optional[datetime] = None,
refresh_token_expires_at: Optional[datetime] = None,
) -> CrmConnection:
"""Persist refreshed tokens, mirroring expiries so sweeps never decrypt."""
try:
connection.encrypted_credentials = encrypt_api_key(json.dumps(credentials))
connection.access_token_expires_at = access_token_expires_at
if refresh_token_expires_at is not None:
connection.refresh_token_expires_at = refresh_token_expires_at
connection.last_refreshed_at = _utcnow()
self.db.commit()
self.db.refresh(connection)
return connection
except Exception as e:
logger.error(f"Error updating CRM credentials: {str(e)}")
self.db.rollback()
raise
def set_status(self, connection: CrmConnection, status: str,
last_error: Optional[str] = None) -> CrmConnection:
try:
connection.status = status
connection.last_error = last_error
self.db.commit()
self.db.refresh(connection)
return connection
except Exception as e:
logger.error(f"Error updating CRM connection status: {str(e)}")
self.db.rollback()
raise
def list_needing_refresh(self, provider: str, not_refreshed_since: datetime) -> List[CrmConnection]:
"""Active connections whose tokens haven't been refreshed recently —
the proactive sweep that keeps Pipedrive's sliding refresh window alive."""
return self.db.query(CrmConnection).filter(
CrmConnection.provider == provider,
CrmConnection.status == CrmConnectionStatus.ACTIVE.value,
(CrmConnection.last_refreshed_at.is_(None)) |
(CrmConnection.last_refreshed_at < not_refreshed_since),
).all()
def delete(self, connection: CrmConnection) -> bool:
try:
self.db.delete(connection)
self.db.commit()
return True
except Exception as e:
logger.error(f"Error deleting CRM connection: {str(e)}")
self.db.rollback()
return False
class CrmSyncJobRepository:
"""The lead-push queue. One job per (lead, provider), claimed with
SKIP LOCKED so multiple workers never double-process."""
def __init__(self, db: Session):
self.db = db
def enqueue(
self,
organization_id: UUID,
lead_response_id: UUID,
provider: str,
agent_id: Optional[UUID] = None,
) -> Optional[CrmSyncJob]:
"""Insert a job; returns None if one already exists for this lead+provider.
The uq_crm_sync_job_lead_provider constraint enforces idempotency —
a concurrent duplicate surfaces as IntegrityError, not a second job.
"""
job = CrmSyncJob(
organization_id=organization_id,
lead_response_id=lead_response_id,
provider=provider,
agent_id=agent_id,
)
try:
self.db.add(job)
self.db.commit()
self.db.refresh(job)
return job
except IntegrityError:
self.db.rollback()
return None
except Exception as e:
logger.error(f"Error enqueuing CRM sync job: {str(e)}")
self.db.rollback()
raise
def claim_batch(self, limit: int = 20) -> List[CrmSyncJob]:
"""Atomically claim due pending jobs (FOR UPDATE SKIP LOCKED)."""
try:
now = _utcnow()
ids = self.db.execute(
select(CrmSyncJob.id)
.where(
CrmSyncJob.status == CrmSyncJobStatus.PENDING.value,
CrmSyncJob.next_attempt_at <= now,
)
.order_by(CrmSyncJob.next_attempt_at)
.limit(limit)
.with_for_update(skip_locked=True)
).scalars().all()
if not ids:
self.db.commit()
return []
self.db.query(CrmSyncJob).filter(CrmSyncJob.id.in_(ids)).update(
{CrmSyncJob.status: CrmSyncJobStatus.PROCESSING.value,
CrmSyncJob.started_at: now},
synchronize_session=False,
)
self.db.commit()
return (self.db.query(CrmSyncJob)
.filter(CrmSyncJob.id.in_(ids))
.order_by(CrmSyncJob.next_attempt_at)
.all())
except Exception as e:
logger.error(f"Error claiming CRM sync jobs: {str(e)}")
self.db.rollback()
return []
def reclaim_stale(self, older_than_minutes: int = STALE_CLAIM_MINUTES) -> int:
"""Return crashed-worker claims to the queue."""
try:
cutoff = _utcnow() - timedelta(minutes=older_than_minutes)
count = self.db.query(CrmSyncJob).filter(
CrmSyncJob.status == CrmSyncJobStatus.PROCESSING.value,
CrmSyncJob.started_at < cutoff,
).update(
{CrmSyncJob.status: CrmSyncJobStatus.PENDING.value,
CrmSyncJob.started_at: None},
synchronize_session=False,
)
self.db.commit()
if count:
logger.warning(f"Reclaimed {count} stale CRM sync job(s)")
return count
except Exception as e:
logger.error(f"Error reclaiming stale CRM sync jobs: {str(e)}")
self.db.rollback()
return 0
def complete(self, job: CrmSyncJob, result: dict,
connection_id: Optional[UUID] = None) -> CrmSyncJob:
return self._finish(job, CrmSyncJobStatus.COMPLETED.value,
result=result, connection_id=connection_id)
def fail(self, job: CrmSyncJob, error: str,
connection_id: Optional[UUID] = None) -> CrmSyncJob:
return self._finish(job, CrmSyncJobStatus.FAILED.value,
error=error, connection_id=connection_id)
def skip(self, job: CrmSyncJob, reason: str) -> CrmSyncJob:
return self._finish(job, CrmSyncJobStatus.SKIPPED.value, error=reason)
def schedule_retry(self, job: CrmSyncJob, error: str,
retry_after_seconds: Optional[int] = None) -> CrmSyncJob:
"""Back the job off exponentially; terminal failure after MAX_ATTEMPTS.
A provider-supplied Retry-After wins over the computed backoff when longer.
"""
try:
job.attempts += 1
job.last_error = error
if job.attempts >= MAX_ATTEMPTS:
job.status = CrmSyncJobStatus.FAILED.value
else:
delay = next_attempt_delay(
job.attempts, base_seconds=BASE_BACKOFF_SECONDS,
max_seconds=MAX_BACKOFF_SECONDS, jitter_seconds=BACKOFF_JITTER_SECONDS,
retry_after_seconds=retry_after_seconds)
job.status = CrmSyncJobStatus.PENDING.value
job.started_at = None
job.next_attempt_at = _utcnow() + timedelta(seconds=delay)
self.db.commit()
self.db.refresh(job)
return job
except Exception as e:
logger.error(f"Error scheduling CRM sync retry: {str(e)}")
self.db.rollback()
raise
def skip_pending_for_connection(self, organization_id: UUID, provider: str,
reason: str) -> int:
"""Cancel queued work when a connection dies (disconnect/uninstall)."""
try:
count = self.db.query(CrmSyncJob).filter(
CrmSyncJob.organization_id == organization_id,
CrmSyncJob.provider == provider,
CrmSyncJob.status == CrmSyncJobStatus.PENDING.value,
).update(
{CrmSyncJob.status: CrmSyncJobStatus.SKIPPED.value,
CrmSyncJob.last_error: reason},
synchronize_session=False,
)
self.db.commit()
return count
except Exception as e:
logger.error(f"Error skipping pending CRM sync jobs: {str(e)}")
self.db.rollback()
return 0
def reopen_failed(self, organization_id: UUID, provider: str) -> int:
"""Requeue terminally-failed jobs after a reconnect. The unique
(lead_response_id, provider) constraint blocks re-enqueue, so without
this a lead that failed while the connection was expired would never
reach the CRM even after the admin reconnects."""
try:
count = self.db.query(CrmSyncJob).filter(
CrmSyncJob.organization_id == organization_id,
CrmSyncJob.provider == provider,
CrmSyncJob.status == CrmSyncJobStatus.FAILED.value,
).update(
{CrmSyncJob.status: CrmSyncJobStatus.PENDING.value,
CrmSyncJob.attempts: 0,
CrmSyncJob.started_at: None,
CrmSyncJob.next_attempt_at: _utcnow()},
synchronize_session=False,
)
self.db.commit()
if count:
logger.info(f"Reopened {count} failed {provider} sync job(s) for "
f"org {organization_id} after reconnect")
return count
except Exception as e:
logger.error(f"Error reopening failed CRM sync jobs: {str(e)}")
self.db.rollback()
return 0
def count_recent_failures(self, organization_id: UUID, provider: str,
days: int = 7) -> int:
cutoff = _utcnow() - timedelta(days=days)
return self.db.query(CrmSyncJob).filter(
CrmSyncJob.organization_id == organization_id,
CrmSyncJob.provider == provider,
CrmSyncJob.status == CrmSyncJobStatus.FAILED.value,
CrmSyncJob.updated_at >= cutoff,
).count()
def _finish(self, job: CrmSyncJob, status: str, result: Optional[dict] = None,
error: Optional[str] = None,
connection_id: Optional[UUID] = None) -> CrmSyncJob:
try:
job.status = status
if result is not None:
job.result = result
if error is not None:
job.last_error = error
if connection_id is not None:
job.connection_id = connection_id
self.db.commit()
self.db.refresh(job)
return job
except Exception as e:
logger.error(f"Error finishing CRM sync job: {str(e)}")
self.db.rollback()
raise
class CrmCustomerSyncRepository:
"""The per-person CRM link shown on the People drawer (one row per
customer + provider); upserted on every successful push."""
def __init__(self, db: Session):
self.db = db
def list_for_customer(self, customer_id: UUID) -> List[CrmCustomerSync]:
return self.db.query(CrmCustomerSync).filter(
CrmCustomerSync.customer_id == customer_id
).all()
def record(self, organization_id: UUID, customer_id: UUID, provider: str,
contact_id: Optional[str] = None, secondary_id: Optional[str] = None,
record_url: Optional[str] = None) -> CrmCustomerSync:
"""Upsert the customer's link for a provider after a successful push."""
try:
row = self.db.query(CrmCustomerSync).filter(
CrmCustomerSync.customer_id == customer_id,
CrmCustomerSync.provider == provider,
).first()
if row is None:
row = CrmCustomerSync(
organization_id=organization_id,
customer_id=customer_id,
provider=provider,
)
self.db.add(row)
row.contact_id = contact_id
row.secondary_id = secondary_id
row.record_url = record_url
row.synced_at = _utcnow()
self.db.commit()
self.db.refresh(row)
return row
except Exception as e:
logger.error(f"Error recording CRM customer sync: {str(e)}")
self.db.rollback()
raise
+488
View File
@@ -0,0 +1,488 @@
"""
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.exc import IntegrityError
from sqlalchemy.orm import Session
from app.utils.uuids import as_uuid
from app.models.customer import Customer
from app.utils.phone import normalize_phone
from uuid import UUID, uuid4
from app.core.logger import get_logger
logger = get_logger(__name__)
# Channels that declare the customer's phone on EVERY inbound message — i.e.
# where the platform id IS the number, so identity resolves by phone and the
# synthesized `{platform_id}@{channel}.channel` address is redundant. Keep in
# step with the adapters that set profile['phone']: app/channels/whatsapp.py
# and app/channels/sms/base.py.
#
# Telegram is deliberately absent. It can learn a phone (share-contact button)
# but never sends it with a message, so it is still found by its address.
PHONE_KEYED_CHANNELS = ('whatsapp', 'sms')
# The same fact expressed as address suffixes. Derived, not spelled out twice —
# the two must never disagree about which channels these are.
PHONE_KEYED_CHANNEL_SUFFIXES = tuple(f'@{c}.channel' for c in PHONE_KEYED_CHANNELS)
class CustomerRepository:
def __init__(self, db: Session):
self.db = db
def get_customer_by_email(
self,
email: str,
organization_id: UUID
) -> Customer | None:
"""Get existing customer by email and organization ID"""
try:
customer = self.db.query(Customer).filter(
Customer.email == email,
Customer.organization_id == organization_id
).first()
return customer
except Exception as e:
logger.error(f"Error getting customer by email: {str(e)}")
return None
def get_customer_by_phone(
self,
phone: str,
organization_id: UUID
) -> Customer | None:
"""Get existing customer by normalized E.164 phone and organization ID.
Callers must pass the output of normalize_phone — the column stores
only that shape, so an unnormalized value would silently never match.
Merged-away rows are excluded, as everywhere else a person is resolved
(lead_capture, People). It matters more here than anywhere: this is the
FIRST lookup on every inbound WhatsApp/SMS message, so a tombstone
still holding a number would capture that conversation onto a row
People hides and no drawer can edit.
"""
try:
return self.db.query(Customer).filter(
Customer.phone == phone,
Customer.organization_id == organization_id,
Customer.merged_into_customer_id.is_(None)
).first()
except Exception as e:
logger.error(f"Error getting customer by phone: {str(e)}")
return None
def create_customer(
self,
email: str,
organization_id: UUID,
full_name: str = None,
meta_data: dict = None,
phone: str = None,
conversation_key: str = None
) -> Customer:
"""Create a new customer"""
try:
customer = Customer(
email=email,
full_name=full_name,
meta_data=meta_data,
conversation_key=conversation_key,
organization_id=organization_id,
# Defensive normalize: the identity column only ever holds the
# canonical E.164 shape, whoever the caller is.
phone=normalize_phone(phone)
)
self.db.add(customer)
self.db.commit()
self.db.refresh(customer)
return customer
except Exception as e:
logger.error(f"Error creating customer: {str(e)}")
self.db.rollback()
raise
def identify(
self,
email: str,
organization_id: UUID,
full_name: str = None,
meta_data: dict = None
) -> Customer:
"""Find or create the customer an embedding app has just named, and record
that the identity came from a system that already knew who they were.
`is_authenticated` is what separates these people from leads the agent
captured: they are the business's own signed-in users, so the People and
lead views filter them out. It is set on creation and backfilled on an
existing row, because the same person may first have arrived anonymously.
A supplied name replaces a stale one, and supplied meta_data is merged
(existing keys not named in this call are kept).
"""
email = (email or "").strip()
if not email:
raise ValueError("identify() needs an email")
try:
customer = self.get_customer_by_email(email, organization_id)
if not customer:
customer = self._create_identified(email, organization_id, full_name, meta_data)
if customer is not None:
return customer
# Someone else created them between our read and our insert —
# two dashboard tabs opening the chat at once is enough. Their
# row is the one that exists now, so carry on updating it.
customer = self.get_customer_by_email(email, organization_id)
if customer is None:
raise RuntimeError(f"Customer {email} vanished after a create conflict")
changed = False
if not customer.is_authenticated:
customer.is_authenticated = True
changed = True
if full_name and customer.full_name != full_name:
customer.full_name = full_name
changed = True
if changed:
self.db.commit()
# Only when it would actually change something: this runs on every
# token request, and an unconditional merge writes a row per request
# to store what is already there.
if meta_data and {**(customer.meta_data or {}), **meta_data} != (customer.meta_data or {}):
customer = self.update_meta_data(customer.id, meta_data) or customer
return customer
except Exception as e:
logger.error(f"Error identifying customer: {str(e)}")
self.db.rollback()
raise
def _create_identified(
self,
email: str,
organization_id: UUID,
full_name: str = None,
meta_data: dict = None
) -> Customer | None:
"""Insert the customer, or None when the (email, organization_id) unique
constraint says a concurrent caller got there first."""
customer = Customer(
email=email,
full_name=full_name,
meta_data=meta_data,
is_authenticated=True,
organization_id=organization_id,
)
self.db.add(customer)
try:
self.db.commit()
except IntegrityError:
self.db.rollback()
return None
self.db.refresh(customer)
logger.info(f"Identified new customer {customer.id} ({email})")
return customer
def update_meta_data(
self,
customer_id: UUID,
meta_data: dict
) -> Customer | None:
"""Merge integrator-supplied fields (e.g. student_name, center_name) into a
customer's existing meta_data, so agents see the latest values in the inbox.
New keys are added and existing keys are overwritten; unrelated keys are kept.
"""
if not meta_data:
return None
try:
customer = self.get_by_id(customer_id)
if not customer:
return None
customer.meta_data = {**(customer.meta_data or {}), **meta_data}
self.db.commit()
self.db.refresh(customer)
return customer
except Exception as e:
logger.error(f"Error updating customer meta_data: {str(e)}")
self.db.rollback()
return None
def set_phone_if_absent(self, customer: Customer, phone: str) -> bool:
"""Stage `phone` on a customer that has none, unless another customer
in the org already owns it (then skip-and-log — never reassign or
merge on a phone collision; that judgement belongs to a human).
The single owner of the set-phone policy: get_or_create_customer's
backfill and update_contact both go through here, so the conflict rule
cannot drift between them. Stages only — the caller owns the commit.
"""
if customer.phone:
return False
existing = self.get_customer_by_phone(phone, customer.organization_id)
if existing and existing.id != customer.id:
logger.warning(
f"Phone {phone} already belongs to customer {existing.id} in org "
f"{customer.organization_id}; not setting it on {customer.id}"
)
return False
customer.phone = phone
return True
def get_or_create_customer(
self,
email: str,
organization_id: UUID,
full_name: str = None,
phone: str = None
) -> Customer:
"""Resolve a person by phone first, then email; create if neither hits.
Phone outranks email because where both exist the phone came verified
from the channel (Meta/the SMS provider), while the email may be a
synthesized per-channel placeholder. When the two match *different*
customers, no merging happens here — auto-merge on a soft signal is how
Chatwoot corrupted contacts (their #2811); the phone match wins the
conversation and both records stay intact for a human.
A customer found by email with no stored phone gets it backfilled, so
pre-existing channel contacts become phone-addressable on their next
message without any data migration.
"""
# Defensive: the column must only ever hold the canonical shape, so an
# unnormalized value from a future caller degrades to no-phone rather
# than splitting one person into two spellings. Idempotent for the
# already-normalized values today's callers pass.
phone = normalize_phone(phone)
customer = self._resolve_existing(email, organization_id, phone)
if customer is not None:
return customer
try:
return self.create_customer(email, organization_id, full_name, phone=phone)
except IntegrityError:
# Lost a race: a concurrent request created this person between our
# lookup and our insert. Either unique key can be the one that
# fired — (organization_id, email) or the partial index on
# (organization_id, phone) — and the phone one is new, so a
# WhatsApp customer messaging while an agent starts an outbound
# send to them now collides on DIFFERENT emails, which the email
# constraint never caught. Re-resolve: the winner's row is the
# right answer. Without this the exception reaches
# process_channel_message's blanket except and the customer's
# message is silently never answered.
self.db.rollback()
customer = self._resolve_existing(email, organization_id, phone)
if customer is None:
raise
return customer
def _resolve_existing(
self,
email: str,
organization_id: UUID,
phone: str | None
) -> Customer | None:
"""Phone first, then email — the resolution order, without the create.
Split out so the post-IntegrityError retry re-runs exactly the lookup
that just lost the race, rather than a second, drifting copy of it.
"""
by_phone = self.get_customer_by_phone(phone, organization_id) if phone else None
if by_phone is not None:
# The email lookup is diagnostic-only here, so skip it on the hot
# path (every inbound message) when the emails trivially agree.
if by_phone.email != email:
by_email = self.get_customer_by_email(email, organization_id)
if by_email is not None and by_email.id != by_phone.id:
logger.warning(
f"Identity conflict in org {organization_id}: phone matches "
f"customer {by_phone.id} but email {email} matches "
f"{by_email.id}; using the phone match and leaving both "
f"records for manual review"
)
return by_phone
customer = self.get_customer_by_email(email, organization_id)
if not customer:
return None
if phone and self.set_phone_if_absent(customer, phone):
try:
self.db.commit()
except Exception as e:
# The partial unique index can race a concurrent insert; the
# customer is still the right one, just without the backfill.
self.db.rollback()
logger.warning(f"Could not backfill phone for customer {customer.id}: {e}")
return customer
def get_by_id(self, customer_id: UUID | str) -> Customer | None:
"""Get customer by ID"""
try:
customer = self.db.query(Customer).filter(Customer.id == as_uuid(customer_id)).first()
return customer
except Exception as e:
logger.error(f"Error getting customer by ID: {str(e)}")
return None
def resolve_merged(self, customer_id: UUID | str) -> Customer | None:
"""The customer `customer_id` names now, following lead-capture merges.
A device's token can still carry an anonymous customer that was since
merged into an identified one; everything acting for that device must
see the survivor.
"""
customer = self.get_by_id(customer_id)
seen = set()
while customer is not None and customer.merged_into_customer_id and customer.id not in seen:
seen.add(customer.id)
logger.info(f"Customer {customer.id} was merged; continuing as {customer.merged_into_customer_id}")
customer = self.get_by_id(customer.merged_into_customer_id)
return customer
def get_customer_email(self, customer_id: UUID) -> str | None:
"""Get customer email by ID"""
try:
customer = self.get_by_id(customer_id)
return customer.email if customer else None
except Exception as e:
logger.error(f"Error getting customer email: {str(e)}")
return None
@staticmethod
def anonymous_email() -> str:
"""A fresh stand-in email for an anonymous widget visitor.
Random, not time-based: two visitors arriving in the same millisecond
would otherwise collide on the (email, organization) unique key and one
of them would get a widget that never loads.
"""
return f"{uuid4().hex}@noemail.com"
@staticmethod
def is_placeholder_email(email: str | None) -> bool:
"""True if the email is missing or an auto-generated stand-in.
Two kinds exist, and neither can receive mail:
- ``…@noemail.com`` — an anonymous widget visitor.
- ``…@<channel>.channel`` — synthesized from a platform id (a wa_id, a
Telegram user id) so channel people have an identity key at all. It
was never an address, and treating it as one is why a channel
customer's captured email was silently dropped and why the handoff
form never asked them for one.
"""
return (not email) or ('@noemail.com' in email) or email.endswith('.channel')
@staticmethod
def display_email(email: str | None) -> str | None:
"""The email to show a human — None when we only ever had a stand-in.
Showing `916…@whatsapp.channel` in an Email field states something
untrue about the person."""
return None if CustomerRepository.is_placeholder_email(email) else email
@staticmethod
def _email_is_identity_key(customer) -> bool:
"""True when this customer is *found* by their synthesized address.
`_get_or_create_customer` resolves by phone first, but only for channels
whose adapter declares one on EVERY inbound message — WhatsApp and SMS,
where the platform id is the number. For those, the phone is the key and
the address is free to become the person's real one.
Every other channel is found by `{platform_id}@{channel}.channel` alone.
Telegram is the trap: it can learn a phone via the share-contact button,
so the customer HAS one — but its inbound messages never carry it, so
the lookup is still by address. Overwrite it and their next message
finds nobody and mints a duplicate. Storing a phone must not be mistaken
for being found by it.
"""
email = customer.email or ''
if not email.endswith('.channel'):
return False
return not (email.endswith(PHONE_KEYED_CHANNEL_SUFFIXES) and customer.phone)
def update_contact(
self,
customer_id: UUID,
email: str = None,
full_name: str = None,
phone: str = None
) -> dict:
"""Update an existing customer's contact details (used by human-handoff capture).
- Replaces a placeholder ``…@noemail.com`` (or empty) email with the supplied one,
respecting the ``(email, organization_id)`` unique constraint — if the email already
belongs to a different customer, the email update is skipped (logged) but the name
can still be updated. A real existing email is never overwritten.
- Sets ``full_name`` when a non-empty value is supplied.
- Sets ``phone`` (normalized E.164) only when the customer has none — automatic
capture paths must not overwrite a channel-verified number — and skips it when the
phone belongs to another customer in the org (same rule as email). Correcting an
existing phone is an explicit human act, not this method's job.
Returns ``{'email_updated': bool, 'name_updated': bool, 'phone_updated': bool,
'email': str|None}``.
"""
result = {'email_updated': False, 'name_updated': False,
'phone_updated': False, 'email': None}
try:
customer = self.get_by_id(customer_id)
if not customer:
return result
if email:
email = email.strip()
# Only replace a placeholder/empty email — don't clobber a real one,
# and never one that is still doing duty as the identity key.
if email and self.is_placeholder_email(customer.email) \
and not self._email_is_identity_key(customer) \
and email.lower() != (customer.email or '').lower():
existing = self.get_customer_by_email(email, customer.organization_id)
if existing and existing.id != customer.id:
logger.warning(
f"Email {email} already belongs to another customer in org "
f"{customer.organization_id}; skipping email update for {customer_id}"
)
else:
customer.email = email
result['email_updated'] = True
if full_name is not None:
full_name = full_name.strip()
if full_name:
customer.full_name = full_name
result['name_updated'] = True
if phone and not customer.phone:
normalized = normalize_phone(phone)
if not normalized:
logger.warning(
f"Captured phone {phone!r} for customer {customer_id} is not a "
f"resolvable E.164 number; skipping"
)
elif self.set_phone_if_absent(customer, normalized):
result['phone_updated'] = True
if result['email_updated'] or result['name_updated'] or result['phone_updated']:
self.db.commit()
self.db.refresh(customer)
result['email'] = customer.email
return result
except Exception as e:
logger.error(f"Error updating customer contact: {str(e)}")
self.db.rollback()
return result
+239
View File
@@ -0,0 +1,239 @@
"""
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 uuid import UUID
from sqlalchemy import or_
from sqlalchemy.orm import Session
from app.models.faq import FAQ, FAQStatus
class FAQRepository:
def __init__(self, db: Session):
self.db = db
def _org_query(self, organization_id: UUID):
return self.db.query(FAQ).filter(FAQ.organization_id == organization_id)
@staticmethod
def _apply_search(query, search: str):
"""Tokenized search: every whitespace-separated term must appear in the
question, answer or category (any of them). Matching the whole query as
one substring made 'close my' miss 'How to close my account'."""
for term in search.split():
pattern = f"%{term}%"
query = query.filter(
or_(
FAQ.question.ilike(pattern),
FAQ.answer.ilike(pattern),
FAQ.category.ilike(pattern),
)
)
return query
def get_by_id(self, faq_id: UUID, organization_id: UUID) -> Optional[FAQ]:
"""Org-scoped fetch: cross-org ids resolve to None (callers 404)."""
return self._org_query(organization_id).filter(FAQ.id == faq_id).first()
def list_for_org(
self,
organization_id: UUID,
status: Optional[FAQStatus] = None,
category: Optional[str] = None,
search: Optional[str] = None,
skip: int = 0,
limit: int = 100,
) -> tuple[List[FAQ], int]:
"""Filtered, paginated list plus the total matching count."""
query = self._org_query(organization_id)
if status is not None:
query = query.filter(FAQ.status == status)
if category:
# Exact match: filter values come from get_categories(), and the
# (organization_id, category) index only serves equality.
query = query.filter(FAQ.category == category)
if search:
query = self._apply_search(query, search)
total = query.count()
items = (
query.order_by(FAQ.category.asc(), FAQ.sort_order.asc(), FAQ.created_at.asc())
.offset(skip)
.limit(limit)
.all()
)
return items, total
def get_published_for_org(self, organization_id: UUID, search: Optional[str] = None) -> List[FAQ]:
"""Published FAQs for the public help center, in display order."""
query = self._org_query(organization_id).filter(FAQ.status == FAQStatus.PUBLISHED)
if search:
query = self._apply_search(query, search)
return query.order_by(FAQ.category.asc(), FAQ.sort_order.asc(), FAQ.created_at.asc()).all()
def slug_exists(
self, organization_id: UUID, slug: str, exclude_id: Optional[UUID] = None
) -> bool:
"""Whether another FAQ in the org already owns this slug. `exclude_id`
skips one row so re-saving an article doesn't collide with itself."""
query = self._org_query(organization_id).filter(FAQ.slug == slug)
if exclude_id is not None:
query = query.filter(FAQ.id != exclude_id)
return self.db.query(query.exists()).scalar()
def get_published_by_slug(self, organization_id: UUID, slug: str) -> Optional[FAQ]:
"""A single published article by its slug (the public /a/{slug} lookup)."""
return (
self._org_query(organization_id)
.filter(FAQ.slug == slug, FAQ.status == FAQStatus.PUBLISHED)
.first()
)
def url_path_exists(
self, organization_id: UUID, url_path: str, exclude_id: Optional[UUID] = None
) -> bool:
"""Whether another FAQ in the org is already served at this preserved
path. `exclude_id` skips one row so re-saving an article doesn't collide
with itself."""
query = self._org_query(organization_id).filter(FAQ.url_path == url_path)
if exclude_id is not None:
query = query.filter(FAQ.id != exclude_id)
return self.db.query(query.exists()).scalar()
def get_published_by_url_path(self, organization_id: UUID, url_path: str) -> Optional[FAQ]:
"""A single published article by its preserved original path — the
lookup for any public URL no other route claimed. Case-sensitive on
purpose: the point of the feature is to serve the exact URL the org
already ranks for."""
return (
self._org_query(organization_id)
.filter(FAQ.url_path == url_path, FAQ.status == FAQStatus.PUBLISHED)
.first()
)
def get_published_related(
self, organization_id: UUID, category: str, exclude_id: UUID, limit: int = 4
) -> List[FAQ]:
"""Other published FAQs in the same category — the article's related list."""
return (
self._org_query(organization_id)
.filter(
FAQ.status == FAQStatus.PUBLISHED,
FAQ.category == category,
FAQ.id != exclude_id,
)
.order_by(FAQ.sort_order.asc(), FAQ.created_at.asc())
.limit(limit)
.all()
)
def get_categories(self, organization_id: UUID) -> List[str]:
rows = (
self._org_query(organization_id)
.with_entities(FAQ.category)
.distinct()
.order_by(FAQ.category.asc())
.all()
)
return [row[0] for row in rows]
def knowledge_ids_with_faqs(self, organization_id: UUID) -> set:
"""Knowledge sources that already produced FAQs — regenerate skips
these unless the caller explicitly targets them. Self-healing: deleting
a source's FAQs makes it eligible for generation again."""
rows = (
self._org_query(organization_id)
.with_entities(FAQ.knowledge_id)
.filter(FAQ.knowledge_id.isnot(None))
.distinct()
.all()
)
return {row[0] for row in rows}
def get_existing_questions(self, organization_id: UUID) -> List[str]:
"""All question strings for the org — the generation dedup baseline."""
rows = self._org_query(organization_id).with_entities(FAQ.question).all()
return [row[0] for row in rows]
def exists_for_org(self, organization_id: UUID) -> bool:
return self.db.query(
self._org_query(organization_id).exists()
).scalar()
def create(self, faq: FAQ) -> FAQ:
self.db.add(faq)
self.db.commit()
self.db.refresh(faq)
return faq
def bulk_create(self, faqs: List[FAQ]) -> List[FAQ]:
# Guarantee every generated/imported FAQ gets a unique article slug (the
# single choke point for batch inserts), so publishing one never yields a
# broken /a/ link.
from app.services.help_center_settings import assign_faq_slugs
assign_faq_slugs(self.db, faqs)
self.db.add_all(faqs)
self.db.commit()
return faqs
def update(self, faq: FAQ) -> FAQ:
self.db.commit()
self.db.refresh(faq)
return faq
def delete(self, faq: FAQ) -> None:
self.db.delete(faq)
self.db.commit()
def record_feedback(self, faq_id: UUID, helpful: bool) -> None:
"""Atomically bump the 'Was this helpful?' tally for an article."""
column = FAQ.helpful_yes if helpful else FAQ.helpful_no
self.db.query(FAQ).filter(FAQ.id == faq_id).update(
{column: column + 1}, synchronize_session=False
)
self.db.commit()
def bulk_set_status(
self, organization_id: UUID, faq_ids: List[UUID], status: FAQStatus
) -> int:
"""Set status on the org's FAQs among faq_ids; returns rows changed."""
updated = (
self._org_query(organization_id)
.filter(FAQ.id.in_(faq_ids))
.update({FAQ.status: status}, synchronize_session=False)
)
self.db.commit()
return updated
def bulk_delete(self, organization_id: UUID, faq_ids: List[UUID]) -> int:
"""Delete the org's FAQs among faq_ids; returns rows deleted.
Org-scoped, so cross-org ids are silently ignored."""
deleted = (
self._org_query(organization_id)
.filter(FAQ.id.in_(faq_ids))
.delete(synchronize_session=False)
)
self.db.commit()
return deleted
def count_published(self, organization_id: UUID) -> int:
return (
self._org_query(organization_id)
.filter(FAQ.status == FAQStatus.PUBLISHED)
.count()
)
@@ -0,0 +1,213 @@
"""
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, timedelta, timezone
from typing import List, Optional
from uuid import UUID
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.logger import get_logger
from app.models.faq_generation_job import (
ACTIVE_FAQ_JOB_STATUSES,
FAQGenerationJob,
FAQJobStage,
FAQJobStatus,
FAQJobType,
)
logger = get_logger(__name__)
class FAQGenerationJobRepository:
def __init__(self, db: Session):
self.db = db
def create(self, job: FAQGenerationJob) -> FAQGenerationJob:
self.db.add(job)
self.db.commit()
self.db.refresh(job)
return job
def get_by_id(self, job_id: int) -> Optional[FAQGenerationJob]:
return self.db.query(FAQGenerationJob).filter(FAQGenerationJob.id == job_id).first()
def get_by_id_for_org(self, job_id: int, organization_id: UUID) -> Optional[FAQGenerationJob]:
return (
self.db.query(FAQGenerationJob)
.filter(
FAQGenerationJob.id == job_id,
FAQGenerationJob.organization_id == organization_id,
)
.first()
)
def get_pending(self) -> List[FAQGenerationJob]:
return (
self.db.query(FAQGenerationJob)
.filter(FAQGenerationJob.status == FAQJobStatus.PENDING.value)
.order_by(FAQGenerationJob.created_at.asc())
.all()
)
def fail_orphaned_processing(
self,
reason: str,
older_than_seconds: Optional[int] = None,
organization_id: Optional[UUID] = None,
) -> int:
"""Mark `processing` jobs failed. With no bounds (worker startup): the
worker is the only writer of `processing`, so on a fresh boot every such
row is an orphan from a previous crash/kill. With `older_than_seconds`
(lazy reap on enqueue): only jobs whose progress stopped advancing past
the threshold — a worker that died and hasn't restarted. Either way the
job (and the enqueue guard + UI polling) would otherwise hang forever."""
query = self.db.query(FAQGenerationJob).filter(
FAQGenerationJob.status == FAQJobStatus.PROCESSING.value
)
if organization_id is not None:
query = query.filter(FAQGenerationJob.organization_id == organization_id)
if older_than_seconds is not None:
cutoff = datetime.now(timezone.utc) - timedelta(seconds=older_than_seconds)
query = query.filter(FAQGenerationJob.updated_at < cutoff)
updated = query.update(
{
FAQGenerationJob.status: FAQJobStatus.FAILED.value,
FAQGenerationJob.error: reason,
},
synchronize_session=False,
)
self.db.commit()
return updated
def get_active_for_org(
self,
organization_id: UUID,
job_type: Optional[FAQJobType] = None,
knowledge_id: Optional[int] = None,
) -> Optional[FAQGenerationJob]:
"""Most recent pending/processing job, optionally narrowed by type and
source — the enqueue-dedup guard and the UI's polling target. A
`processing` job whose progress stopped advancing past
FAQ_JOB_STALE_SECONDS is treated as dead (excluded), so the UI stops
polling even if the worker never restarts to reap it."""
cutoff = datetime.now(timezone.utc) - timedelta(seconds=settings.FAQ_JOB_STALE_SECONDS)
query = self.db.query(FAQGenerationJob).filter(
FAQGenerationJob.organization_id == organization_id,
FAQGenerationJob.status.in_(ACTIVE_FAQ_JOB_STATUSES),
or_(
FAQGenerationJob.status != FAQJobStatus.PROCESSING.value,
FAQGenerationJob.updated_at.is_(None),
FAQGenerationJob.updated_at >= cutoff,
),
)
if job_type is not None:
query = query.filter(FAQGenerationJob.job_type == job_type.value)
if knowledge_id is not None:
query = query.filter(FAQGenerationJob.knowledge_id == knowledge_id)
return query.order_by(FAQGenerationJob.created_at.desc()).first()
def get_latest_for_org(self, organization_id: UUID) -> Optional[FAQGenerationJob]:
return (
self.db.query(FAQGenerationJob)
.filter(FAQGenerationJob.organization_id == organization_id)
.order_by(FAQGenerationJob.created_at.desc())
.first()
)
def increment_llm_calls(self, job_id: int, n: int = 1) -> None:
"""Atomic counter bump — one per attempted LLM call (retries cost too)."""
self.db.query(FAQGenerationJob).filter(FAQGenerationJob.id == job_id).update(
{FAQGenerationJob.llm_calls: FAQGenerationJob.llm_calls + n},
synchronize_session=False,
)
self.db.commit()
def count_llm_calls_for_period(
self, organization_id: UUID, start_date: datetime, end_date: datetime
) -> int:
"""Metered LLM calls in a billing period — the hosted-model usage that
the enterprise message-limit check adds to the bot-message count."""
total = (
self.db.query(func.coalesce(func.sum(FAQGenerationJob.llm_calls), 0))
.filter(
FAQGenerationJob.organization_id == organization_id,
FAQGenerationJob.metered.is_(True),
FAQGenerationJob.created_at >= start_date,
FAQGenerationJob.created_at <= end_date,
)
.scalar()
)
return int(total or 0)
def has_user_initiated_job(self, organization_id: UUID) -> bool:
"""Whether the org ever explicitly ran generate/import (any status —
the attempt is the feature opt-in). GENERATE_SOURCE jobs don't count:
only the auto-hook creates those."""
return self.db.query(
self.db.query(FAQGenerationJob)
.filter(
FAQGenerationJob.organization_id == organization_id,
FAQGenerationJob.job_type != FAQJobType.GENERATE_SOURCE.value,
)
.exists()
).scalar()
def update_progress(
self,
job_id: int,
stage: Optional[FAQJobStage] = None,
progress_percentage: Optional[float] = None,
) -> bool:
"""Direct assignment: each job has exactly one sequential writer (the
worker), so no monotonicity guard is needed and retries may legitimately
move stage/progress backwards."""
fields = {}
if stage is not None:
fields["stage"] = stage.value
if progress_percentage is not None:
fields["progress_percentage"] = progress_percentage
return self._apply(job_id, **fields)
def mark_processing(self, job_id: int) -> bool:
return self._apply(job_id, status=FAQJobStatus.PROCESSING.value)
def mark_completed(self, job_id: int, faqs_created: int) -> bool:
return self._apply(
job_id,
status=FAQJobStatus.COMPLETED.value,
stage=FAQJobStage.COMPLETED.value,
progress_percentage=100.0,
faqs_created=faqs_created,
)
def mark_failed(self, job_id: int, error: str) -> bool:
return self._apply(
job_id,
status=FAQJobStatus.FAILED.value,
error=error[:2000] if error else None,
)
def _apply(self, job_id: int, **fields) -> bool:
job = self.get_by_id(job_id)
if not job:
return False
for key, value in fields.items():
setattr(job, key, value)
self.db.commit()
return True
+115
View File
@@ -0,0 +1,115 @@
"""
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.exc import IntegrityError
from sqlalchemy.orm import Session
from uuid import UUID
from typing import List, Sequence
from app.models.fcm_token import FCMToken
from app.core.logger import get_logger
logger = get_logger(__name__)
class FCMTokenRepository:
"""Web-push tokens, one row per signed-in browser."""
def __init__(self, db: Session):
self.db = db
def register(self, user_id: UUID, token: str) -> bool:
"""Store `token` for `user_id`, creating or reassigning its row.
FCM hands the same token back to a given browser install no matter who
is signed in, so an existing row is moved to the current user rather
than duplicated — otherwise a shared browser would keep receiving the
previous account's notifications.
"""
try:
if self._claim(user_id, token):
return True
# Lost the insert race: the client posts on every app load, so two
# tabs opening together both see no row and both insert. Re-run
# against the row the winner committed instead of failing the
# request and leaving this browser unregistered for the session.
self.db.rollback()
return self._claim(user_id, token)
except Exception as e:
logger.error(f"Error registering FCM token: {str(e)}")
self.db.rollback()
return False
def _claim(self, user_id: UUID, token: str) -> bool:
"""Point `token` at `user_id`. False if a concurrent insert won."""
existing = self.db.query(FCMToken).filter(
FCMToken.token == token).first()
if existing:
if existing.user_id != user_id:
logger.info(
f"Reassigning FCM token from user {existing.user_id} to {user_id}")
existing.user_id = user_id
else:
self.db.add(FCMToken(user_id=user_id, token=token))
try:
self.db.commit()
return True
except IntegrityError:
return False
def get_tokens(self, user_id: UUID) -> List[str]:
"""Every device token registered for a user."""
try:
rows = self.db.query(FCMToken.token).filter(
FCMToken.user_id == user_id).all()
return [row.token for row in rows]
except Exception as e:
logger.error(f"Error getting FCM tokens: {str(e)}")
return []
def remove(self, user_id: UUID, token: str) -> bool:
"""Drop a single device's token — used on logout.
Scoped to the owner so a stolen token can't be used to silence someone
else's notifications.
"""
try:
deleted = self.db.query(FCMToken).filter(
FCMToken.user_id == user_id,
FCMToken.token == token,
).delete(synchronize_session=False)
self.db.commit()
return deleted > 0
except Exception as e:
logger.error(f"Error removing FCM token: {str(e)}")
self.db.rollback()
return False
def remove_tokens(self, tokens: Sequence[str]) -> int:
"""Drop tokens FCM has told us are dead. Returns the number removed."""
if not tokens:
return 0
try:
deleted = self.db.query(FCMToken).filter(
FCMToken.token.in_(list(tokens))).delete(synchronize_session=False)
self.db.commit()
return deleted
except Exception as e:
logger.error(f"Error pruning FCM tokens: {str(e)}")
self.db.rollback()
return 0
@@ -0,0 +1,95 @@
"""
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from sqlalchemy.orm import Session
from app.models.file_attachment import FileAttachment
from typing import List, Optional
from uuid import UUID
class FileAttachmentRepository:
def __init__(self, db: Session):
self.db = db
def create_attachment(
self,
file_url: str,
filename: str,
content_type: str,
file_size: int,
organization_id: UUID,
chat_history_id: Optional[int] = None,
uploaded_by_user_id: Optional[UUID] = None,
uploaded_by_customer_id: Optional[UUID] = None
) -> FileAttachment:
"""Create a new file attachment record"""
attachment = FileAttachment(
file_url=file_url,
filename=filename,
content_type=content_type,
file_size=file_size,
chat_history_id=chat_history_id,
organization_id=organization_id,
uploaded_by_user_id=uploaded_by_user_id,
uploaded_by_customer_id=uploaded_by_customer_id
)
self.db.add(attachment)
self.db.commit()
self.db.refresh(attachment)
return attachment
def get_attachment(self, attachment_id: int) -> Optional[FileAttachment]:
"""Get a file attachment by ID"""
return self.db.query(FileAttachment).filter(
FileAttachment.id == attachment_id
).first()
def get_attachments_by_chat_history(
self, chat_history_id: int
) -> List[FileAttachment]:
"""Get all attachments for a chat message"""
return self.db.query(FileAttachment).filter(
FileAttachment.chat_history_id == chat_history_id
).all()
def get_attachments_by_organization(
self, organization_id: UUID, limit: int = 100
) -> List[FileAttachment]:
"""Get all attachments for an organization"""
return self.db.query(FileAttachment).filter(
FileAttachment.organization_id == organization_id
).order_by(FileAttachment.created_at.desc()).limit(limit).all()
def delete_attachment(self, attachment_id: int) -> bool:
"""Delete a file attachment"""
attachment = self.get_attachment(attachment_id)
if attachment:
self.db.delete(attachment)
self.db.commit()
return True
return False
def link_attachment_to_message(
self, attachment_id: int, chat_history_id: int
) -> Optional[FileAttachment]:
"""Link an existing attachment to a chat message"""
attachment = self.get_attachment(attachment_id)
if attachment:
attachment.chat_history_id = chat_history_id
self.db.commit()
self.db.refresh(attachment)
return attachment
return None
+28
View File
@@ -0,0 +1,28 @@
"""
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.orm import Session, joinedload
from app.models.user import UserGroup
class GroupRepository:
def __init__(self, db: Session):
self.db = db
def get_group_with_users(self, group_id: str):
return self.db.query(UserGroup)\
.options(joinedload(UserGroup.users))\
.filter(UserGroup.id == group_id)\
.first()
+111
View File
@@ -0,0 +1,111 @@
"""
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 Dict, Optional, Sequence
from uuid import UUID
from sqlalchemy import func
from app.core.logger import get_logger
from app.database import SessionLocal
from app.models.guardrail_event import GuardrailEvent
logger = get_logger(__name__)
def record_guardrail_events(
*,
rules: Sequence[str],
surface: str,
layer: str,
action: str,
org_id: Optional[str] = None,
agent_id: Optional[str] = None,
session_id: Optional[str] = None,
score: Optional[float] = None,
char_len: Optional[int] = None,
excerpt: Optional[str] = None,
) -> None:
"""Best-effort insert of one row per triggered rule, in a single session.
Opens its OWN session: callers may hold no session at all (the workflow
LLM path), and a guardrail write must never poison a caller's transaction.
Swallows every exception — an event that fails to record must not affect
the reply.
`matched` stores the rule ids that fired together, never the matched text:
that text is the visitor's own words and this column is unencrypted, so
reviewable content belongs in `excerpt` instead.
"""
if not rules:
return
try:
with SessionLocal() as db:
for rule in rules:
db.add(
GuardrailEvent(
organization_id=org_id,
agent_id=agent_id,
session_id=session_id,
surface=surface,
layer=layer,
rule=rule,
action=action,
score=score,
matched=list(rules),
char_len=char_len,
excerpt=excerpt,
)
)
db.commit()
except Exception as e:
logger.error(f"Failed to record guardrail events {list(rules)}/{action}: {e}")
class GuardrailEventRepository:
"""Query/review side, used by analysis and tests."""
def __init__(self, db):
self.db = db
def counts_by_rule(
self,
start: datetime,
end: datetime,
organization_id: Optional[UUID] = None,
) -> Dict[str, int]:
"""Events per rule id in the half-open window [start, end)."""
query = (
self.db.query(GuardrailEvent.rule, func.count(GuardrailEvent.id))
.filter(GuardrailEvent.created_at >= start)
.filter(GuardrailEvent.created_at < end)
)
if organization_id is not None:
query = query.filter(GuardrailEvent.organization_id == organization_id)
return dict(query.group_by(GuardrailEvent.rule).all())
def mark_false_positive(self, event_id: UUID) -> Optional[GuardrailEvent]:
"""Sole writer of the review flags: marks an event reviewed AND a
false positive (the precision gate for strict mode)."""
event = self.db.query(GuardrailEvent).filter(GuardrailEvent.id == event_id).first()
if not event:
return None
event.reviewed = True
event.false_positive = True
self.db.commit()
self.db.refresh(event)
return event
+129
View File
@@ -0,0 +1,129 @@
"""
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 sqlalchemy.orm import Session
from app.models.help_center import HelpCenterQuery, HelpCenterSettings
# Both DNS records verified — the queryable form of the derived
# HelpCenterSettings.domain_verified property.
_DOMAIN_VERIFIED = (
HelpCenterSettings.txt_record_verified.is_(True)
& HelpCenterSettings.cname_record_verified.is_(True)
)
class HelpCenterRepository:
def __init__(self, db: Session):
self.db = db
def get_by_org(self, organization_id: UUID) -> Optional[HelpCenterSettings]:
return (
self.db.query(HelpCenterSettings)
.filter(HelpCenterSettings.organization_id == organization_id)
.first()
)
def get_by_slug(self, slug: str) -> Optional[HelpCenterSettings]:
return (
self.db.query(HelpCenterSettings)
.filter(HelpCenterSettings.slug == slug.lower())
.first()
)
def get_by_verified_domain(self, domain: str) -> Optional[HelpCenterSettings]:
"""Resolve a custom domain — only verified domains ever match, so an
unverified claim on someone else's hostname can't hijack serving."""
return (
self.db.query(HelpCenterSettings)
.filter(
HelpCenterSettings.custom_domain == domain.lower(),
_DOMAIN_VERIFIED,
)
.first()
)
def list_verified_domains(self) -> List[str]:
"""All verified custom domains, for the host-dispatch cache."""
rows = (
self.db.query(HelpCenterSettings.custom_domain)
.filter(
HelpCenterSettings.custom_domain.isnot(None),
_DOMAIN_VERIFIED,
)
.all()
)
return [row[0] for row in rows]
def list_enabled_slugs(self) -> List[str]:
"""Slugs of enabled help centers — their `{slug}.{base_domain}` origins
are added to the CORS allowlist for the embedded widget."""
rows = (
self.db.query(HelpCenterSettings.slug)
.filter(
HelpCenterSettings.enabled.is_(True),
HelpCenterSettings.slug.isnot(None),
)
.all()
)
return [row[0] for row in rows]
def slug_exists(self, slug: str) -> bool:
return self.db.query(
self.db.query(HelpCenterSettings)
.filter(HelpCenterSettings.slug == slug.lower())
.exists()
).scalar()
def create(self, settings: HelpCenterSettings) -> HelpCenterSettings:
self.db.add(settings)
self.db.commit()
self.db.refresh(settings)
return settings
def update(self, settings: HelpCenterSettings) -> HelpCenterSettings:
self.db.commit()
self.db.refresh(settings)
return settings
class HelpCenterQueryRepository:
def __init__(self, db: Session):
self.db = db
def log(self, organization_id: UUID, query: str, answered: bool) -> HelpCenterQuery:
row = HelpCenterQuery(organization_id=organization_id, query=query, answered=answered)
self.db.add(row)
self.db.commit()
return row
def count_for_period(self, organization_id: UUID, start: datetime, end: datetime) -> int:
"""Answered asks in a billing period — feeds the hosted-model metering.
Half-open [start, end) so a boundary instant is never billed twice."""
return (
self.db.query(HelpCenterQuery)
.filter(
HelpCenterQuery.organization_id == organization_id,
HelpCenterQuery.answered.is_(True),
HelpCenterQuery.created_at >= start,
HelpCenterQuery.created_at < end,
)
.count()
)
+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.orm import Session, joinedload
from sqlalchemy import cast, String, func
from typing import Optional, Dict, Any
from app.models.jira import AgentJiraConfig
from app.models.agent import Agent
from uuid import UUID
from app.core.logger import get_logger
from app.models.schemas.jira import AgentWithJiraConfig
import json
logger = get_logger(__name__)
class JiraRepository:
def __init__(self, db: Session):
self.db = db
def get_agent_with_jira_config(self, agent_id: str) -> Optional[AgentWithJiraConfig]:
"""
Get agent with its Jira configuration in a single optimized query.
Returns an AgentWithJiraConfig object that can be accessed with dot notation.
"""
try:
if isinstance(agent_id, str):
agent_id = UUID(agent_id)
# First get the agent
agent = self.db.query(Agent).options(
joinedload(Agent.groups),
joinedload(Agent.organization)
).filter(
Agent.id == agent_id
).first()
if not agent:
return None
# Then get the Jira config
jira_config = self.db.query(AgentJiraConfig).filter(
AgentJiraConfig.agent_id == str(agent.id)
).first()
# Parse tools JSON if it exists
tools_list = []
if agent.tools:
try:
if isinstance(agent.tools, str):
# Parse JSON string
parsed = json.loads(agent.tools)
tools_list = parsed if isinstance(parsed, list) else []
elif isinstance(agent.tools, list):
# Already a list
tools_list = agent.tools
elif isinstance(agent.tools, dict):
# If it's a dict (empty or not), convert to empty list
logger.warning(f"Agent {agent.id} has tools as dict instead of list, converting to empty list")
tools_list = []
else:
# Unknown type
logger.warning(f"Agent {agent.id} has tools of unexpected type: {type(agent.tools)}")
tools_list = []
except (json.JSONDecodeError, TypeError) as e:
logger.warning(f"Error parsing tools for agent {agent.id}: {str(e)}")
tools_list = []
# Create an AgentWithJiraConfig object
agent_data = AgentWithJiraConfig(
id=agent.id,
name=agent.name,
display_name=agent.display_name,
description=agent.description,
instructions=agent.instructions,
tools=tools_list,
agent_type=agent.agent_type,
is_default=agent.is_default,
is_active=agent.is_active,
organization_id=agent.organization_id,
transfer_to_human=agent.transfer_to_human,
ask_for_rating=agent.ask_for_rating,
ticketing_enabled=agent.ticketing_enabled,
topic_scope=agent.topic_scope,
guardrail_prompt=agent.guardrail_prompt,
guardrail_enabled=agent.guardrail_enabled,
groups=agent.groups,
organization=agent.organization,
knowledge=[], # Empty list as default
jira_enabled=False,
jira_project_key=None,
jira_issue_type_id=None
)
# Add Jira configuration if it exists
if jira_config:
agent_data.jira_enabled = jira_config.enabled
agent_data.jira_project_key = jira_config.project_key
agent_data.jira_issue_type_id = jira_config.issue_type_id
return agent_data
except Exception as e:
logger.error(f"Error getting agent with Jira config: {str(e)}")
return None
+214
View File
@@ -0,0 +1,214 @@
"""
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, timedelta, timezone
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from app.models.knowledge import Knowledge
from typing import List, Optional
from sqlalchemy import func
from sqlalchemy.sql import text
import logging
from uuid import UUID
from app.models.knowledge_to_agent import KnowledgeToAgent
from app.models.knowledge_queue import KnowledgeQueue, QueueStatus
logger = logging.getLogger(__name__)
class KnowledgeRepository:
def __init__(self, db: Session):
self.db = db
def get_by_id(self, knowledge_id: int) -> Optional[Knowledge]:
"""Get knowledge source by ID"""
return self.db.query(Knowledge)\
.filter(Knowledge.id == knowledge_id)\
.first()
def get_by_agent(self, agent_id: UUID, skip: int = 0, limit: int = 100):
"""Get paginated knowledge items for an agent"""
query = (self.db.query(Knowledge)
.join(KnowledgeToAgent)
.filter(
KnowledgeToAgent.knowledge_id == Knowledge.id,
KnowledgeToAgent.agent_id == agent_id
)
.order_by(Knowledge.id)
.offset(skip)
.limit(limit))
result = query.all()
return result
def count_by_agent(self, agent_id: UUID) -> int:
"""Get total count of knowledge items for an agent"""
query = (self.db.query(func.count(Knowledge.id))
.join(KnowledgeToAgent)
.filter(
KnowledgeToAgent.knowledge_id == Knowledge.id,
KnowledgeToAgent.agent_id == agent_id
))
result = query.scalar() or 0
return result
def get_by_org(self, org_id: UUID) -> List[Knowledge]:
"""Get all knowledge sources for an organization"""
return self.db.query(Knowledge)\
.filter(Knowledge.organization_id == org_id)\
.all()
def create(self, knowledge: Knowledge) -> Knowledge:
"""Create a new knowledge source or get existing"""
existing = self.db.query(Knowledge)\
.filter(
Knowledge.organization_id == knowledge.organization_id,
Knowledge.source == knowledge.source
).first()
if existing:
return existing
self.db.add(knowledge)
self.db.commit()
self.db.refresh(knowledge)
return knowledge
def delete(self, knowledge_id: int) -> bool:
"""Delete a knowledge source"""
knowledge = self.db.query(Knowledge)\
.filter(Knowledge.id == knowledge_id)\
.first()
if knowledge:
self.db.delete(knowledge)
self.db.commit()
return True
return False
def count_by_organization(self, org_id: UUID) -> int:
"""Get total count of indexed knowledge items for an organization"""
query = (self.db.query(func.count(Knowledge.id))
.filter(Knowledge.organization_id == org_id))
result = query.scalar() or 0
return result
# A crawl occupies its plan slot the moment it is queued, not when it
# finishes. Counting only indexed rows let an organization submit its
# whole backlog in one sitting - every request saw a count of zero - and
# end up over the plan limit once the queue drained.
IN_FLIGHT_QUEUE_STATUSES = (QueueStatus.PENDING.value, QueueStatus.PROCESSING.value)
# The worker only ever picks up PENDING, so a row left PROCESSING by a
# restart is never retried and never completes. Stop charging for one
# after this long, or a redeploy mid-crawl would cost a slot for ever.
STALE_PROCESSING_AFTER = timedelta(hours=6)
def count_sources_in_use(self, org_id: UUID, now: datetime = None) -> int:
"""Knowledge sources occupying a plan slot: the indexed ones plus the
crawls still queued or running.
Every queue row becomes its own Knowledge row - the worker does not
merge repeats - so rows are counted, not distinct sources: otherwise
submitting one URL repeatedly walked past the limit unchecked. A row
that has just written its Knowledge row but is not yet marked complete
is counted twice for that instant, which errs toward refusing, the
safe direction for a limit."""
now = now or datetime.now(timezone.utc)
fresh_enough = now - self.STALE_PROCESSING_AFTER
in_flight = (
self.db.query(func.count(KnowledgeQueue.id))
.filter(
KnowledgeQueue.organization_id == org_id,
or_(
KnowledgeQueue.status == QueueStatus.PENDING.value,
and_(
KnowledgeQueue.status == QueueStatus.PROCESSING.value,
func.coalesce(
KnowledgeQueue.updated_at, KnowledgeQueue.created_at
) >= fresh_enough,
),
),
)
.scalar() or 0
)
return self.count_by_organization(org_id) + in_flight
def get_by_organization(
self,
org_id: UUID,
skip: int = 0,
limit: int = 10
) -> List[Knowledge]:
"""Get paginated knowledge items for an organization"""
query = (self.db.query(Knowledge)
.filter(Knowledge.organization_id == org_id)
.order_by(Knowledge.id)
.offset(skip)
.limit(limit))
result = query.all()
return result
def get_by_sources(self, organization_id: UUID, sources: List[str]):
"""Get knowledge items by source URLs for an organization"""
return self.db.query(Knowledge)\
.filter(
Knowledge.organization_id == organization_id,
Knowledge.source.in_(sources)
).all()
def delete_with_data(self, knowledge_id: int) -> bool:
"""Delete knowledge source and its associated data"""
try:
knowledge = self.get_by_id(knowledge_id)
if not knowledge:
return False
# Delete from knowledge_to_agent table first
self.db.execute(
text("DELETE FROM knowledge_to_agents WHERE knowledge_id = :kid"),
{"kid": knowledge_id}
)
# If table_name and schema exist, delete the actual data
if knowledge.table_name and knowledge.schema:
try:
# Delete data from the specific table
self.db.execute(
text(f'DELETE FROM {knowledge.schema}."{ knowledge.table_name}" WHERE name = :source'),
{"source": knowledge.source}
)
except Exception as e:
logger.error(f"Error deleting data from {
knowledge.table_name}: {str(e)}")
# Continue with knowledge deletion even if data deletion fails
# Delete the knowledge entry
self.db.delete(knowledge)
self.db.commit()
return True
except Exception as e:
self.db.rollback()
logger.error(f"Error in delete_with_data: {str(e)}")
raise
+149
View File
@@ -0,0 +1,149 @@
"""
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 func
from sqlalchemy.orm import Session
from app.models.knowledge_queue import KnowledgeQueue, QueueStatus
from typing import List, Optional
from datetime import datetime
from app.core.logger import get_logger
from app.core.logger import get_logger
logger = get_logger(__name__)
class KnowledgeQueueRepository:
def __init__(self, db: Session):
self.db = db
def create(self, queue_item: KnowledgeQueue) -> KnowledgeQueue:
self.db.add(queue_item)
self.db.commit()
self.db.refresh(queue_item)
return queue_item
def get_by_id(self, queue_id: int) -> Optional[KnowledgeQueue]:
"""Get queue item by ID"""
return self.db.query(KnowledgeQueue).filter(
KnowledgeQueue.id == queue_id
).first()
def get_pending(self) -> List[KnowledgeQueue]:
"""Get all pending queue items ordered by priority (highest first), then by creation time"""
return self.db.query(KnowledgeQueue)\
.filter(KnowledgeQueue.status == QueueStatus.PENDING)\
.order_by(KnowledgeQueue.priority.desc(), KnowledgeQueue.created_at.asc())\
.all()
def get_abandoned(self, last_progress_before: datetime) -> List[KnowledgeQueue]:
"""Items still 'processing' with no progress since `last_progress_before`,
locked so two workers never handle the same one."""
return self.db.query(KnowledgeQueue)\
.filter(KnowledgeQueue.status == QueueStatus.PROCESSING,
func.coalesce(KnowledgeQueue.updated_at, KnowledgeQueue.created_at) < last_progress_before)\
.with_for_update(skip_locked=True)\
.all()
def update_status(self, queue_id: int, status: QueueStatus, error: Optional[str] = None) -> bool:
item = self.db.query(KnowledgeQueue).filter(
KnowledgeQueue.id == queue_id).first()
if item:
item.status = status
item.error = error
# Update processing stage to COMPLETED when status is COMPLETED
if status == QueueStatus.COMPLETED:
item.processing_stage = "COMPLETED"
item.progress_percentage = 100.0
self.db.commit()
return True
return False
def update_progress(
self,
queue_id: int,
processing_stage: str = None,
progress_percentage: float = None,
total_items: int = None,
processed_items: int = None,
crawled_url: str = None,
force_stage_update: bool = False
) -> bool:
"""Update the progress of a queue item"""
item = self.db.query(KnowledgeQueue).filter(
KnowledgeQueue.id == queue_id).first()
if item:
# Define stage priority to prevent regression
stage_priority = {
"not_started": 0,
"crawling": 1,
"embedding": 2,
"completed": 3
}
# Only update processing stage if it's an advancement or forced
if processing_stage and (force_stage_update or not item.processing_stage or
stage_priority.get(processing_stage.lower(), 0) >= stage_priority.get(item.processing_stage.lower(), 0)):
item.processing_stage = processing_stage
# Update progress percentage using main columns
if progress_percentage is not None:
item.progress_percentage = progress_percentage
elif processed_items is not None and total_items is not None and total_items > 0:
# Auto-calculate percentage based on processed/total items
item.progress_percentage = (processed_items / total_items) * 100
if total_items is not None:
item.total_items = total_items
if processed_items is not None:
item.processed_items = processed_items
# Add crawled URL to the list if provided (store only URL strings)
if crawled_url:
if not item.crawled_urls:
item.crawled_urls = []
# Store only the URL string, no timestamp or status
if crawled_url not in item.crawled_urls:
# Create a new list to ensure SQLAlchemy detects the change
new_crawled_urls = list(item.crawled_urls) if item.crawled_urls else []
new_crawled_urls.append(crawled_url)
item.crawled_urls = new_crawled_urls
# Mark the attribute as modified to ensure SQLAlchemy commits the change
from sqlalchemy.orm.attributes import flag_modified
flag_modified(item, 'crawled_urls')
from app.core.logger import get_logger
logger = get_logger(__name__)
logger.debug(f"📝 Added crawled URL to queue {queue_id}: {crawled_url} (total: {len(item.crawled_urls)})")
else:
from app.core.logger import get_logger
logger = get_logger(__name__)
logger.debug(f"🔄 URL already in list for queue {queue_id}: {crawled_url}")
# Commit changes and verify
self.db.commit()
# If we added a crawled URL, verify it was saved
if crawled_url:
self.db.refresh(item)
from app.core.logger import get_logger
logger = get_logger(__name__)
logger.debug(f"🔍 After commit - queue {queue_id} crawled_urls count: {len(item.crawled_urls) if item.crawled_urls else 0}")
return True
return False
@@ -0,0 +1,94 @@
"""
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.orm import Session
from app.models.knowledge_to_agent import KnowledgeToAgent
from typing import List, Optional
from uuid import UUID
class KnowledgeToAgentRepository:
def __init__(self, db: Session):
self.db = db
def get_by_ids(self, knowledge_id: int, agent_id: UUID) -> Optional[KnowledgeToAgent]:
"""Get a link by knowledge_id and agent_id"""
return self.db.query(KnowledgeToAgent).filter(
KnowledgeToAgent.knowledge_id == knowledge_id,
KnowledgeToAgent.agent_id == agent_id
).first()
def get_by_agent(self, agent_id: UUID) -> List[KnowledgeToAgent]:
"""Get all knowledge sources for an agent"""
return self.db.query(KnowledgeToAgent).filter(
KnowledgeToAgent.agent_id == agent_id
).all()
def get_by_org(self, org_id: UUID) -> List[KnowledgeToAgent]:
"""Get all knowledge sources for an organization"""
return self.db.query(KnowledgeToAgent).filter(
KnowledgeToAgent.organization_id == org_id
).all()
def create(self, knowledge_source: KnowledgeToAgent, commit: bool = True) -> KnowledgeToAgent:
"""Create a new knowledge source.
Pass commit=False when the caller has more work that must land in the
same transaction — the vector-store filter update in particular, which
is what actually makes the link visible to retrieval.
"""
# Check if link already exists
existing = self.get_by_ids(
knowledge_source.knowledge_id,
knowledge_source.agent_id
)
if existing:
return existing
self.db.add(knowledge_source)
if commit:
self.db.commit()
self.db.refresh(knowledge_source)
else:
self.db.flush()
return knowledge_source
def delete(self, source_id: int) -> bool:
"""Delete a knowledge source"""
source = self.db.query(KnowledgeToAgent).filter(
KnowledgeToAgent.id == source_id
).first()
if source:
self.db.delete(source)
self.db.commit()
return True
return False
def delete_by_ids(self, knowledge_id: int, agent_id: UUID, commit: bool = True) -> bool:
"""Delete a link by knowledge and agent IDs.
See create() for why a caller may want commit=False.
"""
result = self.db.query(KnowledgeToAgent)\
.filter(
KnowledgeToAgent.knowledge_id == knowledge_id,
KnowledgeToAgent.agent_id == agent_id
).delete()
if commit:
self.db.commit()
else:
self.db.flush()
return bool(result)
+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.orm import Session
from typing import Optional
from uuid import UUID
from app.models.lead_capture import LeadCaptureConfig
from app.models.schemas.lead_capture import LeadCaptureConfigUpdate
class LeadCaptureConfigRepository:
def __init__(self, db: Session):
self.db = db
def get_by_agent(self, agent_id: UUID) -> Optional[LeadCaptureConfig]:
return (
self.db.query(LeadCaptureConfig)
.filter(LeadCaptureConfig.agent_id == agent_id)
.first()
)
def get_or_create(self, agent_id: UUID) -> LeadCaptureConfig:
"""Return the agent's config, lazily creating a default (disabled) row —
mirrors how AgentCustomization is fetched-or-created."""
config = self.get_by_agent(agent_id)
if config is None:
config = LeadCaptureConfig(agent_id=agent_id, enabled=False)
self.db.add(config)
self.db.commit()
self.db.refresh(config)
return config
def update(self, agent_id: UUID, data: LeadCaptureConfigUpdate) -> LeadCaptureConfig:
"""Full-replace the config from a validated payload."""
config = self.get_or_create(agent_id)
config.enabled = data.enabled
config.require_consent = data.require_consent
config.guidance = data.guidance
config.fields = [f.model_dump() for f in data.fields]
config.assignment_mode = data.assignment_mode
config.assignment_target_user_id = data.assignment_target_user_id
config.crm_sync_target = data.crm_sync_target
config.slack_notify_enabled = data.slack_notify_enabled
self.db.commit()
self.db.refresh(config)
return config
+203
View File
@@ -0,0 +1,203 @@
"""
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.orm import Session, joinedload
from typing import List, Optional
from app.models.agent import Agent
from app.models.mcp_tool import MCPTool, MCPToolToAgent, MCPTransportType
from app.models.ticket_settings import OrganizationTicketSettings
from uuid import UUID
from app.core.logger import get_logger
logger = get_logger(__name__)
class MCPToolRepository:
def __init__(self, db: Session):
self.db = db
def create_mcp_tool(self, **kwargs) -> MCPTool:
"""Create a new MCP tool"""
# Handle org_id to organization_id conversion for backward compatibility
if 'org_id' in kwargs:
kwargs['organization_id'] = kwargs.pop('org_id')
# Create new MCP tool
mcp_tool = MCPTool(**kwargs)
self.db.add(mcp_tool)
self.db.commit()
self.db.refresh(mcp_tool)
return mcp_tool
def get_by_name(self, name: str, organization_id: UUID) -> Optional[MCPTool]:
"""Get MCP tool by name within an organization"""
return self.db.query(MCPTool).filter(
MCPTool.name == name,
MCPTool.organization_id == organization_id
).first()
def get_mcp_tool(self, mcp_tool_id: int) -> Optional[MCPTool]:
"""Get MCP tool by ID"""
return self.db.query(MCPTool).filter(
MCPTool.id == mcp_tool_id
).first()
def get_by_ids(self, org_id: UUID, tool_ids: List[int], enabled_only: bool = True) -> List[MCPTool]:
"""Get specific MCP tools by id, scoped to the organization — ids
from another org are silently dropped."""
if not tool_ids:
return []
query = self.db.query(MCPTool).filter(
MCPTool.organization_id == org_id,
MCPTool.id.in_(tool_ids),
)
if enabled_only:
query = query.filter(MCPTool.enabled == True)
return query.all()
def get_org_mcp_tools(self, org_id: UUID, enabled_only: bool = True) -> List[MCPTool]:
"""Get all MCP tools for an organization"""
query = self.db.query(MCPTool).filter(
MCPTool.organization_id == org_id
)
if enabled_only:
query = query.filter(MCPTool.enabled == True)
return query.all()
def update_mcp_tool(self, mcp_tool_id: int, **kwargs) -> Optional[MCPTool]:
"""Update an existing MCP tool"""
mcp_tool = self.get_mcp_tool(mcp_tool_id)
if not mcp_tool:
return None
for key, value in kwargs.items():
setattr(mcp_tool, key, value)
self.db.commit()
self.db.refresh(mcp_tool)
return mcp_tool
def _get_ticket_settings(self, organization_id: UUID) -> Optional[OrganizationTicketSettings]:
return self.db.query(OrganizationTicketSettings).filter(
OrganizationTicketSettings.organization_id == organization_id
).first()
def is_used_in_investigations(self, mcp_tool_id: int, organization_id: UUID) -> bool:
"""Whether the org's investigation connector selection points at this tool."""
settings = self._get_ticket_settings(organization_id)
return bool(settings and mcp_tool_id in (settings.investigation_mcp_tool_ids or []))
def get_mcp_tool_agent_names(self, mcp_tool_id: int) -> List[str]:
"""Names of the agents this tool is linked to."""
rows = self.db.query(Agent.name)\
.join(MCPToolToAgent, MCPToolToAgent.agent_id == Agent.id)\
.filter(MCPToolToAgent.mcp_tool_id == mcp_tool_id)\
.order_by(Agent.name)\
.all()
return [name for (name,) in rows]
def delete_mcp_tool(self, mcp_tool_id: int) -> bool:
"""Delete an MCP tool, along with every reference to it."""
mcp_tool = self.get_mcp_tool(mcp_tool_id)
if not mcp_tool:
return False
# investigation_mcp_tool_ids is a plain JSON id list with no foreign
# key, so a deleted connector would otherwise stay "configured"
# forever and every later run would report fewer connectors loaded
# than configured — the exact signal #271 added to catch real
# failures. Agent links go with the tool via cascade.
settings = self._get_ticket_settings(mcp_tool.organization_id)
if settings and mcp_tool_id in (settings.investigation_mcp_tool_ids or []):
settings.investigation_mcp_tool_ids = [
tool_id for tool_id in settings.investigation_mcp_tool_ids
if tool_id != mcp_tool_id
]
self.db.delete(mcp_tool)
self.db.commit()
return True
def get_enabled_mcp_tools(self, org_id: UUID) -> List[MCPTool]:
"""Get enabled MCP tools for an organization"""
return self.db.query(MCPTool)\
.filter(MCPTool.organization_id == org_id)\
.filter(MCPTool.enabled == True)\
.all()
def get_all_mcp_tools(self, org_id: UUID) -> List[MCPTool]:
"""Get all MCP tools for organization"""
return self.db.query(MCPTool)\
.filter(MCPTool.organization_id == org_id)\
.all()
# Agent-MCPTool association methods
def add_mcp_tool_to_agent(self, mcp_tool_id: int, agent_id: UUID) -> Optional[MCPToolToAgent]:
"""Add MCP tool to agent"""
# Check if association already exists
existing = self.db.query(MCPToolToAgent).filter(
MCPToolToAgent.mcp_tool_id == mcp_tool_id,
MCPToolToAgent.agent_id == agent_id
).first()
if existing:
return existing
# Create new association
association = MCPToolToAgent(
mcp_tool_id=mcp_tool_id,
agent_id=agent_id
)
self.db.add(association)
self.db.commit()
self.db.refresh(association)
return association
def remove_mcp_tool_from_agent(self, mcp_tool_id: int, agent_id: UUID) -> bool:
"""Remove MCP tool from agent"""
association = self.db.query(MCPToolToAgent).filter(
MCPToolToAgent.mcp_tool_id == mcp_tool_id,
MCPToolToAgent.agent_id == agent_id
).first()
if not association:
return False
self.db.delete(association)
self.db.commit()
return True
def get_agent_mcp_tools(self, agent_id: UUID) -> List[MCPTool]:
"""Get all MCP tools associated with an agent"""
return self.db.query(MCPTool)\
.join(MCPToolToAgent)\
.filter(MCPToolToAgent.agent_id == agent_id)\
.filter(MCPTool.enabled == True)\
.all()
def get_mcp_tool_agents(self, mcp_tool_id: int) -> List[UUID]:
"""Get all agent IDs associated with an MCP tool"""
associations = self.db.query(MCPToolToAgent).filter(
MCPToolToAgent.mcp_tool_id == mcp_tool_id
).all()
return [assoc.agent_id for assoc in associations]
def get_mcp_tool_with_agents(self, mcp_tool_id: int) -> Optional[MCPTool]:
"""Get MCP tool with associated agents loaded"""
return self.db.query(MCPTool)\
.options(joinedload(MCPTool.agent_links))\
.filter(MCPTool.id == mcp_tool_id)\
.first()
@@ -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 typing import Iterable, List, Optional, Set
from uuid import UUID
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.utils.uuids import as_uuid
from app.models.notification_settings import (
NOTIFICATION_PREFERENCE_DEFAULTS,
UserNotificationSettings,
)
class UserNotificationSettingsRepository:
def __init__(self, db: Session):
self.db = db
def get(self, user_id: UUID | str) -> Optional[UserNotificationSettings]:
return (
self.db.query(UserNotificationSettings)
.filter(UserNotificationSettings.user_id == as_uuid(user_id))
.first()
)
def get_or_create(self, user_id: UUID | str) -> UserNotificationSettings:
settings = self.get(user_id)
if settings is None:
settings = UserNotificationSettings(user_id=as_uuid(user_id))
self.db.add(settings)
try:
self.db.commit()
self.db.refresh(settings)
except IntegrityError:
# Two concurrent first-reads (e.g. two tabs) race on the
# user_id PK; the loser re-reads the row the winner inserted
# rather than surfacing a 500.
self.db.rollback()
settings = self.get(user_id)
return settings
def filter_enabled(self, user_ids: Iterable[UUID | str], field: str) -> Set[UUID]:
"""Of `user_ids`, return those who want notifications for `field`.
One query regardless of recipient count — transfer fans out to a whole
group and new chats fan out across the org. Users with no row yet fall
back to the column default rather than being dropped, so nothing has to
be backfilled.
"""
if field not in NOTIFICATION_PREFERENCE_DEFAULTS:
raise ValueError(f"Unknown notification preference: {field}")
candidates = {as_uuid(user_id) for user_id in user_ids if user_id}
if not candidates:
return set()
rows = (
self.db.query(
UserNotificationSettings.user_id,
getattr(UserNotificationSettings, field),
)
.filter(UserNotificationSettings.user_id.in_(candidates))
.all()
)
stored = {user_id: enabled for user_id, enabled in rows}
default = NOTIFICATION_PREFERENCE_DEFAULTS[field]
return {
user_id for user_id in candidates
if stored.get(user_id, default)
}
+165
View File
@@ -0,0 +1,165 @@
"""
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 func
from sqlalchemy.orm import Session
from typing import Optional, List
from app.models.organization import Organization
from app.core.logger import get_logger
logger = get_logger(__name__)
from uuid import UUID
class OrganizationRepository:
def __init__(self, db: Session):
self.db = db
def create_organization(
self,
name: str,
domain: str,
timezone: str = 'UTC',
business_hours: Optional[dict] = None
) -> Organization:
"""Create a new organization"""
try:
organization = Organization(
name=name,
domain=domain,
timezone=timezone,
business_hours=business_hours
)
self.db.add(organization)
self.db.flush() # Get organization.id
return organization
except Exception as e:
logger.error(f"Failed to create organization: {str(e)}")
self.db.rollback()
raise
def get_organization(self, org_id: UUID) -> Optional[Organization]:
"""Get organization by ID"""
return self.db.query(Organization).filter(
Organization.id == org_id
).first()
def get_organization_by_domain(self, domain: str) -> Optional[Organization]:
"""Get organization by domain"""
return self.db.query(Organization).filter(
Organization.domain == domain
).first()
def is_domain_taken(self, domain: str, exclude_org_id: Optional[UUID] = None) -> bool:
"""Whether another organization already uses this domain, ignoring case"""
query = self.db.query(Organization.id).filter(
func.lower(Organization.domain) == domain.lower()
)
if exclude_org_id is not None:
query = query.filter(Organization.id != exclude_org_id)
return query.first() is not None
def get_active_organizations(self) -> List[Organization]:
"""Get all active organizations"""
return self.db.query(Organization).filter(
Organization.is_active == True
).all()
def update_organization(
self,
org_id: UUID,
name: Optional[str] = None,
domain: Optional[str] = None,
settings: Optional[dict] = None,
is_active: Optional[bool] = None
) -> Optional[Organization]:
"""Update an organization"""
try:
organization = self.get_organization(org_id)
if not organization:
return None
if name is not None:
organization.name = name
if domain is not None:
organization.domain = domain
if settings is not None:
# Merge into a NEW dict: the column default is a shared mutable
# {} and an in-place update is invisible to the unit of work
organization.settings = {**(organization.settings or {}), **settings}
if is_active is not None:
organization.is_active = is_active
self.db.commit()
self.db.refresh(organization)
return organization
except Exception as e:
logger.error(f"Failed to update organization {org_id}: {str(e)}")
self.db.rollback()
raise
def update_settings(self, org_id: UUID, settings: dict) -> Optional[Organization]:
"""Update organization settings"""
try:
organization = self.get_organization(org_id)
if not organization:
return None
# Merge into a NEW dict (shared mutable default, see update_organization)
organization.settings = {**(organization.settings or {}), **settings}
self.db.commit()
self.db.refresh(organization)
return organization
except Exception as e:
logger.error(f"Failed to update settings for org {
org_id}: {str(e)}")
self.db.rollback()
raise
def deactivate_organization(self, org_id: UUID) -> bool:
"""Deactivate an organization"""
try:
organization = self.get_organization(org_id)
if not organization:
return False
organization.is_active = False
self.db.commit()
return True
except Exception as e:
logger.error(f"Failed to deactivate org {org_id}: {str(e)}")
self.db.rollback()
raise
def delete_organization(self, org_id: UUID) -> bool:
"""Hard delete an organization (use with caution)"""
try:
organization = self.get_organization(org_id)
if not organization:
return False
self.db.delete(organization)
self.db.commit()
return True
except Exception as e:
logger.error(f"Failed to delete org {org_id}: {str(e)}")
self.db.rollback()
raise
+422
View File
@@ -0,0 +1,422 @@
"""
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.orm import Session
import re
from sqlalchemy import and_, func, or_, desc, exists
from sqlalchemy.exc import IntegrityError
from typing import Optional, Tuple, List
from uuid import UUID
from datetime import datetime, timedelta, timezone
from app.repositories.customer import (
CustomerRepository,
PHONE_KEYED_CHANNELS,
PHONE_KEYED_CHANNEL_SUFFIXES,
)
from app.utils.phone import normalize_phone
from app.models.customer import Customer, LeadStage
from app.models.channels.channel_conversation import ChannelConversation
from app.models.chat_history import ChatHistory
from app.models.lead_capture import LeadCaptureResponse
from app.models.session_to_agent import SessionToAgent
from app.models.agent import Agent
from app.repositories.chat import ChatRepository
class PeopleRepository:
def __init__(self, db: Session):
self.db = db
def _is_anonymous(self, customer: Customer) -> bool:
return CustomerRepository.is_placeholder_email(customer.email) and not (customer.full_name or "").strip()
def _captured_contact_map(self, customer_ids) -> dict:
"""customer_id -> {'email':.., 'name':..} from qualifying lead responses.
Used so a captured lead never shows as "Anonymous" when its email/name
could not be written onto the customer row (e.g. the email already belongs
to another customer). One query for the whole page — no N+1. Latest wins.
"""
ids = [cid for cid in customer_ids if cid]
if not ids:
return {}
rows = (
self.db.query(LeadCaptureResponse.customer_id, LeadCaptureResponse.field_values)
.filter(LeadCaptureResponse.customer_id.in_(ids),
LeadCaptureResponse.qualified.is_(True))
.order_by(LeadCaptureResponse.created_at)
.all()
)
out: dict = {}
for cid, fv in rows:
if not fv:
continue
d = out.setdefault(cid, {})
if fv.get("email"):
d["email"] = fv["email"]
if fv.get("name"):
d["name"] = fv["name"]
return out
def _resolve_display(self, customer: Customer, captured: Optional[dict]):
"""Return (name, email, is_anonymous), preferring the customer's own contact
and falling back to the captured lead's email/name when the record is bare."""
if not self._is_anonymous(customer):
# display_email: a channel person's `{id}@{channel}.channel` key is
# not an address and must not be shown as one.
return customer.full_name, CustomerRepository.display_email(customer.email), False
captured = captured or {}
email = captured.get("email")
name = (customer.full_name or "").strip() or captured.get("name")
if email or name:
return name, email, False
return customer.full_name, None, True
# A person is IDENTIFIED when something could actually reach or recognize
# them: a real email (not the @noemail.com widget placeholder, not a
# synthesized {id}@{channel}.channel address), a phone, or a qualifying
# lead capture. Deliberately NOT name — name is never an identity key.
# The SQL restatement of CustomerRepository.is_placeholder_email — the one
# copy that cannot be avoided, since this runs in the database. Its three
# clauses mirror that function's three: empty, @noemail.com, .channel.
_REAL_EMAIL = and_(Customer.email != "",
Customer.email.notilike("%@noemail.com"),
Customer.email.notilike("%.channel"))
def _identified(self, qualified_cid):
return or_(self._REAL_EMAIL, Customer.phone.isnot(None),
qualified_cid.isnot(None))
@staticmethod
def _search_clauses(search: str):
"""Name/email substring match, plus phone when the term looks like a
number — matching on digits so "+91 12345" and "9112345" both hit."""
term = search.strip()
like = f"%{term}%"
clauses = [Customer.full_name.ilike(like), Customer.email.ilike(like)]
digits = re.sub(r"[\s\-().]", "", term)
if re.fullmatch(r"\+?\d{3,}", digits):
clauses.append(Customer.phone.like(f"%{digits.lstrip('+')}%"))
return or_(*clauses)
def list_people(
self, org_id: UUID, stage: Optional[str] = None, search: Optional[str] = None,
page: int = 1, page_size: int = 20, view: str = "identified",
) -> Tuple[List[dict], int]:
# Latest activity per customer (one aggregate row per customer — no N+1).
last_activity_sq = (
self.db.query(
ChatHistory.customer_id.label("cid"),
func.max(ChatHistory.created_at).label("last"),
)
.filter(ChatHistory.organization_id == org_id)
.group_by(ChatHistory.customer_id)
.subquery()
)
# Customers who have at least one qualifying capture.
qualified_sq = (
self.db.query(LeadCaptureResponse.customer_id.label("cid"))
.filter(
LeadCaptureResponse.organization_id == org_id,
LeadCaptureResponse.qualified.is_(True),
)
.distinct()
.subquery()
)
q = (
self.db.query(Customer, last_activity_sq.c.last, qualified_sq.c.cid)
.outerjoin(last_activity_sq, last_activity_sq.c.cid == Customer.id)
.outerjoin(qualified_sq, qualified_sq.c.cid == Customer.id)
.filter(Customer.organization_id == org_id)
# Hide rows merged into another customer — the target row represents them.
.filter(Customer.merged_into_customer_id.is_(None))
# Exclude integration-authenticated people (identified via generate-token) —
# they're the business's existing customers, not leads the agent captured.
.filter(Customer.is_authenticated.is_(False))
# Only people who actually engaged (have chat history) — drops the huge tail of
# empty widget-loads that created a customer but never sent a message.
.filter(last_activity_sq.c.cid.isnot(None))
)
# Identity split: the directory shows identified people; anonymous
# browser sessions are a funnel signal behind an explicit view, not
# the page (prod data: 5,873 of one org's 5,876 rows were anonymous).
if view == "anonymous":
q = q.filter(~self._identified(qualified_sq.c.cid))
else:
q = q.filter(self._identified(qualified_sq.c.cid))
if stage and stage != "all":
try:
q = q.filter(Customer.lead_stage == LeadStage(stage))
except ValueError:
pass # unknown stage → no filter
if search:
q = q.filter(self._search_clauses(search))
total = q.count()
rows = (
q.order_by(desc(func.coalesce(last_activity_sq.c.last, Customer.created_at)))
.offset(max(page - 1, 0) * page_size)
.limit(page_size)
.all()
)
contact_map = self._captured_contact_map([c.id for c, _, _ in rows])
items = []
for customer, last, qcid in rows:
name, email, anon = self._resolve_display(customer, contact_map.get(customer.id))
items.append({
"id": customer.id,
"name": name,
"email": email,
"phone": customer.phone,
"is_anonymous": anon,
"lead_stage": customer.lead_stage,
"qualified": qcid is not None,
"source": customer.lead_source,
"captured_at": customer.lead_qualified_at,
"last_activity": last,
"synced": False,
})
return items, total
def get_stats(self, org_id: UUID) -> dict:
week_ago = datetime.now(timezone.utc) - timedelta(days=7)
# Same scoping as list_people: exclude merged rows and integration-authenticated
# (generate-token) customers so the KPIs count only organic visitors/leads.
not_merged = Customer.merged_into_customer_id.is_(None)
organic = Customer.is_authenticated.is_(False)
# Engaged = actually chatted (has chat history) — excludes empty widget-loads.
engaged = exists().where(ChatHistory.customer_id == Customer.id)
# "Identified" for stats mirrors list_people's rule; qualified capture
# is expressed as an EXISTS here rather than the list's join.
has_capture = exists().where(and_(
LeadCaptureResponse.customer_id == Customer.id,
LeadCaptureResponse.qualified.is_(True)))
identified = or_(self._REAL_EMAIL, Customer.phone.isnot(None), has_capture)
total = self.db.query(func.count(Customer.id)).filter(
Customer.organization_id == org_id, not_merged, organic, engaged,
identified).scalar() or 0
anonymous = self.db.query(func.count(Customer.id)).filter(
Customer.organization_id == org_id, not_merged, organic, engaged,
~identified).scalar() or 0
new_leads = self.db.query(func.count(Customer.id)).filter(
Customer.organization_id == org_id,
not_merged, organic,
Customer.lead_stage == LeadStage.LEAD,
Customer.lead_qualified_at >= week_ago,
).scalar() or 0
customers = self.db.query(func.count(Customer.id)).filter(
Customer.organization_id == org_id,
not_merged, organic,
Customer.lead_stage == LeadStage.CUSTOMER,
).scalar() or 0
return {
"total_people": total,
"anonymous": anonymous,
"new_leads_7d": new_leads,
"customers": customers,
"synced_to_crm": 0,
}
def get_customer(self, org_id: UUID, customer_id: UUID) -> Optional[Customer]:
customer = self.db.query(Customer).filter(
Customer.id == customer_id, Customer.organization_id == org_id,
).first()
# Follow the merge pointer so stale links open the surviving record.
seen = set()
while customer and customer.merged_into_customer_id and customer.id not in seen:
seen.add(customer.id)
customer = self.db.query(Customer).filter(
Customer.id == customer.merged_into_customer_id,
Customer.organization_id == org_id,
).first()
return customer
def get_detail(self, org_id: UUID, customer_id: UUID) -> Optional[dict]:
customer = self.get_customer(org_id, customer_id)
if not customer:
return None
customer_id = customer.id # may differ if a merged row was requested
responses = (
self.db.query(LeadCaptureResponse)
.filter(LeadCaptureResponse.customer_id == customer_id)
.order_by(LeadCaptureResponse.created_at)
.all()
)
# Merge the fields captured across submissions; latest AI summary wins.
attrs: dict = {}
qualified = False
summary = None
for r in responses:
qualified = qualified or bool(r.qualified)
for k, v in (r.field_values or {}).items():
attrs[k] = v
if r.summary:
summary = r.summary
name, email, anon = self._resolve_display(customer, attrs)
return {
"id": customer.id,
"name": name,
"email": email,
"phone": customer.phone,
"identified": self.is_identified(customer, qualified),
"is_anonymous": anon,
"lead_stage": customer.lead_stage,
"qualified": qualified,
"source": customer.lead_source,
"created_at": customer.created_at,
"lead_qualified_at": customer.lead_qualified_at,
"meta_data": customer.meta_data,
"summary": summary,
"captured_attributes": attrs,
"timeline": self._timeline(customer),
"conversations": self._conversations(customer_id),
}
def _timeline(self, customer: Customer) -> List[dict]:
entries = [{"stage": "visitor", "at": customer.created_at}]
if customer.lead_qualified_at:
entries.append({"stage": "lead", "at": customer.lead_qualified_at})
# No dedicated became-customer timestamp in phase 1; approximate with updated_at.
if customer.lead_stage == LeadStage.CUSTOMER and customer.updated_at:
entries.append({"stage": "customer", "at": customer.updated_at})
return [e for e in entries if e["at"] is not None]
def _conversations(self, customer_id: UUID) -> List[dict]:
sessions = (
self.db.query(SessionToAgent)
.filter(SessionToAgent.customer_id == customer_id)
.order_by(desc(SessionToAgent.assigned_at))
.limit(20)
.all()
)
# One query for all previews, sharing the chat inbox's definition of
# "most recent message" rather than restating it here.
last_messages = ChatRepository(self.db).get_last_messages(
[s.session_id for s in sessions])
out = []
for s in sessions:
agent = self.db.query(Agent).filter(Agent.id == s.agent_id).first() if s.agent_id else None
out.append({
"session_id": s.session_id,
"agent_name": (agent.display_name or agent.name) if agent else None,
"status": s.status.value if s.status else None,
"last_message": last_messages.get(s.session_id),
"created_at": s.assigned_at,
})
return out
@staticmethod
def is_identified(customer: Customer, qualified: bool = False) -> bool:
"""Python-side twin of the SQL identity rule (_identified), for single
records. Defers to is_placeholder_email so the rule for what counts as
a real address lives in exactly one place — _REAL_EMAIL below is its
unavoidable SQL restatement, and a third hand-rolled copy here is how
the three quietly drift apart."""
real_email = not CustomerRepository.is_placeholder_email(customer.email)
return bool(real_email or customer.phone or qualified)
def _has_qualified_capture(self, customer_id: UUID) -> bool:
return self.db.query(
exists().where(and_(LeadCaptureResponse.customer_id == customer_id,
LeadCaptureResponse.qualified.is_(True)))
).scalar()
def _phone_is_sole_identity_key(self, customer: Customer) -> bool:
"""True when losing the phone would make this person unfindable.
A WhatsApp/SMS person starts out keyed by BOTH their synthesized
`{number}@whatsapp.channel` address and their phone. Once a capture
upgrades that address to a real email (which update_contact allows
precisely because the phone is a key), the phone is all that is left
for inbound routing: nothing about a WhatsApp message carries an email.
Change or clear the phone in that state and their next message finds
nobody and mints a duplicate — the exact failure the phone key exists
to prevent.
"""
if not customer.phone:
return False
if (customer.email or "").endswith(PHONE_KEYED_CHANNEL_SUFFIXES):
return False # still findable by their synthesized address
return self.db.query(
exists().where(and_(
ChannelConversation.customer_id == customer.id,
ChannelConversation.channel_type.in_(PHONE_KEYED_CHANNELS),
))
).scalar()
def update_person(self, org_id: UUID, customer_id: UUID,
full_name: Optional[str] = None,
phone: Optional[str] = None) -> Tuple[Optional[Customer], Optional[str]]:
"""Explicit human edit of a person's name/phone from the drawer.
Unlike the automatic capture paths (set-if-absent), a human may
CORRECT a wrong phone — overwrite is allowed, clearing via "" too.
Returns (customer, error): error is a human-readable refusal (bad
format / number belongs to someone else / it is their only identity),
customer is None only when the record doesn't exist.
"""
customer = self.get_customer(org_id, customer_id)
if not customer:
return None, None
if phone is not None:
normalized = None if phone.strip() == "" else normalize_phone(phone)
if phone.strip() != "" and not normalized:
return customer, "Enter the number in international format, e.g. +91 12345 67890"
if normalized != customer.phone and self._phone_is_sole_identity_key(customer):
return customer, (
"This number is how their WhatsApp messages find them — changing "
"it here would split them into a second person. Ask them to "
"message from the new number instead."
)
if normalized is not None:
other = CustomerRepository(self.db).get_customer_by_phone(normalized, org_id)
if other and other.id != customer.id:
return customer, "That number already belongs to another person"
customer.phone = normalized
if full_name is not None and full_name.strip():
customer.full_name = full_name.strip()
try:
self.db.commit()
except IntegrityError:
# The uniqueness check above is not atomic with the write: two
# agents assigning the same number concurrently both pass it. The
# index is the real arbiter — report its refusal the same way.
self.db.rollback()
return customer, "That number already belongs to another person"
self.db.refresh(customer)
return customer, None
def mark_customer(self, org_id: UUID, customer_id: UUID) -> Optional[Customer]:
customer = self.get_customer(org_id, customer_id)
if not customer:
return None
customer.lead_stage = LeadStage.CUSTOMER
self.db.commit()
self.db.refresh(customer)
return customer
+97
View File
@@ -0,0 +1,97 @@
"""
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.orm import Session
from app.models.rating import Rating
from typing import Optional, List
from uuid import UUID
from sqlalchemy import func
class RatingRepository:
def __init__(self, db: Session):
self.db = db
def create_rating(self, session_id: UUID, customer_id: UUID, user_id: UUID, agent_id: UUID,
organization_id: UUID, rating: int, feedback: Optional[str] = None) -> Rating:
"""Create a new rating"""
db_rating = Rating(
session_id=session_id,
customer_id=customer_id,
user_id=user_id,
agent_id=agent_id,
organization_id=organization_id,
rating=rating,
feedback=feedback
)
self.db.add(db_rating)
self.db.commit()
self.db.refresh(db_rating)
return db_rating
def upsert_rating(self, session_id: UUID, customer_id: UUID, user_id: UUID, agent_id: UUID,
organization_id: UUID, rating: int, feedback: Optional[str] = None) -> Rating:
"""Record the customer's rating for a session, replacing any earlier one.
One row per session: the widget can re-show the rating prompt, and
without this a customer could pile up rows that skew agent/org averages
and, since the score now flows onto the ticket, spam its activity feed.
"""
existing = self.get_rating_by_session(session_id)
if existing is None:
return self.create_rating(
session_id=session_id, customer_id=customer_id, user_id=user_id,
agent_id=agent_id, organization_id=organization_id,
rating=rating, feedback=feedback,
)
existing.rating = rating
existing.feedback = feedback
self.db.commit()
self.db.refresh(existing)
return existing
def get_rating_by_session(self, session_id: UUID) -> Optional[Rating]:
"""Get rating by session ID"""
return self.db.query(Rating).filter(Rating.session_id == session_id).first()
def get_ratings_by_agent(self, agent_id: UUID) -> List[Rating]:
"""Get all ratings for an agent"""
return self.db.query(Rating).filter(Rating.agent_id == agent_id).all()
def get_ratings_by_customer(self, customer_id: UUID) -> List[Rating]:
"""Get all ratings from a customer"""
return self.db.query(Rating).filter(Rating.customer_id == customer_id).all()
def get_average_rating_by_agent(self, agent_id: UUID) -> float:
"""Get average rating for an agent"""
result = self.db.query(func.avg(Rating.rating)).filter(
Rating.agent_id == agent_id
).scalar()
return float(result) if result else 0.0
def get_organization_ratings(self, organization_id: UUID,
limit: int = 100, offset: int = 0) -> List[Rating]:
"""Get all ratings for an organization with pagination"""
return self.db.query(Rating).filter(
Rating.organization_id == organization_id
).order_by(Rating.created_at.desc()).offset(offset).limit(limit).all()
def get_organization_average_rating(self, organization_id: UUID) -> float:
"""Get average rating for an organization"""
result = self.db.query(func.avg(Rating.rating)).filter(
Rating.organization_id == organization_id
).scalar()
return float(result) if result else 0.0
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff. Show more