diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
new file mode 100644
index 0000000..8a49f78
--- /dev/null
+++ b/backend/app/models/__init__.py
@@ -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",
+]
diff --git a/backend/app/models/agent.py b/backend/app/models/agent.py
new file mode 100644
index 0000000..786b91f
--- /dev/null
+++ b/backend/app/models/agent.py
@@ -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
diff --git a/backend/app/models/ai_config.py b/backend/app/models/ai_config.py
new file mode 100644
index 0000000..ced7605
--- /dev/null
+++ b/backend/app/models/ai_config.py
@@ -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")
diff --git a/backend/app/models/auth_session.py b/backend/app/models/auth_session.py
new file mode 100644
index 0000000..290d472
--- /dev/null
+++ b/backend/app/models/auth_session.py
@@ -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)
diff --git a/backend/app/models/channels/__init__.py b/backend/app/models/channels/__init__.py
new file mode 100644
index 0000000..4b45b91
--- /dev/null
+++ b/backend/app/models/channels/__init__.py
@@ -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",
+]
diff --git a/backend/app/models/channels/agent_channel_config.py b/backend/app/models/channels/agent_channel_config.py
new file mode 100644
index 0000000..d69e8ef
--- /dev/null
+++ b/backend/app/models/channels/agent_channel_config.py
@@ -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")
diff --git a/backend/app/models/channels/channel_account.py b/backend/app/models/channels/channel_account.py
new file mode 100644
index 0000000..affe41f
--- /dev/null
+++ b/backend/app/models/channels/channel_account.py
@@ -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")
diff --git a/backend/app/models/channels/channel_conversation.py b/backend/app/models/channels/channel_conversation.py
new file mode 100644
index 0000000..db43b35
--- /dev/null
+++ b/backend/app/models/channels/channel_conversation.py
@@ -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")
diff --git a/backend/app/models/chat_history.py b/backend/app/models/chat_history.py
new file mode 100644
index 0000000..6bc220f
--- /dev/null
+++ b/backend/app/models/chat_history.py
@@ -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")
+
diff --git a/backend/app/models/crm.py b/backend/app/models/crm.py
new file mode 100644
index 0000000..f29f266
--- /dev/null
+++ b/backend/app/models/crm.py
@@ -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")
diff --git a/backend/app/models/customer.py b/backend/app/models/customer.py
new file mode 100644
index 0000000..82af4f8
--- /dev/null
+++ b/backend/app/models/customer.py
@@ -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")
\ No newline at end of file
diff --git a/backend/app/models/faq.py b/backend/app/models/faq.py
new file mode 100644
index 0000000..69568d4
--- /dev/null
+++ b/backend/app/models/faq.py
@@ -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
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),
+ )
diff --git a/backend/app/models/faq_generation_job.py b/backend/app/models/faq_generation_job.py
new file mode 100644
index 0000000..3fe3661
--- /dev/null
+++ b/backend/app/models/faq_generation_job.py
@@ -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.
+ )
diff --git a/backend/app/models/fcm_token.py b/backend/app/models/fcm_token.py
new file mode 100644
index 0000000..7321677
--- /dev/null
+++ b/backend/app/models/fcm_token.py
@@ -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")
diff --git a/backend/app/models/file_attachment.py b/backend/app/models/file_attachment.py
new file mode 100644
index 0000000..b14f53e
--- /dev/null
+++ b/backend/app/models/file_attachment.py
@@ -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])
diff --git a/backend/app/models/guardrail_event.py b/backend/app/models/guardrail_event.py
new file mode 100644
index 0000000..ab78121
--- /dev/null
+++ b/backend/app/models/guardrail_event.py
@@ -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())
diff --git a/backend/app/models/help_center.py b/backend/app/models/help_center.py
new file mode 100644
index 0000000..9742eb5
--- /dev/null
+++ b/backend/app/models/help_center.py
@@ -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)
diff --git a/backend/app/models/investigation.py b/backend/app/models/investigation.py
new file mode 100644
index 0000000..969351f
--- /dev/null
+++ b/backend/app/models/investigation.py
@@ -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"),
+ )
diff --git a/backend/app/models/jira.py b/backend/app/models/jira.py
new file mode 100644
index 0000000..d4556c0
--- /dev/null
+++ b/backend/app/models/jira.py
@@ -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)
\ No newline at end of file
diff --git a/backend/app/models/knowledge.py b/backend/app/models/knowledge.py
new file mode 100644
index 0000000..e76f1b7
--- /dev/null
+++ b/backend/app/models/knowledge.py
@@ -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")
diff --git a/backend/app/models/knowledge_queue.py b/backend/app/models/knowledge_queue.py
new file mode 100644
index 0000000..734c4f2
--- /dev/null
+++ b/backend/app/models/knowledge_queue.py
@@ -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")
\ No newline at end of file
diff --git a/backend/app/models/knowledge_to_agent.py b/backend/app/models/knowledge_to_agent.py
new file mode 100644
index 0000000..822f374
--- /dev/null
+++ b/backend/app/models/knowledge_to_agent.py
@@ -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")
diff --git a/backend/app/models/lead_capture.py b/backend/app/models/lead_capture.py
new file mode 100644
index 0000000..95c9364
--- /dev/null
+++ b/backend/app/models/lead_capture.py
@@ -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")
diff --git a/backend/app/models/mcp_tool.py b/backend/app/models/mcp_tool.py
new file mode 100644
index 0000000..34dcf75
--- /dev/null
+++ b/backend/app/models/mcp_tool.py
@@ -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")
\ No newline at end of file
diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py
new file mode 100644
index 0000000..e91f639
--- /dev/null
+++ b/backend/app/models/notification.py
@@ -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")
diff --git a/backend/app/models/notification_settings.py b/backend/app/models/notification_settings.py
new file mode 100644
index 0000000..e02d7c9
--- /dev/null
+++ b/backend/app/models/notification_settings.py
@@ -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")
diff --git a/backend/app/models/organization.py b/backend/app/models/organization.py
new file mode 100644
index 0000000..5f75544
--- /dev/null
+++ b/backend/app/models/organization.py
@@ -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
diff --git a/backend/app/models/permission.py b/backend/app/models/permission.py
new file mode 100644
index 0000000..854b747
--- /dev/null
+++ b/backend/app/models/permission.py
@@ -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")
+ ]
diff --git a/backend/app/models/rating.py b/backend/app/models/rating.py
new file mode 100644
index 0000000..bd73413
--- /dev/null
+++ b/backend/app/models/rating.py
@@ -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")
\ No newline at end of file
diff --git a/backend/app/models/role.py b/backend/app/models/role.py
new file mode 100644
index 0000000..29b18a7
--- /dev/null
+++ b/backend/app/models/role.py
@@ -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")
diff --git a/backend/app/models/schemas/__init__.py b/backend/app/models/schemas/__init__.py
new file mode 100644
index 0000000..a6b511e
--- /dev/null
+++ b/backend/app/models/schemas/__init__.py
@@ -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'
+]
diff --git a/backend/app/models/schemas/agent.py b/backend/app/models/schemas/agent.py
new file mode 100644
index 0000000..fd01d8d
--- /dev/null
+++ b/backend/app/models/schemas/agent.py
@@ -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] = []
diff --git a/backend/app/models/schemas/agent_customization.py b/backend/app/models/schemas/agent_customization.py
new file mode 100644
index 0000000..dcd8b76
--- /dev/null
+++ b/backend/app/models/schemas/agent_customization.py
@@ -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
diff --git a/backend/app/models/schemas/ai_config.py b/backend/app/models/schemas/ai_config.py
new file mode 100644
index 0000000..eee6923
--- /dev/null
+++ b/backend/app/models/schemas/ai_config.py
@@ -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
diff --git a/backend/app/models/schemas/channel.py b/backend/app/models/schemas/channel.py
new file mode 100644
index 0000000..491178f
--- /dev/null
+++ b/backend/app/models/schemas/channel.py
@@ -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
diff --git a/backend/app/models/schemas/chat.py b/backend/app/models/schemas/chat.py
new file mode 100644
index 0000000..18f3afc
--- /dev/null
+++ b/backend/app/models/schemas/chat.py
@@ -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]
diff --git a/backend/app/models/schemas/crm.py b/backend/app/models/schemas/crm.py
new file mode 100644
index 0000000..d277a2c
--- /dev/null
+++ b/backend/app/models/schemas/crm.py
@@ -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]
diff --git a/backend/app/models/schemas/faq.py b/backend/app/models/schemas/faq.py
new file mode 100644
index 0000000..f183a50
--- /dev/null
+++ b/backend/app/models/schemas/faq.py
@@ -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
diff --git a/backend/app/models/schemas/help_center.py b/backend/app/models/schemas/help_center.py
new file mode 100644
index 0000000..d68c7a7
--- /dev/null
+++ b/backend/app/models/schemas/help_center.py
@@ -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
diff --git a/backend/app/models/schemas/investigation.py b/backend/app/models/schemas/investigation.py
new file mode 100644
index 0000000..b191b6f
--- /dev/null
+++ b/backend/app/models/schemas/investigation.py
@@ -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,
+}
diff --git a/backend/app/models/schemas/jira.py b/backend/app/models/schemas/jira.py
new file mode 100644
index 0000000..6580aa8
--- /dev/null
+++ b/backend/app/models/schemas/jira.py
@@ -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
\ No newline at end of file
diff --git a/backend/app/models/schemas/lead_capture.py b/backend/app/models/schemas/lead_capture.py
new file mode 100644
index 0000000..7e46d20
--- /dev/null
+++ b/backend/app/models/schemas/lead_capture.py
@@ -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 []
diff --git a/backend/app/models/schemas/mcp_tool.py b/backend/app/models/schemas/mcp_tool.py
new file mode 100644
index 0000000..9d13991
--- /dev/null
+++ b/backend/app/models/schemas/mcp_tool.py
@@ -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
\ No newline at end of file
diff --git a/backend/app/models/schemas/notification.py b/backend/app/models/schemas/notification.py
new file mode 100644
index 0000000..8b18a57
--- /dev/null
+++ b/backend/app/models/schemas/notification.py
@@ -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
diff --git a/backend/app/models/schemas/organization.py b/backend/app/models/schemas/organization.py
new file mode 100644
index 0000000..3896955
--- /dev/null
+++ b/backend/app/models/schemas/organization.py
@@ -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
+
diff --git a/backend/app/models/schemas/pagination.py b/backend/app/models/schemas/pagination.py
new file mode 100644
index 0000000..6a29827
--- /dev/null
+++ b/backend/app/models/schemas/pagination.py
@@ -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,
+ )
diff --git a/backend/app/models/schemas/people.py b/backend/app/models/schemas/people.py
new file mode 100644
index 0000000..7cd34be
--- /dev/null
+++ b/backend/app/models/schemas/people.py
@@ -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
diff --git a/backend/app/models/schemas/role.py b/backend/app/models/schemas/role.py
new file mode 100644
index 0000000..99d67d5
--- /dev/null
+++ b/backend/app/models/schemas/role.py
@@ -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()
diff --git a/backend/app/models/schemas/shopify/__init__.py b/backend/app/models/schemas/shopify/__init__.py
new file mode 100644
index 0000000..57e0e6f
--- /dev/null
+++ b/backend/app/models/schemas/shopify/__init__.py
@@ -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
+)
\ No newline at end of file
diff --git a/backend/app/models/schemas/shopify/agent_shopify_config.py b/backend/app/models/schemas/shopify/agent_shopify_config.py
new file mode 100644
index 0000000..fa7ee44
--- /dev/null
+++ b/backend/app/models/schemas/shopify/agent_shopify_config.py
@@ -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
\ No newline at end of file
diff --git a/backend/app/models/schemas/shopify/shopify_shop.py b/backend/app/models/schemas/shopify/shopify_shop.py
new file mode 100644
index 0000000..18757f4
--- /dev/null
+++ b/backend/app/models/schemas/shopify/shopify_shop.py
@@ -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
\ No newline at end of file
diff --git a/backend/app/models/schemas/ticket.py b/backend/app/models/schemas/ticket.py
new file mode 100644
index 0000000..3a9492e
--- /dev/null
+++ b/backend/app/models/schemas/ticket.py
@@ -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
diff --git a/backend/app/models/schemas/ticket_db_connector.py b/backend/app/models/schemas/ticket_db_connector.py
new file mode 100644
index 0000000..225cfc5
--- /dev/null
+++ b/backend/app/models/schemas/ticket_db_connector.py
@@ -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
diff --git a/backend/app/models/schemas/user.py b/backend/app/models/schemas/user.py
new file mode 100644
index 0000000..1bbbdb2
--- /dev/null
+++ b/backend/app/models/schemas/user.py
@@ -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
diff --git a/backend/app/models/schemas/user_group.py b/backend/app/models/schemas/user_group.py
new file mode 100644
index 0000000..90586eb
--- /dev/null
+++ b/backend/app/models/schemas/user_group.py
@@ -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]
\ No newline at end of file
diff --git a/backend/app/models/schemas/widget.py b/backend/app/models/schemas/widget.py
new file mode 100644
index 0000000..ca9429a
--- /dev/null
+++ b/backend/app/models/schemas/widget.py
@@ -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
diff --git a/backend/app/models/schemas/widget_app.py b/backend/app/models/schemas/widget_app.py
new file mode 100644
index 0000000..0f6b135
--- /dev/null
+++ b/backend/app/models/schemas/widget_app.py
@@ -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]
diff --git a/backend/app/models/schemas/workflow.py b/backend/app/models/schemas/workflow.py
new file mode 100644
index 0000000..566aee9
--- /dev/null
+++ b/backend/app/models/schemas/workflow.py
@@ -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
\ No newline at end of file
diff --git a/backend/app/models/session_to_agent.py b/backend/app/models/session_to_agent.py
new file mode 100644
index 0000000..f772fad
--- /dev/null
+++ b/backend/app/models/session_to_agent.py
@@ -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
+ )
\ No newline at end of file
diff --git a/backend/app/models/shopify/__init__.py b/backend/app/models/shopify/__init__.py
new file mode 100644
index 0000000..7247428
--- /dev/null
+++ b/backend/app/models/shopify/__init__.py
@@ -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
\ No newline at end of file
diff --git a/backend/app/models/shopify/agent_shopify_config.py b/backend/app/models/shopify/agent_shopify_config.py
new file mode 100644
index 0000000..f57b0af
--- /dev/null
+++ b/backend/app/models/shopify/agent_shopify_config.py
@@ -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())
\ No newline at end of file
diff --git a/backend/app/models/shopify/shopify_shop.py b/backend/app/models/shopify/shopify_shop.py
new file mode 100644
index 0000000..0ec595a
--- /dev/null
+++ b/backend/app/models/shopify/shopify_shop.py
@@ -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")
\ No newline at end of file
diff --git a/backend/app/models/ticket.py b/backend/app/models/ticket.py
new file mode 100644
index 0000000..7012f20
--- /dev/null
+++ b/backend/app/models/ticket.py
@@ -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")
diff --git a/backend/app/models/ticket_activity.py b/backend/app/models/ticket_activity.py
new file mode 100644
index 0000000..2883021
--- /dev/null
+++ b/backend/app/models/ticket_activity.py
@@ -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"),
+ )
diff --git a/backend/app/models/ticket_db_connector.py b/backend/app/models/ticket_db_connector.py
new file mode 100644
index 0000000..7cc1fe5
--- /dev/null
+++ b/backend/app/models/ticket_db_connector.py
@@ -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"),
+ )
diff --git a/backend/app/models/ticket_settings.py b/backend/app/models/ticket_settings.py
new file mode 100644
index 0000000..3d6f0c0
--- /dev/null
+++ b/backend/app/models/ticket_settings.py
@@ -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")
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
new file mode 100644
index 0000000..9b6a76c
--- /dev/null
+++ b/backend/app/models/user.py
@@ -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)
diff --git a/backend/app/models/widget.py b/backend/app/models/widget.py
new file mode 100644
index 0000000..44443b7
--- /dev/null
+++ b/backend/app/models/widget.py
@@ -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")
diff --git a/backend/app/models/widget_app.py b/backend/app/models/widget_app.py
new file mode 100644
index 0000000..0d091f8
--- /dev/null
+++ b/backend/app/models/widget_app.py
@@ -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])
diff --git a/backend/app/models/workflow.py b/backend/app/models/workflow.py
new file mode 100644
index 0000000..e9217c9
--- /dev/null
+++ b/backend/app/models/workflow.py
@@ -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
\ No newline at end of file
diff --git a/backend/app/models/workflow_connection.py b/backend/app/models/workflow_connection.py
new file mode 100644
index 0000000..7568e05
--- /dev/null
+++ b/backend/app/models/workflow_connection.py
@@ -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
\ No newline at end of file
diff --git a/backend/app/models/workflow_node.py b/backend/app/models/workflow_node.py
new file mode 100644
index 0000000..491fc95
--- /dev/null
+++ b/backend/app/models/workflow_node.py
@@ -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
\ No newline at end of file
diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py
new file mode 100644
index 0000000..4bf993d
--- /dev/null
+++ b/backend/app/repositories/__init__.py
@@ -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
\ No newline at end of file
diff --git a/backend/app/repositories/agent.py b/backend/app/repositories/agent.py
new file mode 100644
index 0000000..145340d
--- /dev/null
+++ b/backend/app/repositories/agent.py
@@ -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
diff --git a/backend/app/repositories/agent_shopify_config_repository.py b/backend/app/repositories/agent_shopify_config_repository.py
new file mode 100644
index 0000000..d24f4bd
--- /dev/null
+++ b/backend/app/repositories/agent_shopify_config_repository.py
@@ -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
\ No newline at end of file
diff --git a/backend/app/repositories/ai_config.py b/backend/app/repositories/ai_config.py
new file mode 100644
index 0000000..f456b56
--- /dev/null
+++ b/backend/app/repositories/ai_config.py
@@ -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
diff --git a/backend/app/repositories/channels/__init__.py b/backend/app/repositories/channels/__init__.py
new file mode 100644
index 0000000..371793d
--- /dev/null
+++ b/backend/app/repositories/channels/__init__.py
@@ -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",
+]
diff --git a/backend/app/repositories/channels/accounts.py b/backend/app/repositories/channels/accounts.py
new file mode 100644
index 0000000..6745cb4
--- /dev/null
+++ b/backend/app/repositories/channels/accounts.py
@@ -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": , "at": }. 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}
diff --git a/backend/app/repositories/channels/agent_config.py b/backend/app/repositories/channels/agent_config.py
new file mode 100644
index 0000000..fba1c45
--- /dev/null
+++ b/backend/app/repositories/channels/agent_config.py
@@ -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
diff --git a/backend/app/repositories/channels/conversations.py b/backend/app/repositories/channels/conversations.py
new file mode 100644
index 0000000..7b53ef6
--- /dev/null
+++ b/backend/app/repositories/channels/conversations.py
@@ -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()
diff --git a/backend/app/repositories/chat.py b/backend/app/repositories/chat.py
new file mode 100644
index 0000000..32d00f1
--- /dev/null
+++ b/backend/app/repositories/chat.py
@@ -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
+ }
diff --git a/backend/app/repositories/crm.py b/backend/app/repositories/crm.py
new file mode 100644
index 0000000..fb28da6
--- /dev/null
+++ b/backend/app/repositories/crm.py
@@ -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
diff --git a/backend/app/repositories/customer.py b/backend/app/repositories/customer.py
new file mode 100644
index 0000000..8407765
--- /dev/null
+++ b/backend/app/repositories/customer.py
@@ -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`` — 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
diff --git a/backend/app/repositories/faq.py b/backend/app/repositories/faq.py
new file mode 100644
index 0000000..ed18dc9
--- /dev/null
+++ b/backend/app/repositories/faq.py
@@ -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()
+ )
diff --git a/backend/app/repositories/faq_generation_job.py b/backend/app/repositories/faq_generation_job.py
new file mode 100644
index 0000000..eb149ea
--- /dev/null
+++ b/backend/app/repositories/faq_generation_job.py
@@ -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
diff --git a/backend/app/repositories/fcm_token.py b/backend/app/repositories/fcm_token.py
new file mode 100644
index 0000000..d9d7fa1
--- /dev/null
+++ b/backend/app/repositories/fcm_token.py
@@ -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
diff --git a/backend/app/repositories/file_attachment.py b/backend/app/repositories/file_attachment.py
new file mode 100644
index 0000000..50d793e
--- /dev/null
+++ b/backend/app/repositories/file_attachment.py
@@ -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
diff --git a/backend/app/repositories/group.py b/backend/app/repositories/group.py
new file mode 100644
index 0000000..7217ccf
--- /dev/null
+++ b/backend/app/repositories/group.py
@@ -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()
\ No newline at end of file
diff --git a/backend/app/repositories/guardrail_event.py b/backend/app/repositories/guardrail_event.py
new file mode 100644
index 0000000..93e2979
--- /dev/null
+++ b/backend/app/repositories/guardrail_event.py
@@ -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
diff --git a/backend/app/repositories/help_center.py b/backend/app/repositories/help_center.py
new file mode 100644
index 0000000..2c22841
--- /dev/null
+++ b/backend/app/repositories/help_center.py
@@ -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()
+ )
diff --git a/backend/app/repositories/jira.py b/backend/app/repositories/jira.py
new file mode 100644
index 0000000..30aa35d
--- /dev/null
+++ b/backend/app/repositories/jira.py
@@ -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
\ No newline at end of file
diff --git a/backend/app/repositories/knowledge.py b/backend/app/repositories/knowledge.py
new file mode 100644
index 0000000..f044c6e
--- /dev/null
+++ b/backend/app/repositories/knowledge.py
@@ -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
diff --git a/backend/app/repositories/knowledge_queue.py b/backend/app/repositories/knowledge_queue.py
new file mode 100644
index 0000000..34bef4f
--- /dev/null
+++ b/backend/app/repositories/knowledge_queue.py
@@ -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
\ No newline at end of file
diff --git a/backend/app/repositories/knowledge_to_agent.py b/backend/app/repositories/knowledge_to_agent.py
new file mode 100644
index 0000000..42bd8c7
--- /dev/null
+++ b/backend/app/repositories/knowledge_to_agent.py
@@ -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)
diff --git a/backend/app/repositories/lead_capture.py b/backend/app/repositories/lead_capture.py
new file mode 100644
index 0000000..598efd2
--- /dev/null
+++ b/backend/app/repositories/lead_capture.py
@@ -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
diff --git a/backend/app/repositories/mcp_tool.py b/backend/app/repositories/mcp_tool.py
new file mode 100644
index 0000000..b67b672
--- /dev/null
+++ b/backend/app/repositories/mcp_tool.py
@@ -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()
\ No newline at end of file
diff --git a/backend/app/repositories/notification_settings.py b/backend/app/repositories/notification_settings.py
new file mode 100644
index 0000000..eb9376a
--- /dev/null
+++ b/backend/app/repositories/notification_settings.py
@@ -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)
+ }
diff --git a/backend/app/repositories/organization.py b/backend/app/repositories/organization.py
new file mode 100644
index 0000000..d30d916
--- /dev/null
+++ b/backend/app/repositories/organization.py
@@ -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
diff --git a/backend/app/repositories/people.py b/backend/app/repositories/people.py
new file mode 100644
index 0000000..d4c6e43
--- /dev/null
+++ b/backend/app/repositories/people.py
@@ -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
diff --git a/backend/app/repositories/rating.py b/backend/app/repositories/rating.py
new file mode 100644
index 0000000..9a13176
--- /dev/null
+++ b/backend/app/repositories/rating.py
@@ -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
\ No newline at end of file
diff --git a/backend/app/repositories/role.py b/backend/app/repositories/role.py
new file mode 100644
index 0000000..d403a8e
--- /dev/null
+++ b/backend/app/repositories/role.py
@@ -0,0 +1,184 @@
+"""
+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, Union
+from app.models.role import Role
+from app.models.permission import Permission, role_permissions
+from app.models.user import User
+from uuid import UUID
+from app.core.logger import get_logger
+
+logger = get_logger(__name__)
+
+class RoleRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def is_role_in_use(self, role_id: int) -> bool:
+ """Check if role is assigned to any users"""
+ return self.db.query(User)\
+ .filter(User.role_id == role_id)\
+ .first() is not None
+
+ def create_role(self, name: str, description: str, organization_id: UUID, is_default: bool = False, permission_ids: List[int] = []) -> Role:
+ """Create a new role with permissions"""
+ role = Role(
+ name=name,
+ description=description,
+ organization_id=organization_id,
+ is_default=is_default
+ )
+ self.db.add(role)
+ self.db.flush() # Get the role ID
+
+ # Add permissions
+ if permission_ids:
+ permissions = self.db.query(Permission)\
+ .filter(Permission.id.in_(permission_ids))\
+ .all()
+ role.permissions.extend(permissions)
+
+ self.db.commit()
+ self.db.refresh(role)
+ return role
+
+ def get_role(self, role_id: int) -> Optional[Role]:
+ """Get role by ID"""
+ return self.db.query(Role).filter(Role.id == role_id).first()
+
+ def get_org_roles(self, organization_id: UUID) -> List[Role]:
+ """Get all roles for an organization"""
+ return self.db.query(Role)\
+ .filter(Role.organization_id == organization_id)\
+ .all()
+
+ def update_role(self, role_id: int, **kwargs) -> Optional[Role]:
+ """Update role details"""
+ role = self.get_role(role_id)
+ if not role:
+ return None
+
+ # Handle permissions separately if they're in kwargs
+ permissions = kwargs.pop('permissions', None)
+ if permissions is not None:
+ # Handle both dict and object formats
+ permission_ids = [
+ p.id if hasattr(p, 'id') else p['id']
+ for p in permissions
+ ]
+ new_permissions = self.db.query(Permission)\
+ .filter(Permission.id.in_(permission_ids))\
+ .all()
+ role.permissions = new_permissions
+
+ # Update other attributes
+ for key, value in kwargs.items():
+ if hasattr(role, key):
+ setattr(role, key, value)
+
+ self.db.commit()
+ self.db.refresh(role)
+ return role
+
+ def delete_role(self, role_id: int) -> bool:
+ """Delete a role"""
+ role = self.get_role(role_id)
+ if not role:
+ return False
+
+ self.db.delete(role)
+ self.db.commit()
+ return True
+
+ def add_permission(self, role_id: int, permission: str) -> bool:
+ """Add a permission to a role"""
+ role = self.get_role(role_id)
+ if not role:
+ return False
+
+ permission_obj = self.db.query(Permission)\
+ .filter(Permission.name == permission)\
+ .first()
+
+ if not permission_obj:
+ return False
+
+ if permission_obj not in role.permissions:
+ role.permissions.append(permission_obj)
+ self.db.commit()
+ return True
+
+ def remove_permission(self, role_id: int, permission: str) -> bool:
+ """Remove a permission from a role"""
+ role = self.get_role(role_id)
+ if not role:
+ return False
+
+ permission_obj = self.db.query(Permission)\
+ .filter(Permission.name == permission)\
+ .first()
+
+ if not permission_obj:
+ return False
+
+ if permission_obj in role.permissions:
+ role.permissions.remove(permission_obj)
+ self.db.commit()
+ return True
+
+ def get_role_permissions(self, role_id: int) -> List[str]:
+ """Get all permissions for a role"""
+ role = self.get_role(role_id)
+ if not role:
+ return []
+ return [p.name for p in role.permissions]
+
+ def get_roles_by_organization(self, organization_id: UUID) -> List[Role]:
+ """Get all roles in an organization"""
+ return self.db.query(Role)\
+ .filter(Role.organization_id == organization_id)\
+ .order_by(Role.name)\
+ .all()
+
+ def get_default_role(self, organization_id: UUID) -> Optional[Role]:
+ """Get the default role for an organization"""
+ return self.db.query(Role)\
+ .filter(Role.organization_id == organization_id)\
+ .filter(Role.is_default == True)\
+ .first()
+
+ def get_admin_role(self, organization_id: UUID) -> Role:
+ """Get or create the admin role for an organization"""
+ admin_role = self.db.query(Role)\
+ .filter(Role.organization_id == organization_id)\
+ .filter(Role.name == "Admin")\
+ .first()
+
+ if not admin_role:
+ # Get all permissions
+ all_permissions = self.db.query(Permission).all()
+
+ # Create admin role with all permissions
+ admin_role = self.create_role(
+ name="Admin",
+ description="Administrator role with full permissions",
+ organization_id=organization_id,
+ is_default=False,
+ permission_ids=[p.id for p in all_permissions]
+ )
+
+ return admin_role
\ No newline at end of file
diff --git a/backend/app/repositories/session_to_agent.py b/backend/app/repositories/session_to_agent.py
new file mode 100644
index 0000000..9a5354f
--- /dev/null
+++ b/backend/app/repositories/session_to_agent.py
@@ -0,0 +1,579 @@
+"""
+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.session_to_agent import ACTIVE_STATUSES, SessionToAgent, SessionStatus, EndChatReasonType
+from uuid import UUID
+from datetime import datetime, timedelta
+from app.core.logger import get_logger
+from sqlalchemy import or_, func
+
+from app.models.user import User
+from app.models.chat_history import ChatHistory
+from app.services.conversation_token import session_key_for_scope
+from app.utils.uuids import as_uuid
+
+logger = get_logger(__name__)
+
+class SessionToAgentRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def create_session(self, session_id: UUID | str, agent_id: UUID | str = None, customer_id: UUID | str = None, user_id: UUID | str = None, organization_id: UUID | str = None, channel: str = 'web', conversation_key: Optional[str] = None) -> SessionToAgent:
+ """Create a new session assignment"""
+ try:
+ # Convert string IDs to UUID objects
+ if isinstance(session_id, str):
+ session_id = UUID(session_id)
+ if isinstance(agent_id, str):
+ agent_id = UUID(agent_id)
+ if isinstance(customer_id, str):
+ customer_id = UUID(customer_id)
+ if isinstance(user_id, str):
+ user_id = UUID(user_id)
+ if isinstance(organization_id, str):
+ organization_id = UUID(organization_id)
+
+ workflow_id = None
+
+ # Check if agent has an active workflow (only if agent_id is provided)
+ if agent_id is not None:
+ from app.models.agent import Agent
+ agent = self.db.query(Agent).filter(Agent.id == agent_id).first()
+
+ if agent:
+ logger.info(f"Agent {agent_id} has use_workflow: {agent.use_workflow} and active_workflow_id: {agent.active_workflow_id}")
+ if agent.use_workflow and agent.active_workflow_id:
+ workflow_id = agent.active_workflow_id
+ logger.info(f"Agent {agent_id} has active workflow {workflow_id}, adding to session")
+
+ session = SessionToAgent(
+ session_id=session_id,
+ agent_id=agent_id,
+ customer_id=customer_id,
+ user_id=user_id,
+ organization_id=organization_id,
+ status=SessionStatus.OPEN,
+ workflow_id=workflow_id,
+ channel=channel,
+ conversation_key=conversation_key
+ )
+ self.db.add(session)
+ self.db.commit()
+ self.db.refresh(session)
+ return session
+ except Exception as e:
+ logger.error(f"Error creating session: {str(e)}")
+ self.db.rollback()
+ raise
+
+ def get_session(self, session_id: UUID | str) -> Optional[SessionToAgent]:
+ """Get session by ID"""
+ try:
+ if isinstance(session_id, str):
+ session_id = UUID(session_id)
+ return self.db.query(SessionToAgent).filter(
+ SessionToAgent.session_id == session_id
+ ).first()
+ except Exception as e:
+ logger.error(f"Error getting session: {str(e)}")
+ return None
+
+ def assign_user(self, session_id: UUID | str, user_id: UUID | str) -> bool:
+ """Assign a user to a session"""
+ try:
+ session = self.get_session(session_id)
+ if not session:
+ return False
+
+ # Convert string user_id to UUID if needed
+ if isinstance(user_id, str):
+ user_id = UUID(user_id)
+
+ session.user_id = user_id
+ session.status = SessionStatus.TRANSFERRED
+ self.db.commit()
+ return True
+ except Exception as e:
+ logger.error(f"Error assigning user to session: {str(e)}")
+ self.db.rollback()
+ return False
+
+ def close_session(self, session_id: UUID | str, reason=None, description=None) -> bool:
+ """Close a session, optionally recording why (end_chat reason/description)."""
+ try:
+ session = self.get_session(session_id)
+ if not session:
+ return False
+
+ session.status = SessionStatus.CLOSED
+ session.closed_at = datetime.utcnow()
+ if reason is not None:
+ session.end_chat_reason = reason
+ if description is not None:
+ session.end_chat_description = description
+ self.db.commit()
+ return True
+ except Exception as e:
+ logger.error(f"Error closing session: {str(e)}")
+ self.db.rollback()
+ return False
+
+ def get_agent_sessions(self, agent_id: UUID | str, status: SessionStatus = None) -> List[SessionToAgent]:
+ """Get all sessions for an agent"""
+ try:
+ query = self.db.query(SessionToAgent).filter(
+ SessionToAgent.agent_id == agent_id
+ )
+ if status:
+ query = query.filter(SessionToAgent.status == status)
+ return query.all()
+ except Exception as e:
+ logger.error(f"Error getting agent sessions: {str(e)}")
+ return []
+
+ def get_user_sessions(self, user_id: UUID | str, status: SessionStatus = None) -> List[SessionToAgent]:
+ """Get all sessions assigned to a user"""
+ try:
+ query = self.db.query(SessionToAgent).filter(
+ SessionToAgent.user_id == user_id
+ )
+ if status:
+ query = query.filter(SessionToAgent.status == status)
+ return query.all()
+ except Exception as e:
+ logger.error(f"Error getting user sessions: {str(e)}")
+ return []
+
+ def get_open_sessions(self) -> List[SessionToAgent]:
+ """Get all open sessions"""
+ try:
+ return self.db.query(SessionToAgent).filter(
+ SessionToAgent.status == SessionStatus.OPEN
+ ).all()
+ except Exception as e:
+ logger.error(f"Error getting open sessions: {str(e)}")
+ return []
+
+ def get_customer_sessions(self, customer_id: UUID | str, status: SessionStatus, scope: str) -> List[SessionToAgent]:
+ """A customer's sessions in `status` that a conversation-token scope reaches"""
+ try:
+ query = self.db.query(
+ SessionToAgent,
+ User.full_name.label('user_full_name'),
+ User.profile_pic.label('user_profile_pic')
+ ).outerjoin(
+ User, SessionToAgent.user_id == User.id
+ ).filter(
+ SessionToAgent.customer_id == as_uuid(customer_id)
+ )
+ query = query.filter(SessionToAgent.status == status, _in_scope(scope))
+
+ return query.order_by(SessionToAgent.assigned_at.desc()).all()
+ except Exception as e:
+ logger.error(f"Error getting customer sessions: {str(e)}")
+ return []
+
+ def get_active_widget_session(self, customer_id: UUID | str, agent_id: UUID | str, scope: str) -> Optional[SessionToAgent]:
+ """The open session a widget conversation token may resume.
+
+ Never "the customer's open session": a widget token can name a customer
+ the visitor never proved to be, so it only reaches sessions created
+ under its own scope.
+ """
+ try:
+ return self.db.query(SessionToAgent).filter(
+ SessionToAgent.customer_id == as_uuid(customer_id),
+ SessionToAgent.agent_id == as_uuid(agent_id),
+ SessionToAgent.status.in_(ACTIVE_STATUSES),
+ _in_scope(scope),
+ ).order_by(SessionToAgent.assigned_at.desc()).first()
+ except Exception as e:
+ logger.error(f"Error getting active widget session: {str(e)}")
+ return None
+
+ def get_widget_session(self, session_id: UUID | str, customer_id: UUID | str, scope: Optional[str],
+ active_only: bool = True) -> Optional[SessionToAgent]:
+ """A widget socket's own conversation, while it still belongs to that socket.
+
+ Socket handlers act on this rather than on "the customer's open session":
+ a widget token may name a customer the visitor only claimed to be, and
+ must never reach a conversation outside its own scope.
+ """
+ own = self.get_session(session_id) if session_id else None
+ if own is None or str(own.customer_id) != str(customer_id):
+ return None
+ if not session_in_scope(own, scope):
+ return None
+ if active_only and own.status not in ACTIVE_STATUSES:
+ return None
+ return own
+
+ def customer_has_sessions_outside_scope(self, customer_id: UUID | str, scope: str) -> bool:
+ """True when the customer has a session this token scope did not create.
+
+ Fails closed: on error the customer is treated as shared.
+ """
+ try:
+ return self.db.query(SessionToAgent.session_id).filter(
+ SessionToAgent.customer_id == as_uuid(customer_id),
+ _outside_scope(scope),
+ ).first() is not None
+ except Exception as e:
+ logger.error(f"Error checking customer session scopes: {str(e)}")
+ return True
+
+ def get_agent_customer_sessions(self, agent_id: UUID | str, customer_id: UUID | str, status: SessionStatus = None) -> List[SessionToAgent]:
+ """Get all sessions between an agent and customer"""
+ try:
+ query = self.db.query(SessionToAgent).filter(
+ SessionToAgent.agent_id == agent_id,
+ SessionToAgent.customer_id == customer_id
+ )
+ if status:
+ query = query.filter(SessionToAgent.status == status)
+
+ return query.order_by(SessionToAgent.assigned_at.desc()).all()
+ except Exception as e:
+ logger.error(f"Error getting agent-customer sessions: {str(e)}")
+ return []
+
+ def reopen_closed_session(self, session_id: UUID | str) -> bool:
+ """Reopen a closed session"""
+ try:
+ session = self.get_session(session_id)
+ if not session:
+ return False
+
+ if session.status == SessionStatus.CLOSED:
+ session.status = SessionStatus.OPEN
+ self.db.commit()
+ return True
+ return False # Session was not closed
+ except Exception as e:
+ logger.error(f"Error reopening session: {str(e)}")
+ self.db.rollback()
+ return False
+
+ def get_latest_customer_session(self, customer_id: UUID | str, scope: str, agent_id: UUID | str = None) -> Optional[SessionToAgent]:
+ """The latest session a conversation-token scope reaches, whatever its status"""
+ try:
+ query = self.db.query(SessionToAgent).filter(
+ SessionToAgent.customer_id == as_uuid(customer_id)
+ )
+ if agent_id:
+ query = query.filter(SessionToAgent.agent_id == agent_id)
+ query = query.filter(_in_scope(scope))
+
+ return query.order_by(SessionToAgent.assigned_at.desc()).first()
+ except Exception as e:
+ logger.error(f"Error getting latest customer session: {str(e)}")
+ return None
+
+ def update_session(self, session_id: UUID | str, data: dict) -> bool:
+ """Update a session"""
+ try:
+ session = self.get_session(session_id)
+ if not session:
+ logger.error(f"Session {session_id} not found for update")
+ return False
+
+ logger.info(f"Updating session {session_id} with data: {data}")
+
+ # Direct assignment instead of setattr for better SQLAlchemy JSON handling
+ if 'workflow_state' in data:
+ session.workflow_state = data['workflow_state']
+ logger.info(f"Set workflow_state = {data['workflow_state']}")
+
+ if 'current_node_id' in data:
+ session.current_node_id = data['current_node_id']
+ logger.info(f"Set current_node_id = {data['current_node_id']}")
+
+ # Handle other fields with setattr
+ for key, value in data.items():
+ if key not in ['workflow_state', 'current_node_id']:
+ setattr(session, key, value)
+ logger.info(f"Set {key} = {value}")
+
+ # Mark the session as dirty to ensure SQLAlchemy tracks the changes
+ self.db.flush()
+ self.db.commit()
+
+ # Verify the update by refreshing from database
+ self.db.refresh(session)
+ logger.info(f"Session after update - workflow_state: {session.workflow_state}")
+ logger.info(f"Session after update - current_node_id: {session.current_node_id}")
+
+ return True
+ except Exception as e:
+ logger.error(f"Error updating session: {str(e)}")
+ self.db.rollback()
+ return False
+
+ def takeover_session(self, session_id: str, user_id: str) -> bool:
+ """Take over a chat session"""
+ try:
+ session = self.get_session(session_id)
+ if not session:
+ return False
+
+ # Check if session is already taken
+ if session.user_id is not None:
+ return False
+
+ # Update session
+ session.user_id = UUID(user_id)
+ session.group_id = None # Remove group assignment
+ session.status = SessionStatus.OPEN # Keep status as open
+
+ self.db.commit()
+ return True
+
+ except Exception as e:
+ logger.error(f"Error taking over session: {str(e)}")
+ self.db.rollback()
+ return False
+
+ def reassign_session(self, session_id: str, to_user_id: str) -> bool:
+ """Reassign a chat session to another user regardless of current assignee"""
+ try:
+ session = self.get_session(session_id)
+ if not session:
+ return False
+
+ # Update assignment
+ session.user_id = UUID(to_user_id)
+ session.group_id = None
+ session.status = SessionStatus.OPEN
+ session.updated_at = datetime.utcnow()
+
+ self.db.commit()
+ return True
+ except Exception as e:
+ logger.error(f"Error reassigning session: {str(e)}")
+ self.db.rollback()
+ return False
+
+ def update_workflow_state(self, session_id: UUID | str, current_node_id: Optional[UUID], workflow_state: dict) -> bool:
+ """Update workflow state and current node for a session"""
+ try:
+ from sqlalchemy.orm.attributes import flag_modified
+
+ session = self.get_session(session_id)
+ if not session:
+ logger.error(f"Session {session_id} not found for workflow state update")
+ return False
+
+ logger.info(f"Updating workflow state for session {session_id}")
+ logger.info(f"Setting current_node_id to: {current_node_id}")
+ logger.info(f"Setting workflow_state to: {workflow_state}")
+
+ # Update fields
+ session.current_node_id = current_node_id
+ session.workflow_state = workflow_state.copy() if workflow_state else {}
+ session.updated_at = datetime.utcnow()
+
+ # Explicitly mark JSON field as modified for SQLAlchemy
+ flag_modified(session, 'workflow_state')
+
+ # Commit changes
+ self.db.commit()
+ self.db.refresh(session)
+
+ # Verify the update
+ logger.info(f"Verified - current_node_id: {session.current_node_id}")
+ logger.info(f"Verified - workflow_state: {session.workflow_state}")
+
+ return True
+ except Exception as e:
+ logger.error(f"Error updating workflow state: {str(e)}")
+ self.db.rollback()
+ return False
+
+ def update_session_status(self, session_id: UUID | str, status: str) -> Optional[SessionToAgent]:
+ """Update the status of a session"""
+ try:
+ session = self.db.query(SessionToAgent).filter(SessionToAgent.session_id == session_id).first()
+ if not session:
+ logger.error(f"Session {session_id} not found")
+ return None
+
+ # Convert string status to enum if needed
+ if isinstance(status, str):
+ try:
+ status = SessionStatus[status]
+ except KeyError:
+ logger.error(f"Invalid session status: {status}")
+ return None
+
+ session.status = status
+ session.updated_at = datetime.utcnow()
+
+ self.db.commit()
+ self.db.refresh(session)
+ logger.info(f"Updated session {session_id} status to {status}")
+ return session
+ except Exception as e:
+ logger.error(f"Error updating session status: {str(e)}")
+ self.db.rollback()
+ return None
+
+ def add_workflow_history_entry(self, session_id: UUID | str, node_id: UUID | str, entry_type: str, data: dict) -> bool:
+ """Add an entry to the workflow history with proper multi-language Unicode support"""
+ try:
+ import json
+ import copy
+ from sqlalchemy.orm.attributes import flag_modified
+
+ session = self.get_session(session_id)
+ if not session:
+ logger.error(f"Session {session_id} not found for workflow history update")
+ return False
+
+ # Initialize workflow_history if None
+ if session.workflow_history is None:
+ session.workflow_history = []
+
+ # Create a deep copy of the data to avoid modifying the original
+ data_copy = copy.deepcopy(data)
+
+ # Create history entry
+ history_entry = {
+ "node_id": str(node_id),
+ "type": entry_type,
+ "timestamp": datetime.utcnow().isoformat(),
+ "data": data_copy
+ }
+
+ # Convert to JSON with ensure_ascii=False to preserve Unicode characters
+ # Then parse it back to ensure it's properly formatted for SQLAlchemy
+ json_str = json.dumps(history_entry, ensure_ascii=False, separators=(',', ':'))
+ unicode_safe_entry = json.loads(json_str)
+
+ # Add to history
+ session.workflow_history.append(unicode_safe_entry)
+
+ # Mark as modified for SQLAlchemy
+ flag_modified(session, 'workflow_history')
+
+ # Commit changes
+ self.db.commit()
+ self.db.refresh(session)
+
+ logger.info(f"Added workflow history entry for session {session_id}: {entry_type}")
+ return True
+ except Exception as e:
+ logger.error(f"Error adding workflow history entry: {str(e)}")
+ self.db.rollback()
+ return False
+
+ def get_workflow_history(self, session_id: UUID | str) -> list:
+ """Get the workflow history for a session"""
+ try:
+ session = self.get_session(session_id)
+ if not session:
+ logger.error(f"Session {session_id} not found")
+ return []
+
+ return session.workflow_history or []
+ except Exception as e:
+ logger.error(f"Error getting workflow history: {str(e)}")
+ return []
+
+ def auto_close_inactive_agent_chats(self) -> int:
+ """
+ Auto-close chats that are handled by agents (not users) and have been inactive for more than 1 day.
+ Returns the number of chats that were closed.
+ """
+ try:
+ # Calculate the cutoff time (24 hours ago)
+ cutoff_time = datetime.utcnow() - timedelta(days=1)
+
+ # Find open sessions handled by agents (user_id is None) where the last message is older than 1 day
+ sessions_to_close = (
+ self.db.query(SessionToAgent)
+ .join(
+ ChatHistory,
+ SessionToAgent.session_id == ChatHistory.session_id
+ )
+ .filter(
+ SessionToAgent.status == SessionStatus.OPEN,
+ SessionToAgent.user_id.is_(None), # Handled by agent, not user
+ SessionToAgent.agent_id.is_not(None) # Must have an agent assigned
+ )
+ .group_by(SessionToAgent.session_id)
+ .having(
+ func.max(ChatHistory.created_at) < cutoff_time
+ )
+ .all()
+ )
+
+ closed_count = 0
+
+ for session in sessions_to_close:
+ try:
+ # Update session status to closed
+ session.status = SessionStatus.CLOSED
+ session.end_chat_reason = EndChatReasonType.ISSUE_RESOLVED
+ session.end_chat_description = "Inactive for more than one day"
+ session.updated_at = datetime.utcnow()
+
+ closed_count += 1
+ logger.info(f"Auto-closed inactive chat session {session.session_id}")
+
+ except Exception as e:
+ logger.error(f"Error closing session {session.session_id}: {str(e)}")
+ continue
+
+ # Commit all changes
+ if closed_count > 0:
+ self.db.commit()
+ logger.info(f"Auto-closed {closed_count} inactive agent chats")
+
+ return closed_count
+
+ except Exception as e:
+ logger.error(f"Error in auto_close_inactive_agent_chats: {str(e)}")
+ self.db.rollback()
+ return 0
+
+
+def _in_scope(scope: str):
+ """SQL condition: the session was created under this token scope."""
+ key = session_key_for_scope(scope)
+ if key is None:
+ return SessionToAgent.conversation_key.is_(None)
+ return SessionToAgent.conversation_key == key
+
+
+def _outside_scope(scope: str):
+ """SQL condition: the session was NOT created under this token scope.
+
+ Spelled out rather than ~_in_scope: NOT (key = 'x') is NULL, not true, for a
+ keyless session, which would hide every vouched-for conversation.
+ """
+ key = session_key_for_scope(scope)
+ if key is None:
+ return SessionToAgent.conversation_key.isnot(None)
+ return or_(SessionToAgent.conversation_key.is_(None), SessionToAgent.conversation_key != key)
+
+
+def session_in_scope(session: SessionToAgent, scope: Optional[str]) -> bool:
+ """Python twin of _in_scope for a session already loaded. No scope, no access."""
+ if not scope:
+ return False
+ return session.conversation_key == session_key_for_scope(scope)
diff --git a/backend/app/repositories/shopify_shop_repository.py b/backend/app/repositories/shopify_shop_repository.py
new file mode 100644
index 0000000..1fcf841
--- /dev/null
+++ b/backend/app/repositories/shopify_shop_repository.py
@@ -0,0 +1,135 @@
+"""
+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, Union
+import uuid
+from sqlalchemy.orm import Session
+from app.models.shopify.shopify_shop import ShopifyShop
+from app.models.schemas.shopify import ShopifyShopCreate, ShopifyShopUpdate
+from uuid import UUID
+from app.core.logger import get_logger
+
+logger = get_logger(__name__)
+
+class ShopifyShopRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def get_shop(self, shop_id: str) -> Optional[ShopifyShop]:
+ """
+ Get a shop by ID
+ """
+ return self.db.query(ShopifyShop).filter(ShopifyShop.id == shop_id).first()
+
+ def get_shop_by_domain(self, shop_domain: str) -> Optional[ShopifyShop]:
+ """
+ Get a shop by domain
+ """
+ return self.db.query(ShopifyShop).filter(ShopifyShop.shop_domain == shop_domain).first()
+
+ def get_shops(self, skip: int = 0, limit: int = 100) -> List[ShopifyShop]:
+ """
+ Get all shops
+ """
+ return self.db.query(ShopifyShop).offset(skip).limit(limit).all()
+
+ def get_shops_by_organization(self, organization_id: Union[str, UUID], skip: int = 0, limit: int = 100) -> List[ShopifyShop]:
+ """
+ Get all shops for an organization
+ """
+ # Convert organization_id to UUID if it's a string
+ org_id = organization_id
+ if organization_id and isinstance(organization_id, str):
+ try:
+ org_id = UUID(organization_id)
+ except ValueError:
+ # If not a valid UUID, continue with string version
+ pass
+
+ return self.db.query(ShopifyShop).filter(ShopifyShop.organization_id == org_id).offset(skip).limit(limit).all()
+
+ def create_shop(self, shop: ShopifyShopCreate) -> ShopifyShop:
+ """
+ Create a new shop
+ """
+ # Convert organization_id to UUID if it's a string
+ org_id = shop.organization_id
+ if shop.organization_id and isinstance(shop.organization_id, str):
+ try:
+ org_id = UUID(shop.organization_id)
+ except ValueError:
+ # If not a valid UUID, continue with string version
+ pass
+
+ db_shop = ShopifyShop(
+ id=str(uuid.uuid4()),
+ shop_domain=shop.shop_domain,
+ access_token=shop.access_token,
+ scope=shop.scope,
+ is_installed=shop.is_installed,
+ organization_id=org_id,
+ oauth_state=shop.oauth_state,
+ oauth_state_expiry=shop.oauth_state_expiry
+ )
+ self.db.add(db_shop)
+ self.db.commit()
+ self.db.refresh(db_shop)
+ return db_shop
+
+ def update_shop(self, shop_id: str, shop_update: ShopifyShopUpdate) -> Optional[ShopifyShop]:
+ """
+ Update a shop
+ """
+ db_shop = self.get_shop(shop_id)
+ if not db_shop:
+ return None
+
+ update_data = shop_update.model_dump(exclude_unset=True)
+
+ # Convert organization_id to UUID if it's a string
+ if 'organization_id' in update_data and update_data['organization_id'] and isinstance(update_data['organization_id'], str):
+ try:
+ update_data['organization_id'] = UUID(update_data['organization_id'])
+ except ValueError:
+ # If not a valid UUID, continue with string version
+ pass
+
+ for key, value in update_data.items():
+ setattr(db_shop, key, value)
+
+ self.db.commit()
+ self.db.refresh(db_shop)
+ return db_shop
+
+ def update(self, shop: ShopifyShop) -> ShopifyShop:
+ """
+ Update an existing shop object
+ """
+ self.db.commit()
+ self.db.refresh(shop)
+ return shop
+
+ def delete_shop(self, shop_id: str) -> bool:
+ """
+ Delete a shop
+ """
+ db_shop = self.get_shop(shop_id)
+ if not db_shop:
+ return False
+
+ self.db.delete(db_shop)
+ self.db.commit()
+ return True
\ No newline at end of file
diff --git a/backend/app/repositories/ticket.py b/backend/app/repositories/ticket.py
new file mode 100644
index 0000000..34a56e4
--- /dev/null
+++ b/backend/app/repositories/ticket.py
@@ -0,0 +1,556 @@
+"""
+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 Dict, List, Optional, Tuple
+from uuid import UUID
+
+from sqlalchemy import and_, func, or_, text
+from sqlalchemy.orm import Session, joinedload
+
+from app.core.logger import get_logger
+from app.models.investigation import (
+ ACTIVE_RUN_STATUSES,
+ InvestigationEvent,
+ InvestigationHypothesis,
+ InvestigationRun,
+ InvestigationRunStatus,
+ InvestigationRunType,
+ RCADocument,
+)
+from app.models.ticket import (
+ OPEN_TICKET_STATUSES,
+ Ticket,
+ TicketSequence,
+ TicketSession,
+ TicketStatus,
+)
+from app.models.ticket_activity import TicketActivity
+from app.models.ticket_settings import OrganizationTicketSettings
+
+logger = get_logger(__name__)
+
+# Safety cap for the in-Python SLA-breach computation over open tickets.
+MAX_OPEN_TICKETS_FOR_STATS = 5000
+
+
+class TicketRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def allocate_ticket_number(self, organization_id: UUID) -> int:
+ """Allocate the next per-org display number. Locks the sequence row
+ (SELECT ... FOR UPDATE) so concurrent creates can't collide; must be
+ committed in the same transaction as the ticket row."""
+ if self.db.bind is not None and self.db.bind.dialect.name == "postgresql":
+ # FOR UPDATE can't lock a row that doesn't exist yet: two
+ # concurrent first tickets would both insert and one would die on
+ # the PK. Upsert the row race-free, then lock it.
+ self.db.execute(
+ text(
+ "INSERT INTO ticket_sequences (organization_id, next_number) "
+ "VALUES (:org, 1) ON CONFLICT (organization_id) DO NOTHING"
+ ),
+ {"org": str(organization_id)},
+ )
+ seq = (
+ self.db.query(TicketSequence)
+ .filter(TicketSequence.organization_id == organization_id)
+ .with_for_update()
+ .first()
+ )
+ if seq is None: # non-postgres (sqlite tests)
+ seq = TicketSequence(organization_id=organization_id, next_number=1)
+ self.db.add(seq)
+ self.db.flush()
+ number = seq.next_number
+ seq.next_number = number + 1
+ self.db.flush()
+ return number
+
+ def create(self, ticket: Ticket) -> Ticket:
+ ticket.ticket_number = self.allocate_ticket_number(ticket.organization_id)
+ self.db.add(ticket)
+ self.db.flush()
+ return ticket
+
+ def get_by_id(
+ self,
+ ticket_id: UUID,
+ organization_id: Optional[UUID] = None,
+ for_update: bool = False,
+ ) -> Optional[Ticket]:
+ query = self.db.query(Ticket).filter(Ticket.id == ticket_id)
+ if organization_id is not None:
+ query = query.filter(Ticket.organization_id == organization_id)
+ if for_update:
+ # Serialize concurrent mutations of the same ticket (e.g. a
+ # double-submitted resolve) so each request observes the prior
+ # one's committed status. No-op on sqlite (tests).
+ query = query.with_for_update()
+ return query.first()
+
+ def get_by_number(self, organization_id: UUID, ticket_number: int) -> Optional[Ticket]:
+ return (
+ self.db.query(Ticket)
+ .filter(
+ Ticket.organization_id == organization_id,
+ Ticket.ticket_number == ticket_number,
+ )
+ .first()
+ )
+
+ def get_by_session(self, session_id: UUID) -> Optional[Ticket]:
+ """The native ticket linked to a chat session (at most one)."""
+ return (
+ self.db.query(Ticket)
+ .join(TicketSession, TicketSession.ticket_id == Ticket.id)
+ .filter(TicketSession.session_id == session_id)
+ .order_by(Ticket.created_at.desc())
+ .first()
+ )
+
+ def link_session(self, ticket_id: UUID, session_id: UUID) -> None:
+ exists = (
+ self.db.query(TicketSession)
+ .filter(
+ TicketSession.ticket_id == ticket_id,
+ TicketSession.session_id == session_id,
+ )
+ .first()
+ )
+ if not exists:
+ self.db.add(TicketSession(ticket_id=ticket_id, session_id=session_id))
+ self.db.flush()
+
+ def get_session_ids(self, ticket_id: UUID) -> List[UUID]:
+ rows = (
+ self.db.query(TicketSession.session_id)
+ .filter(TicketSession.ticket_id == ticket_id)
+ .order_by(TicketSession.linked_at)
+ .all()
+ )
+ return [r[0] for r in rows]
+
+ def _ai_state_condition(self, ai_state: str):
+ """SQL condition matching the derived AI-state used by the list filter.
+ Mirrors TicketService.ai_state()."""
+ from sqlalchemy import exists
+ active_run = exists().where(
+ and_(
+ InvestigationRun.ticket_id == Ticket.id,
+ InvestigationRun.status.in_(ACTIVE_RUN_STATUSES),
+ )
+ )
+ resolved_states = [
+ TicketStatus.RESOLVED.value,
+ TicketStatus.CLOSED.value,
+ TicketStatus.RESOLVED_PENDING_CONFIRMATION.value,
+ ]
+ is_resolved = Ticket.status.in_(resolved_states)
+ # Resolution wins over a stale not-yet-processed run (mirrors ai_state()).
+ investigating = and_(
+ ~is_resolved,
+ or_(
+ active_run,
+ Ticket.status.in_([TicketStatus.TRIAGING.value, TicketStatus.INVESTIGATING.value]),
+ ),
+ )
+ awaiting = and_(
+ Ticket.status == TicketStatus.AWAITING_APPROVAL.value, ~active_run
+ )
+ ai_resolved = and_(is_resolved, Ticket.resolved_by_actor == "ai")
+ if ai_state == "investigating":
+ return investigating
+ if ai_state == "awaiting":
+ return awaiting
+ if ai_state == "resolved":
+ return ai_resolved
+ if ai_state == "human":
+ return and_(~investigating, ~awaiting, ~ai_resolved)
+ return None
+
+ def list(
+ self,
+ organization_id: UUID,
+ status: Optional[List[str]] = None,
+ priority: Optional[str] = None,
+ assignee_user_id: Optional[UUID] = None,
+ group_id: Optional[UUID] = None,
+ unassigned: bool = False,
+ ai_state: Optional[str] = None,
+ search: Optional[str] = None,
+ sort: str = "updated",
+ page: int = 1,
+ page_size: int = 25,
+ ) -> Tuple[List[Ticket], int]:
+ query = (
+ self.db.query(Ticket)
+ .options(joinedload(Ticket.assignee))
+ .filter(Ticket.organization_id == organization_id)
+ )
+ if status:
+ query = query.filter(Ticket.status.in_(status))
+ if ai_state:
+ condition = self._ai_state_condition(ai_state)
+ if condition is not None:
+ query = query.filter(condition)
+ if priority:
+ query = query.filter(Ticket.priority == priority)
+ if assignee_user_id is not None:
+ query = query.filter(Ticket.assignee_user_id == assignee_user_id)
+ if unassigned:
+ query = query.filter(Ticket.assignee_user_id.is_(None))
+ if group_id is not None:
+ query = query.filter(Ticket.group_id == group_id)
+ if search:
+ # Escape LIKE metacharacters so a literal '%'/'_' in the search
+ # text doesn't become a wildcard.
+ escaped = (
+ search.strip()
+ .replace("\\", "\\\\")
+ .replace("%", "\\%")
+ .replace("_", "\\_")
+ )
+ term = f"%{escaped}%"
+ filters = [
+ Ticket.title.ilike(term, escape="\\"),
+ Ticket.description.ilike(term, escape="\\"),
+ ]
+ # "TKT-123" / "123" searches match the display number.
+ digits = search.strip().upper().removeprefix("TKT-")
+ if digits.isdigit():
+ filters.append(Ticket.ticket_number == int(digits))
+ query = query.filter(or_(*filters))
+
+ total = query.count()
+
+ if sort == "priority":
+ # urgent < high < medium < low by enum position.
+ priority_order = {"urgent": 0, "high": 1, "medium": 2, "low": 3}
+ whens = [(Ticket.priority == value, rank) for value, rank in priority_order.items()]
+ from sqlalchemy import case
+ query = query.order_by(case(*whens, else_=99), Ticket.updated_at.desc())
+ elif sort == "created":
+ query = query.order_by(Ticket.created_at.desc())
+ else:
+ query = query.order_by(Ticket.updated_at.desc())
+
+ tickets = query.offset((page - 1) * page_size).limit(page_size).all()
+ return tickets, total
+
+ def find_similar(
+ self,
+ organization_id: UUID,
+ embedding: List[float],
+ limit: int = 5,
+ exclude_ticket_id: Optional[UUID] = None,
+ only_open: bool = False,
+ min_similarity: float = 0.0,
+ ) -> List[Tuple[Ticket, float]]:
+ """kNN cosine search over ticket embeddings. Postgres-only (pgvector);
+ returns [] on other dialects (sqlite tests) or on error."""
+ if self.db.bind is None or self.db.bind.dialect.name != "postgresql":
+ return []
+ try:
+ from sqlalchemy import bindparam
+
+ vec = "[" + ",".join(f"{v:.8f}" for v in embedding) + "]"
+ conditions = ["organization_id = :org_id", "embedding IS NOT NULL"]
+ params = {"org_id": str(organization_id), "vec": vec, "limit": limit}
+ if exclude_ticket_id is not None:
+ conditions.append("id != :exclude_id")
+ params["exclude_id"] = str(exclude_ticket_id)
+ if only_open:
+ conditions.append("status IN :statuses")
+ params["statuses"] = list(OPEN_TICKET_STATUSES)
+ sql = text(
+ "SELECT id, 1 - (embedding <=> :vec) AS similarity FROM tickets "
+ f"WHERE {' AND '.join(conditions)} "
+ "ORDER BY embedding <=> :vec LIMIT :limit"
+ )
+ if only_open:
+ sql = sql.bindparams(bindparam("statuses", expanding=True))
+ rows = self.db.execute(sql, params).fetchall()
+ similarities = {
+ row[0]: float(row[1]) for row in rows if float(row[1]) >= min_similarity
+ }
+ if not similarities:
+ return []
+ # One IN query instead of a round-trip per row.
+ tickets = (
+ self.db.query(Ticket).filter(Ticket.id.in_(similarities.keys())).all()
+ )
+ results = [(t, similarities[t.id]) for t in tickets]
+ results.sort(key=lambda pair: pair[1], reverse=True)
+ return results
+ except Exception as e:
+ logger.error(f"Ticket similarity search failed: {e}")
+ return []
+
+ def active_run_ticket_ids(self, ticket_ids: List[UUID]) -> set:
+ """Which of the given tickets have an active investigation run."""
+ if not ticket_ids:
+ return set()
+ rows = (
+ self.db.query(InvestigationRun.ticket_id)
+ .filter(
+ InvestigationRun.ticket_id.in_(ticket_ids),
+ InvestigationRun.status.in_(ACTIVE_RUN_STATUSES),
+ )
+ .all()
+ )
+ return {r[0] for r in rows}
+
+ def count_by_status(self, organization_id: UUID) -> dict:
+ rows = (
+ self.db.query(Ticket.status, func.count(Ticket.id))
+ .filter(Ticket.organization_id == organization_id)
+ .group_by(Ticket.status)
+ .all()
+ )
+ return {status: count for status, count in rows}
+
+ def open_ticket_sla_fields(self, organization_id: UUID) -> List[Tuple[str, object]]:
+ """(priority, created_at) of open tickets — all the SLA-breach stat
+ needs; avoids hydrating full rows (embedding + text) per poll."""
+ return (
+ self.db.query(Ticket.priority, Ticket.created_at)
+ .filter(
+ Ticket.organization_id == organization_id,
+ Ticket.status.in_(OPEN_TICKET_STATUSES),
+ )
+ .limit(MAX_OPEN_TICKETS_FOR_STATS)
+ .all()
+ )
+
+ def resolved_counts_7d(self, organization_id: UUID) -> Tuple[int, int]:
+ """(ai_resolved, total_resolved) over the trailing 7 days."""
+ since = datetime.now(timezone.utc) - timedelta(days=7)
+ base = self.db.query(func.count(Ticket.id)).filter(
+ Ticket.organization_id == organization_id,
+ Ticket.resolved_at.isnot(None),
+ Ticket.resolved_at >= since,
+ )
+ total = base.scalar() or 0
+ ai = base.filter(Ticket.resolved_by_actor == "ai").scalar() or 0
+ return ai, total
+
+ def csat_by_resolver(self, organization_id: UUID, days: int = 30) -> Dict[str, Tuple[int, float]]:
+ """{resolver: (responses, average)} over the trailing window, keyed by
+ resolved_by_actor ('ai' | 'user' | '' when the ticket was never
+ formally resolved). Powers the AI- vs human-resolved CSAT split."""
+ since = datetime.now(timezone.utc) - timedelta(days=days)
+ rows = (
+ self.db.query(
+ Ticket.resolved_by_actor,
+ func.count(Ticket.id),
+ func.avg(Ticket.csat_score),
+ )
+ .filter(
+ Ticket.organization_id == organization_id,
+ Ticket.csat_score.isnot(None),
+ Ticket.csat_responded_at >= since,
+ )
+ .group_by(Ticket.resolved_by_actor)
+ .all()
+ )
+ return {(actor or ""): (count, float(avg)) for actor, count, avg in rows}
+
+
+class TicketActivityRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def add(self, activity: TicketActivity) -> TicketActivity:
+ self.db.add(activity)
+ self.db.flush()
+ return activity
+
+ def list_for_ticket(self, ticket_id: UUID, limit: int = 200) -> List[TicketActivity]:
+ return (
+ self.db.query(TicketActivity)
+ .options(joinedload(TicketActivity.actor_user))
+ .filter(TicketActivity.ticket_id == ticket_id)
+ .order_by(TicketActivity.created_at)
+ .limit(limit)
+ .all()
+ )
+
+
+class TicketSettingsRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def get(self, organization_id: UUID) -> Optional[OrganizationTicketSettings]:
+ return (
+ self.db.query(OrganizationTicketSettings)
+ .filter(OrganizationTicketSettings.organization_id == organization_id)
+ .first()
+ )
+
+ def get_or_create(self, organization_id: UUID) -> OrganizationTicketSettings:
+ settings = self.get(organization_id)
+ if settings is None:
+ settings = OrganizationTicketSettings(organization_id=organization_id)
+ self.db.add(settings)
+ self.db.commit()
+ self.db.refresh(settings)
+ return settings
+
+
+class InvestigationRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def get_by_id(self, run_id: UUID) -> Optional[InvestigationRun]:
+ return self.db.query(InvestigationRun).filter(InvestigationRun.id == run_id).first()
+
+ def get_active_for_ticket(self, ticket_id: UUID) -> Optional[InvestigationRun]:
+ return (
+ self.db.query(InvestigationRun)
+ .filter(
+ InvestigationRun.ticket_id == ticket_id,
+ InvestigationRun.status.in_(ACTIVE_RUN_STATUSES),
+ )
+ .first()
+ )
+
+ def count_for_ticket(self, ticket_id: UUID, run_type: Optional[str] = None) -> int:
+ query = self.db.query(func.count(InvestigationRun.id)).filter(
+ InvestigationRun.ticket_id == ticket_id
+ )
+ if run_type:
+ query = query.filter(InvestigationRun.run_type == run_type)
+ return query.scalar() or 0
+
+ def list_for_ticket(self, ticket_id: UUID) -> List[InvestigationRun]:
+ return (
+ self.db.query(InvestigationRun)
+ .filter(InvestigationRun.ticket_id == ticket_id)
+ .order_by(InvestigationRun.created_at.desc())
+ .all()
+ )
+
+ def enqueue(self, run: InvestigationRun) -> Optional[InvestigationRun]:
+ """Insert a pending run unless the ticket already has an active one
+ (double-guarded by uq_investigation_runs_one_active in Postgres)."""
+ # Sessions run with autoflush=False: the caller may have just moved
+ # the previous run to a terminal state in this same session, and
+ # without a flush the guard query would still see the stale row and
+ # silently refuse the enqueue (bit the triage -> investigation chain).
+ self.db.flush()
+ if self.get_active_for_ticket(run.ticket_id) is not None:
+ return None
+ self.db.add(run)
+ self.db.flush()
+ return run
+
+ def get_pending_ids(self, limit: int = 10) -> List[UUID]:
+ rows = (
+ self.db.query(InvestigationRun.id)
+ .filter(InvestigationRun.status == InvestigationRunStatus.PENDING)
+ .order_by(InvestigationRun.created_at)
+ .limit(limit)
+ .all()
+ )
+ return [r[0] for r in rows]
+
+ def count_llm_calls_for_period(
+ self, organization_id: UUID, start_date: datetime, end_date: datetime
+ ) -> int:
+ """Metered investigation LLM calls in a billing period — the hosted-
+ model usage the enterprise message-limit check adds to the bot-message
+ count. Mirrors FAQGenerationJobRepository.count_llm_calls_for_period."""
+ total = (
+ self.db.query(func.coalesce(func.sum(InvestigationRun.llm_calls), 0))
+ .filter(
+ InvestigationRun.organization_id == organization_id,
+ InvestigationRun.metered.is_(True),
+ InvestigationRun.created_at >= start_date,
+ InvestigationRun.created_at <= end_date,
+ )
+ .scalar()
+ )
+ return int(total or 0)
+
+ def latest_run_of_type(self, ticket_id: UUID, run_type: str) -> Optional[InvestigationRun]:
+ return (
+ self.db.query(InvestigationRun)
+ .filter(
+ InvestigationRun.ticket_id == ticket_id,
+ InvestigationRun.run_type == run_type,
+ )
+ .order_by(InvestigationRun.created_at.desc())
+ .first()
+ )
+
+ def list_hypotheses(self, run_id: UUID) -> List[InvestigationHypothesis]:
+ return (
+ self.db.query(InvestigationHypothesis)
+ .filter(InvestigationHypothesis.run_id == run_id)
+ .order_by(InvestigationHypothesis.idx)
+ .all()
+ )
+
+ def list_events(self, run_id: UUID, limit: int = 500) -> List[InvestigationEvent]:
+ return (
+ self.db.query(InvestigationEvent)
+ .filter(InvestigationEvent.run_id == run_id)
+ .order_by(InvestigationEvent.seq)
+ .limit(limit)
+ .all()
+ )
+
+ def latest_rca(self, ticket_id: UUID) -> Optional[RCADocument]:
+ return (
+ self.db.query(RCADocument)
+ .filter(RCADocument.ticket_id == ticket_id)
+ .order_by(RCADocument.version.desc())
+ .first()
+ )
+
+ def next_rca_version(self, ticket_id: UUID) -> int:
+ current = (
+ self.db.query(func.max(RCADocument.version))
+ .filter(RCADocument.ticket_id == ticket_id)
+ .scalar()
+ )
+ return (current or 0) + 1
+
+ def reap_orphaned_runs(self) -> int:
+ """Fail 'running' rows left behind by a dead worker (startup pass), and
+ un-stick their tickets. Without the ticket reset a crash mid-run leaves
+ the ticket in triaging/investigating with no active run — its ai_state
+ would read 'investigating' forever (nothing else recovers it)."""
+ orphaned = (
+ self.db.query(InvestigationRun)
+ .filter(InvestigationRun.status == InvestigationRunStatus.RUNNING)
+ .all()
+ )
+ transient = (TicketStatus.TRIAGING.value, TicketStatus.INVESTIGATING.value)
+ for run in orphaned:
+ run.status = InvestigationRunStatus.FAILED
+ run.error = "Worker restarted while run was in progress"
+ run.finished_at = datetime.now(timezone.utc)
+ # NB: self is InvestigationRepository — query the Ticket directly,
+ # not self.get_by_id (that resolves run ids).
+ ticket = self.db.query(Ticket).filter(Ticket.id == run.ticket_id).first()
+ if ticket is not None and str(ticket.status) in transient:
+ ticket.status = TicketStatus.OPEN.value
+ if orphaned:
+ self.db.commit()
+ return len(orphaned)
diff --git a/backend/app/repositories/user.py b/backend/app/repositories/user.py
new file mode 100644
index 0000000..7fe403e
--- /dev/null
+++ b/backend/app/repositories/user.py
@@ -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.
+"""
+
+from sqlalchemy.orm import Session
+from app.models.user import User
+from app.models.role import Role
+from app.models.permission import Permission, role_permissions
+from uuid import UUID
+from typing import Iterable, List, Optional
+from app.core.logger import get_logger
+
+logger = get_logger(__name__)
+
+
+class UserRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def get_users_by_organization(self, organization_id: UUID) -> List[User]:
+ """Get all users in an organization"""
+ return self.db.query(User)\
+ .filter(User.organization_id == organization_id)\
+ .order_by(User.created_at.desc())\
+ .all()
+
+ def get_users_with_any_permission(
+ self, organization_id: str | UUID, permission_names: Iterable[str]
+ ) -> List[User]:
+ """Active users in an org whose role grants any of `permission_names`.
+
+ Used to pick notification recipients for chats nobody owns yet, so the
+ push only reaches people who can actually open the conversation.
+ """
+ if isinstance(organization_id, str):
+ organization_id = UUID(organization_id)
+
+ names = list(permission_names)
+ if not names:
+ return []
+
+ return self.db.query(User)\
+ .join(Role, User.role_id == Role.id)\
+ .join(role_permissions, role_permissions.c.role_id == Role.id)\
+ .join(Permission, Permission.id == role_permissions.c.permission_id)\
+ .filter(User.organization_id == organization_id)\
+ .filter(User.is_active == True)\
+ .filter(Permission.name.in_(names))\
+ .distinct()\
+ .all()
+
+ def get_active_users_count(self, organization_id: str | UUID) -> int:
+ """Get count of active users in an organization"""
+ if isinstance(organization_id, str):
+ organization_id = UUID(organization_id)
+ return self.db.query(User)\
+ .filter(User.organization_id == organization_id)\
+ .filter(User.is_active == True)\
+ .count()
+
+ def get_user(self, user_id: str | UUID) -> User | None:
+ """Get a user by ID"""
+ if isinstance(user_id, str):
+ user_id = UUID(user_id)
+ return self.db.query(User).filter(User.id == user_id).first()
+
+ def get_user_by_email(self, email: str) -> User | None:
+ """Get a user by email"""
+ return self.db.query(User).filter(User.email == email).first()
+
+ def create_user(self, **kwargs) -> User:
+ """Create a new user"""
+ user = User(**kwargs)
+ self.db.add(user)
+ self.db.commit()
+ self.db.refresh(user)
+ return user
+
+ def update_user(self, user_id: str, **kwargs) -> Optional[User]:
+ """Update user"""
+ user = self.get_user(user_id)
+ if user:
+ for key, value in kwargs.items():
+ setattr(user, key, value)
+ self.db.commit()
+ self.db.refresh(user)
+ return user
+
+ def delete_user(self, user_id: str) -> bool:
+ """Delete user"""
+ user = self.get_user(user_id)
+ if user:
+ self.db.delete(user)
+ self.db.commit()
+ return True
+ return False
+
+ def get_first_admin_by_org(self, organization_id: UUID) -> Optional[User]:
+ """Get the first admin user in an organization"""
+ try:
+ return self.db.query(User)\
+ .filter(User.organization_id == organization_id)\
+ .filter(User.is_admin == True)\
+ .first()
+ except Exception as e:
+ logger.error(f"Error getting admin user: {str(e)}")
+ return None
\ No newline at end of file
diff --git a/backend/app/repositories/user_group.py b/backend/app/repositories/user_group.py
new file mode 100644
index 0000000..b94823b
--- /dev/null
+++ b/backend/app/repositories/user_group.py
@@ -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 typing import List, Optional
+from app.models.user import UserGroup, User
+from uuid import UUID
+from app.core.logger import get_logger
+
+logger = get_logger(__name__)
+
+class UserGroupRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def get_groups_by_organization(self, organization_id: UUID) -> List[UserGroup]:
+ """Get all groups in an organization"""
+ return self.db.query(UserGroup)\
+ .filter(UserGroup.organization_id == organization_id)\
+ .order_by(UserGroup.name)\
+ .all()
+
+ def get_group(self, group_id: UUID) -> Optional[UserGroup]:
+ """Get group by ID"""
+ return self.db.query(UserGroup).filter(UserGroup.id == group_id).first()
+
+ def create_group(self, name: str, description: str, organization_id: UUID) -> UserGroup:
+ """Create a new group"""
+ group = UserGroup(
+ name=name,
+ description=description,
+ organization_id=organization_id
+ )
+ self.db.add(group)
+ self.db.commit()
+ self.db.refresh(group)
+ return group
+
+ def update_group(self, group_id: UUID, **kwargs) -> Optional[UserGroup]:
+ """Update group"""
+ group = self.get_group(group_id)
+ if group:
+ for key, value in kwargs.items():
+ setattr(group, key, value)
+ self.db.commit()
+ self.db.refresh(group)
+ return group
+
+ def delete_group(self, group_id: UUID) -> bool:
+ """Delete group"""
+ group = self.get_group(group_id)
+ if group:
+ self.db.delete(group)
+ self.db.commit()
+ return True
+ return False
+
+ def add_user(self, group_id: UUID, user_id: UUID) -> bool:
+ """Add user to group.
+
+ The user is looked up within the group's own organization, so a group
+ can never gain a member from another tenant regardless of what user_id
+ a caller supplies — group membership drives chat routing and
+ assigned-chat visibility, so a foreign member would leak conversations.
+ """
+ group = self.get_group(group_id)
+ if not group:
+ return False
+
+ user = self.db.query(User).filter(
+ User.id == user_id,
+ User.organization_id == group.organization_id
+ ).first()
+ if not user:
+ return False
+
+ if user not in group.users:
+ group.users.append(user)
+ self.db.commit()
+ return True
+
+ def remove_user(self, group_id: UUID, user_id: UUID) -> bool:
+ """Remove user from group (within the group's organization)."""
+ group = self.get_group(group_id)
+ if not group:
+ return False
+
+ user = self.db.query(User).filter(
+ User.id == user_id,
+ User.organization_id == group.organization_id
+ ).first()
+ if not user:
+ return False
+
+ if user in group.users:
+ group.users.remove(user)
+ self.db.commit()
+ return True
+
+ def get_user_groups(self, user_id: UUID) -> List[UserGroup]:
+ """Get all groups a user belongs to"""
+ user = self.db.query(User).filter(User.id == user_id).first()
+ return user.groups if user else []
\ No newline at end of file
diff --git a/backend/app/repositories/widget.py b/backend/app/repositories/widget.py
new file mode 100644
index 0000000..0798184
--- /dev/null
+++ b/backend/app/repositories/widget.py
@@ -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 app.models.widget import Widget
+from app.models.schemas.widget import WidgetCreate
+
+class WidgetRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def create_widget(self, widget: WidgetCreate, organization_id: str) -> Widget:
+ db_widget = Widget(
+ name=widget.name,
+ organization_id=organization_id,
+ agent_id=widget.agent_id
+ )
+ self.db.add(db_widget)
+ self.db.commit()
+ self.db.refresh(db_widget)
+ return db_widget
+
+
+ def get_widget(self, widget_id: str) -> Widget:
+ return self.db.query(Widget).filter(Widget.id == widget_id).first()
+
+
+ def get_widgets(self, organization_id: str) -> list[Widget]:
+ return self.db.query(Widget).filter(Widget.organization_id == organization_id).all()
+
+
+ def get_widgets_by_agent(self, agent_id: str) -> list[Widget]:
+ """
+ Get all widgets associated with a specific agent.
+
+ Args:
+ agent_id: The ID of the agent
+
+ Returns:
+ List of Widget objects associated with the agent
+ """
+ return self.db.query(Widget).filter(Widget.agent_id == agent_id).all()
+
+
+ def delete_widget(self, widget_id: str) -> None:
+ self.db.query(Widget).filter(Widget.id == widget_id).delete()
+ self.db.commit()
diff --git a/backend/app/repositories/widget_app.py b/backend/app/repositories/widget_app.py
new file mode 100644
index 0000000..17fdb4e
--- /dev/null
+++ b/backend/app/repositories/widget_app.py
@@ -0,0 +1,215 @@
+"""
+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.widget_app import WidgetApp
+from app.core.security import (
+ generate_widget_api_key,
+ hash_widget_api_key,
+ verify_widget_api_key,
+ widget_api_key_lookup,
+)
+from typing import Optional, List
+from uuid import UUID
+from app.core.logger import get_logger
+
+logger = get_logger(__name__)
+
+
+class WidgetAppRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def create_app(
+ self,
+ organization_id: UUID,
+ created_by: UUID,
+ name: str,
+ description: Optional[str] = None
+ ) -> tuple[WidgetApp, str]:
+ """
+ Create a new widget app with generated API key.
+
+ Returns:
+ tuple: (WidgetApp object, plain_text_api_key)
+ Plain key is returned ONCE and never stored.
+ """
+ try:
+ # Generate new API key
+ plain_key = generate_widget_api_key()
+
+ app = WidgetApp(
+ name=name,
+ description=description,
+ organization_id=organization_id,
+ created_by=created_by,
+ api_key_hash=hash_widget_api_key(plain_key),
+ api_key_lookup=widget_api_key_lookup(plain_key),
+ is_active=True
+ )
+
+ self.db.add(app)
+ self.db.commit()
+ self.db.refresh(app)
+
+ logger.info(f"Created widget app: {app.id} for org: {organization_id}")
+
+ # Return both app and plain key (shown only once)
+ return app, plain_key
+
+ except Exception as e:
+ self.db.rollback()
+ logger.error(f"Error creating widget app: {str(e)}")
+ raise
+
+ def get_app_by_id(self, app_id: UUID, organization_id: UUID) -> Optional[WidgetApp]:
+ """Get widget app by ID (scoped to organization)"""
+ return self.db.query(WidgetApp).filter(
+ WidgetApp.id == app_id,
+ WidgetApp.organization_id == organization_id
+ ).first()
+
+ def get_apps_by_organization(
+ self,
+ organization_id: UUID,
+ include_inactive: bool = False
+ ) -> List[WidgetApp]:
+ """Get all widget apps for an organization"""
+ query = self.db.query(WidgetApp).filter(
+ WidgetApp.organization_id == organization_id
+ )
+
+ if not include_inactive:
+ query = query.filter(WidgetApp.is_active == True)
+
+ return query.order_by(WidgetApp.created_at.desc()).all()
+
+ def validate_api_key(self, api_key: str) -> Optional[WidgetApp]:
+ """
+ Validate API key and return associated app if valid.
+
+ One indexed query on the key's SHA-256. Keys issued before that column
+ existed are bcrypt-checked against the apps that still have none (about
+ 100ms each), then get their lookup filled in, so it happens once per key.
+
+ Args:
+ api_key: Plain text API key from Authorization header
+
+ Returns:
+ WidgetApp if valid, None otherwise
+ """
+ lookup = widget_api_key_lookup(api_key)
+ app = self.db.query(WidgetApp).filter(
+ WidgetApp.api_key_lookup == lookup,
+ WidgetApp.is_active == True
+ ).first()
+ if app:
+ return app
+
+ unindexed_apps = self.db.query(WidgetApp).filter(
+ WidgetApp.api_key_lookup.is_(None),
+ WidgetApp.is_active == True
+ ).all()
+ for app in unindexed_apps:
+ if verify_widget_api_key(api_key, app.api_key_hash):
+ app.api_key_lookup = lookup
+ self.db.commit()
+ logger.info(f"Valid API key for app: {app.id}; indexed its lookup")
+ return app
+
+ logger.warning("Invalid API key attempt")
+ return None
+
+ def update_app(
+ self,
+ app_id: UUID,
+ organization_id: UUID,
+ name: Optional[str] = None,
+ description: Optional[str] = None,
+ is_active: Optional[bool] = None
+ ) -> Optional[WidgetApp]:
+ """Update widget app (name, description, active status)"""
+ app = self.get_app_by_id(app_id, organization_id)
+ if not app:
+ return None
+
+ if name is not None:
+ app.name = name
+ if description is not None:
+ app.description = description
+ if is_active is not None:
+ app.is_active = is_active
+
+ self.db.commit()
+ self.db.refresh(app)
+
+ logger.info(f"Updated widget app: {app_id}")
+ return app
+
+ def deactivate_app(self, app_id: UUID, organization_id: UUID) -> bool:
+ """Deactivate (soft delete) a widget app"""
+ app = self.get_app_by_id(app_id, organization_id)
+ if not app:
+ return False
+
+ app.is_active = False
+ self.db.commit()
+
+ logger.info(f"Deactivated widget app: {app_id}")
+ return True
+
+ def regenerate_api_key(
+ self,
+ app_id: UUID,
+ organization_id: UUID
+ ) -> Optional[tuple[WidgetApp, str]]:
+ """
+ Regenerate API key for an existing app.
+
+ Returns:
+ tuple: (WidgetApp, new_plain_key) or None if app not found
+ """
+ app = self.get_app_by_id(app_id, organization_id)
+ if not app:
+ return None
+
+ # Generate new key
+ new_plain_key = generate_widget_api_key()
+
+ # The old key stops matching both ways at once
+ app.api_key_hash = hash_widget_api_key(new_plain_key)
+ app.api_key_lookup = widget_api_key_lookup(new_plain_key)
+ self.db.commit()
+ self.db.refresh(app)
+
+ logger.info(f"Regenerated API key for app: {app_id}")
+
+ return app, new_plain_key
+
+ def delete_app(self, app_id: UUID, organization_id: UUID) -> bool:
+ """
+ Hard delete a widget app (use with caution).
+ Prefer deactivate_app() for soft delete.
+ """
+ app = self.get_app_by_id(app_id, organization_id)
+ if not app:
+ return False
+
+ self.db.delete(app)
+ self.db.commit()
+
+ logger.warning(f"HARD DELETED widget app: {app_id}")
+ return True
diff --git a/backend/app/repositories/workflow.py b/backend/app/repositories/workflow.py
new file mode 100644
index 0000000..b476e09
--- /dev/null
+++ b/backend/app/repositories/workflow.py
@@ -0,0 +1,127 @@
+"""
+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, Dict, Any
+from uuid import UUID
+from sqlalchemy.orm import Session, selectinload
+from sqlalchemy.exc import SQLAlchemyError
+
+from app.models.workflow import Workflow, WorkflowStatus
+from app.models.workflow_node import WorkflowNode
+from app.models.workflow_connection import WorkflowConnection
+
+from app.core.logger import get_logger
+
+logger = get_logger(__name__)
+
+
+class WorkflowRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def create_workflow(self, **kwargs) -> Workflow:
+ """Create a new workflow"""
+ try:
+ workflow = Workflow(**kwargs)
+ self.db.add(workflow)
+ self.db.commit()
+ self.db.refresh(workflow)
+ logger.info(f"Created workflow: {workflow.id}")
+ return workflow
+ except Exception as e:
+ logger.error(f"Error creating workflow: {str(e)}")
+ self.db.rollback()
+ raise
+
+ def get_by_id(self, workflow_id: UUID) -> Optional[Workflow]:
+ """Get workflow by ID"""
+ return self.db.query(Workflow).filter(Workflow.id == workflow_id).first()
+
+ def get_by_name(self, name: str, organization_id: UUID) -> Optional[Workflow]:
+ """Get workflow by name within organization"""
+ return self.db.query(Workflow).filter(
+ Workflow.name == name,
+ Workflow.organization_id == organization_id
+ ).first()
+
+ def get_all_workflows(self, organization_id: UUID) -> List[Workflow]:
+ """Get all workflows for an organization"""
+ return self.db.query(Workflow).filter(
+ Workflow.organization_id == organization_id
+ ).all()
+
+ def get_workflows_by_agent(self, agent_id: UUID) -> List[Workflow]:
+ """Get all workflows for a specific agent"""
+ return self.db.query(Workflow).filter(
+ Workflow.agent_id == agent_id
+ ).all()
+
+ def get_published_workflows(self, organization_id: UUID) -> List[Workflow]:
+ """Get all published workflows for an organization"""
+ return self.db.query(Workflow).filter(
+ Workflow.organization_id == organization_id,
+ Workflow.status == WorkflowStatus.PUBLISHED
+ ).all()
+
+ def get_workflow_templates(self, organization_id: UUID) -> List[Workflow]:
+ """Get all workflow templates for an organization"""
+ return self.db.query(Workflow).filter(
+ Workflow.organization_id == organization_id,
+ Workflow.is_template == True
+ ).all()
+
+ def update_workflow(self, workflow_id: UUID, **kwargs) -> Optional[Workflow]:
+ """Update workflow"""
+ try:
+ workflow = self.get_by_id(workflow_id)
+ if not workflow:
+ return None
+
+ for key, value in kwargs.items():
+ if hasattr(workflow, key):
+ setattr(workflow, key, value)
+
+ self.db.commit()
+ self.db.refresh(workflow)
+ logger.info(f"Updated workflow: {workflow_id}")
+ return workflow
+ except Exception as e:
+ logger.error(f"Error updating workflow {workflow_id}: {str(e)}")
+ self.db.rollback()
+ raise
+
+ def delete_workflow(self, workflow_id: UUID) -> bool:
+ """Delete workflow and all related data"""
+ try:
+ workflow = self.get_by_id(workflow_id)
+ if not workflow:
+ return False
+
+ self.db.delete(workflow)
+ self.db.commit()
+ logger.info(f"Deleted workflow: {workflow_id}")
+ return True
+ except Exception as e:
+ logger.error(f"Error deleting workflow {workflow_id}: {str(e)}")
+ self.db.rollback()
+ raise
+
+ def get_workflow_with_nodes_and_connections(self, workflow_id: UUID) -> Optional[Workflow]:
+ """Get workflow with all nodes and connections"""
+ return self.db.query(Workflow).options(
+ selectinload(Workflow.nodes),
+ selectinload(Workflow.connections)
+ ).filter(Workflow.id == workflow_id).first()
\ No newline at end of file
diff --git a/backend/app/repositories/workflow_node.py b/backend/app/repositories/workflow_node.py
new file mode 100644
index 0000000..847e812
--- /dev/null
+++ b/backend/app/repositories/workflow_node.py
@@ -0,0 +1,133 @@
+"""
+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.orm import Session
+from app.models.workflow_node import WorkflowNode
+from app.models.workflow_connection import WorkflowConnection
+from app.core.logger import get_logger
+
+logger = get_logger(__name__)
+
+
+class WorkflowNodeRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def create_node(self, **kwargs) -> WorkflowNode:
+ """Create a new workflow node"""
+ try:
+ node = WorkflowNode(**kwargs)
+ self.db.add(node)
+ self.db.flush() # Flush to get the ID without committing
+ logger.info(f"Created workflow node: {node.id}")
+ return node
+ except Exception as e:
+ logger.error(f"Error creating workflow node: {str(e)}")
+ raise
+
+ def get_nodes_by_workflow(self, workflow_id: UUID) -> List[WorkflowNode]:
+ """Get all nodes for a workflow"""
+ return self.db.query(WorkflowNode).filter(
+ WorkflowNode.workflow_id == workflow_id
+ ).all()
+
+ def get_node_by_id(self, node_id: UUID) -> Optional[WorkflowNode]:
+ """Get node by ID"""
+ return self.db.query(WorkflowNode).filter(WorkflowNode.id == node_id).first()
+
+ def update_node(self, node_id: UUID, **kwargs) -> Optional[WorkflowNode]:
+ """Update workflow node"""
+ try:
+ node = self.get_node_by_id(node_id)
+ if not node:
+ return None
+
+ # Filter out None values to avoid overwriting existing data with None
+ # Only update fields that have actual values
+ for key, value in kwargs.items():
+ if hasattr(node, key) and value is not None:
+ setattr(node, key, value)
+
+ self.db.flush() # Flush to update without committing
+ logger.info(f"Updated workflow node: {node_id}")
+ return node
+ except Exception as e:
+ logger.error(f"Error updating workflow node {node_id}: {str(e)}")
+ raise
+
+ def delete_node(self, node_id: UUID) -> bool:
+ """Delete workflow node"""
+ try:
+ node = self.get_node_by_id(node_id)
+ if not node:
+ return False
+
+ self.db.delete(node)
+ self.db.flush() # Flush to delete without committing
+ logger.info(f"Deleted workflow node: {node_id}")
+ return True
+ except Exception as e:
+ logger.error(f"Error deleting workflow node {node_id}: {str(e)}")
+ raise
+
+ def delete_nodes_by_workflow(self, workflow_id: UUID) -> bool:
+ """Delete all nodes for a workflow"""
+ try:
+ # Delete all nodes directly without calling get_nodes_by_workflow to avoid the JOIN issue
+ deleted_count = self.db.query(WorkflowNode).filter(
+ WorkflowNode.workflow_id == workflow_id
+ ).delete()
+
+ self.db.flush() # Flush to delete without committing
+ logger.info(f"Deleted {deleted_count} nodes for workflow: {workflow_id}")
+ return True
+ except Exception as e:
+ logger.error(f"Error deleting nodes for workflow {workflow_id}: {str(e)}")
+ raise
+
+ def create_connection(self, **kwargs) -> WorkflowConnection:
+ """Create a new workflow connection"""
+ try:
+ connection = WorkflowConnection(**kwargs)
+ self.db.add(connection)
+ self.db.flush() # Flush to get the ID without committing
+ logger.info(f"Created workflow connection: {connection.id}")
+ return connection
+ except Exception as e:
+ logger.error(f"Error creating workflow connection: {str(e)}")
+ raise
+
+ def get_connections_by_workflow(self, workflow_id: UUID) -> List[WorkflowConnection]:
+ """Get all connections for a workflow"""
+ return self.db.query(WorkflowConnection).filter(
+ WorkflowConnection.workflow_id == workflow_id
+ ).all()
+
+ def delete_connections_by_workflow(self, workflow_id: UUID) -> bool:
+ """Delete all connections for a workflow"""
+ try:
+ connections = self.get_connections_by_workflow(workflow_id)
+ for connection in connections:
+ self.db.delete(connection)
+
+ self.db.flush() # Flush to delete without committing
+ logger.info(f"Deleted all connections for workflow: {workflow_id}")
+ return True
+ except Exception as e:
+ logger.error(f"Error deleting connections for workflow {workflow_id}: {str(e)}")
+ raise
\ No newline at end of file
diff --git a/backend/app/utils/agno_eval_guard.py b/backend/app/utils/agno_eval_guard.py
new file mode 100644
index 0000000..77514a1
--- /dev/null
+++ b/backend/app/utils/agno_eval_guard.py
@@ -0,0 +1,75 @@
+"""
+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.
+"""
+
+# Runtime guard for the pinned agno==1.7.6 (GHSA-77rh-m34w-rv36, fixed
+# upstream in 2.3.24). Remove this module with the agno 2.x upgrade.
+#
+# agno turns a user-input field's type name back into a Python type by calling
+# eval() on a string it did not produce: in agno.models.base the string is the
+# `field_type` of a `get_user_input` tool call, i.e. text the model wrote, and
+# in agno.tools.function it is read back from a stored run response. A tool
+# named get_user_input is all it takes to reach that eval, and MCP connectors
+# register tools under whatever name the server advertises, so a hostile or
+# prompt-injected connector could run arbitrary Python inside the backend.
+#
+# Both modules look `eval` up as a global name, so binding a module-level `eval`
+# shadows the builtin for those two modules only. The replacement never
+# evaluates anything: it maps a bare builtin type name to the type and refuses
+# everything else.
+
+import agno.models.base
+import agno.tools.function
+
+from app.core.logger import get_logger
+
+logger = get_logger(__name__)
+
+# Every type UserInputField.to_dict can emit (it writes type.__name__). Nothing
+# else was ever a valid field type, so refusing it changes no legitimate path.
+_FIELD_TYPES = {t.__name__: t for t in (str, int, float, bool, list, dict, tuple, set, bytes)}
+
+_GUARDED_MODULES = (agno.models.base, agno.tools.function)
+
+
+class UnsafeFieldTypeError(NameError):
+ """Raised for any field_type that is not a plain builtin type name.
+
+ A NameError on purpose: agno's model loop catches (NameError, SyntaxError)
+ around its eval and falls back to str, so a rejected value degrades exactly
+ the way an unknown name always did — the run continues, nothing executes.
+ """
+
+
+def resolve_field_type(expr, *_args, **_kwargs):
+ """Drop-in for eval() that only ever maps a type name to the type.
+
+ Accepts eval's optional globals/locals so any call shape agno uses works.
+ """
+ if isinstance(expr, str):
+ field_type = _FIELD_TYPES.get(expr.strip())
+ if field_type is not None:
+ return field_type
+ # agno swallows the error and carries on with str, so without this line a
+ # blocked payload would leave no trace at all. Nothing legitimate reaches
+ # here, which makes every one of these worth seeing.
+ logger.warning(f"Blocked an unsafe user-input field type: {expr!r}")
+ raise UnsafeFieldTypeError(f"Unsupported user-input field type: {expr!r}")
+
+
+def install_eval_guard() -> None:
+ """Shadow eval in the agno modules that hand model-supplied text to it."""
+ for module in _GUARDED_MODULES:
+ module.eval = resolve_field_type
diff --git a/backend/app/utils/agno_patches.py b/backend/app/utils/agno_patches.py
new file mode 100644
index 0000000..a83c670
--- /dev/null
+++ b/backend/app/utils/agno_patches.py
@@ -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.
+"""
+
+# Runtime patches for the pinned agno==1.7.6. Remove this module with the
+# agno 2.x upgrade.
+#
+# agno 1.7.6 does not terminate a run when tool_call_limit is exceeded: it
+# appends a tool-result message asking the model not to call the tool again
+# and sends the conversation back to the model. Models that ignore the
+# instruction (observed with gpt-4o-mini) then loop forever, re-sending an
+# ever-growing payload on every iteration (issue #269).
+#
+# The first fix set stop_after_tool_call=True on the limit-error message, which
+# breaks the response loop in agno.models.base. That ended the loop, but it ended
+# it AT THE TOOL RESULT — before the model ever wrote an answer — so every run
+# that reached the limit returned an empty turn and the visitor got the
+# "I didn't quite catch that" fallback. In production that was most turns on any
+# agent with a few knowledge sources.
+#
+# What we actually want is a bounded run that still answers: at the limit, stop
+# offering tools and let the model reply from what it already retrieved. Clearing
+# `tools` for the next completion does exactly that, and it also makes the runaway
+# loop impossible — a model with no tools cannot call one.
+#
+# There is deliberately NO hard-stop backstop. An earlier version kept one for the
+# case where withdrawal somehow failed, and it turned out to be the only remaining
+# producer of empty replies: it fired occasionally and cost the visitor an answer
+# every time. The hang it guarded against is already prevented by the
+# AGENT_RUN_TIMEOUT wrapper around every agno run, which does not depend on this
+# module patching the right entry points. Two locks on one door, and this was the
+# lock that slammed on the user.
+
+from agno.models.base import Model
+
+from app.core.logger import get_logger
+from app.utils.agno_eval_guard import install_eval_guard
+
+logger = get_logger(__name__)
+
+_PATCHED_FLAG = "_chattermate_answers_at_limit"
+
+# Set on the Model instance once this run has spent its tool budget. Safe as
+# instance state: create_model() builds a fresh Model per ChatAgent and ChatAgent
+# is built per request, so it never outlives a single run.
+#
+# A model can request SEVERAL tools in one assistant message and agno walks that
+# whole batch before calling the model again, so this is hit repeatedly within a
+# single round. That is fine — setting it twice is a no-op. It mattered only while
+# there was a backstop that had to tell a parallel sibling apart from a genuine
+# repeat; without one, a plain flag is enough.
+_EXHAUSTED = "_chattermate_tools_exhausted"
+
+# Groq's structured output arrives as a tool call of this name rather than as
+# text. Kept in step with app.agents.structured_output.build_groq_json_tool.
+CAPTURE_TOOL_NAME = "json"
+
+# Replaces agno's own limit-error text, which only tells the model it may not
+# call the tool again. Left at that, gpt-4o-mini narrated the plumbing to the
+# visitor ("I wasn't able to retrieve ... due to tool limitation") and then
+# answered from general knowledge — which also contradicts the grounding rule the
+# guardrail relies on. Say what to do instead: answer from what is already here,
+# and if that is not enough, admit it rather than invent.
+_LIMIT_INSTRUCTION = (
+ "You have used all the tool calls available for this turn. Answer now, using "
+ "only what the tools have already returned and what your instructions cover. "
+ "Do not mention tools, searches, limits or any internal constraint — the "
+ "visitor must not read about the plumbing. If what you have is not enough to "
+ "answer properly, say plainly that you don't have that detail and offer to "
+ "connect them with the team. Never fill the gap with general knowledge."
+)
+
+
+def _tool_name(tool) -> str:
+ """Name out of a provider-format tool definition, whatever the nesting."""
+ if not isinstance(tool, dict):
+ return ""
+ fn = tool.get("function")
+ if isinstance(fn, dict):
+ return fn.get("name") or ""
+ return tool.get("name") or ""
+
+
+def _strip_tools(self, kwargs: dict) -> dict:
+ """Withdraw the action tools once the budget is spent — but never the tool
+ the model answers THROUGH.
+
+ On Groq the structured response is not text: it is a tool call named `json`
+ whose arguments are the response fields (see app.agents.structured_output).
+ Withdrawing every tool would take away the only way that model has to reply,
+ so a Groq agent hitting the limit would return an empty turn every time —
+ precisely the failure this patch exists to remove.
+ """
+ tools = kwargs.get("tools")
+ if not getattr(self, _EXHAUSTED, False) or not tools:
+ return kwargs
+
+ kept = [t for t in tools if _tool_name(t) == CAPTURE_TOOL_NAME]
+ if kept:
+ # Leave tool_choice alone: the capture tool is how the answer is emitted,
+ # so the model must still be allowed — or required — to call it.
+ return {**kwargs, "tools": kept}
+ return {**kwargs, "tools": None, "tool_choice": None}
+
+
+def apply_agno_patches() -> None:
+ """Apply all agno runtime patches. Safe to call more than once."""
+ # Idempotent on its own, so it sits before the flag check: the guard must be
+ # in place whichever call reaches here first.
+ install_eval_guard()
+
+ if getattr(Model.create_tool_call_limit_error_result, _PATCHED_FLAG, False):
+ return
+
+ original_limit_result = Model.create_tool_call_limit_error_result
+ original_process = Model._process_model_response
+ original_aprocess = Model._aprocess_model_response
+ # Streaming is a separate pair of entry points that never touch the two
+ # above. Patching only the non-streaming ones left the live chat — which
+ # streams — with its tools intact after the limit, so the next round called a
+ # tool again and hit the backstop. Verified against a real agent.
+ original_stream = Model.process_response_stream
+ original_astream = Model.aprocess_response_stream
+
+ def create_tool_call_limit_error_result(self, function_call):
+ message = original_limit_result(self, function_call)
+
+ # Every over-budget call in the batch gets the instruction, not just the
+ # first: the model reads all of the tool results, and leaving agno's
+ # "tool call limit reached" text on the siblings was enough for it to keep
+ # narrating the limit to the visitor.
+ message.content = _LIMIT_INSTRUCTION
+
+ if not getattr(self, _EXHAUSTED, False):
+ setattr(self, _EXHAUSTED, True)
+ logger.warning(
+ "Tool call limit reached; answering without further tools "
+ f"(tool={function_call.function.name})"
+ )
+ return message
+
+ def _process_model_response(self, *args, **kwargs):
+ return original_process(self, *args, **_strip_tools(self, kwargs))
+
+ async def _aprocess_model_response(self, *args, **kwargs):
+ return await original_aprocess(self, *args, **_strip_tools(self, kwargs))
+
+ def process_response_stream(self, *args, **kwargs):
+ yield from original_stream(self, *args, **_strip_tools(self, kwargs))
+
+ async def aprocess_response_stream(self, *args, **kwargs):
+ async for chunk in original_astream(self, *args, **_strip_tools(self, kwargs)):
+ yield chunk
+
+ setattr(create_tool_call_limit_error_result, _PATCHED_FLAG, True)
+ Model.create_tool_call_limit_error_result = create_tool_call_limit_error_result
+ Model._process_model_response = _process_model_response
+ Model._aprocess_model_response = _aprocess_model_response
+ Model.process_response_stream = process_response_stream
+ Model.aprocess_response_stream = aprocess_response_stream
diff --git a/backend/app/utils/agno_utils.py b/backend/app/utils/agno_utils.py
new file mode 100644
index 0000000..a373f15
--- /dev/null
+++ b/backend/app/utils/agno_utils.py
@@ -0,0 +1,208 @@
+"""
+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 agno.agent import Agent
+from agno.models.openai import OpenAIChat
+from openai import AsyncOpenAI, OpenAI
+from app.core.logger import get_logger
+from app.core.config import settings
+from app.core.outbound_http import guarded_async_http_client, guarded_http_client
+from app.core.outbound_policy import check_outbound_target, public_only
+from app.utils.agno_patches import apply_agno_patches
+from typing import Dict, Any, Optional, List
+from fastapi import HTTPException
+
+logger = get_logger(__name__)
+
+# Every code path that builds an agno model goes through this module, so this
+# is the single choke point where the 1.7.6 runtime patches get applied.
+apply_agno_patches()
+
+
+class PinnedOpenAIChat(OpenAIChat):
+ """OpenAIChat for a user-supplied base URL under OUTBOUND_URL_POLICY=
+ public_only: every connection, redirect included, goes to a vetted public
+ address. agno 1.7.6 takes a single http_client for both its sync and
+ async OpenAI clients, so each getter supplies its own guarded one, fresh
+ per call like agno's default."""
+
+ def get_client(self):
+ # follow_redirects matches the OpenAI SDK's own default client.
+ return OpenAI(**{**self._get_client_params(),
+ "http_client": guarded_http_client(follow_redirects=True)})
+
+ def get_async_client(self):
+ return AsyncOpenAI(**{**self._get_client_params(),
+ "http_client": guarded_async_http_client(follow_redirects=True)})
+
+
+def create_model(model_type: str, api_key: str, model_name: str, max_tokens: int = 1000, response_format: Optional[Dict[str, Any]] = None, base_url: Optional[str] = None) -> Any:
+ """
+ Create and return the specified model based on model_type.
+
+ Args:
+ model_type: The type of model (OPENAI, GROQ, etc.)
+ api_key: The API key
+ model_name: The name/ID of the model
+ max_tokens: Maximum tokens for model output
+ response_format: Optional response format specification
+ base_url: Custom API endpoint, used by OPENAI_COMPATIBLE
+
+ Returns:
+ The initialized model object
+
+ Raises:
+ HTTPException: If the model type is not supported
+ """
+ model_type = model_type.upper()
+
+ try:
+ if model_type == 'OPENAI' or model_type == 'CHATTERMATE': # own model for enterprise customers
+ if response_format:
+ return OpenAIChat(api_key=api_key, id=model_name, max_tokens=max_tokens, response_format=response_format)
+ else:
+ return OpenAIChat(api_key=api_key, id=model_name, max_tokens=max_tokens)
+ elif model_type == 'OPENAI_COMPATIBLE':
+ if not base_url:
+ raise ValueError("base_url is required for OPENAI_COMPATIBLE")
+ # Re-checked on every build, not only on save: DNS can change.
+ check_outbound_target(base_url, "Base URL")
+ base_url = re.sub(r'/chat/completions/?$', '', base_url.strip(), flags=re.IGNORECASE).rstrip('/')
+ model_cls = PinnedOpenAIChat if public_only() else OpenAIChat
+ if response_format:
+ return model_cls(api_key=api_key, id=model_name, base_url=base_url, max_tokens=max_tokens, response_format=response_format)
+ else:
+ return model_cls(api_key=api_key, id=model_name, base_url=base_url, max_tokens=max_tokens)
+ elif model_type == 'ANTHROPIC':
+ from agno.models.anthropic import Claude
+ return Claude(api_key=api_key, id=model_name, max_tokens=max_tokens)
+ elif model_type == 'DEEPSEEK':
+ from agno.models.deepseek import DeepSeek
+ return DeepSeek(api_key=api_key, id=model_name, max_tokens=max_tokens)
+ elif model_type == 'GOOGLE':
+ from agno.models.google import Gemini
+ # Gemini's agno model exposes max_output_tokens, not max_tokens.
+ return Gemini(api_key=api_key, id=model_name, max_output_tokens=max_tokens)
+ elif model_type == 'GOOGLEVERTEX':
+ from agno.models.vertexai import Gemini
+ return Gemini(api_key=api_key, id=model_name, max_tokens=max_tokens)
+ elif model_type == 'GROQ':
+ from agno.models.groq import Groq
+
+ # Create a patched Groq model that handles the response_format + tools conflict
+ class PatchedGroq(Groq):
+ def __post_init__(self):
+ """Set supports_native_structured_outputs after initialization"""
+ super().__post_init__() if hasattr(super(), '__post_init__') else None
+ # Override to indicate Groq supports structured outputs via response_format
+ object.__setattr__(self, 'supports_native_structured_outputs', True)
+
+ def get_request_params(self, response_format=None, tools=None, tool_choice=None):
+ """
+ Override get_request_params to handle the conflict between response_format and tools.
+ Groq API doesn't allow JSON response_format when tools are present.
+ """
+ # Get the base parameters from parent class
+ base_params = {
+ "frequency_penalty": self.frequency_penalty,
+ "logit_bias": self.logit_bias,
+ "logprobs": self.logprobs,
+ "max_tokens": self.max_tokens,
+ "presence_penalty": self.presence_penalty,
+ "seed": self.seed,
+ "stop": self.stop,
+ "temperature": self.temperature,
+ "top_logprobs": self.top_logprobs,
+ "top_p": self.top_p,
+ "user": self.user,
+ "extra_headers": self.extra_headers,
+ "extra_query": self.extra_query,
+ }
+
+ # Filter out None values
+ request_params = {k: v for k, v in base_params.items() if v is not None}
+
+ # Add tools first
+ if tools is not None:
+ request_params["tools"] = tools
+ if tool_choice is not None:
+ request_params["tool_choice"] = tool_choice
+ # Don't add response_format when tools are present to avoid conflict
+ logger.debug("Groq: Skipping response_format due to tools being present")
+ else:
+ # Only add response_format if no tools are present
+ if response_format is not None:
+ request_params["response_format"] = response_format
+
+ # Add additional request params if provided
+ if self.request_params:
+ request_params.update(self.request_params)
+
+ return request_params
+
+ # Groq model doesn't support response_format parameter in constructor
+ return PatchedGroq(api_key=api_key, id=model_name, max_tokens=max_tokens)
+ elif model_type == 'MISTRAL':
+ from agno.models.mistral import MistralChat
+ return MistralChat(api_key=api_key, id=model_name, max_tokens=max_tokens)
+ elif model_type == 'HUGGINGFACE':
+ from agno.models.huggingface import HuggingFace
+ return HuggingFace(api_key=api_key, id=model_name, max_tokens=max_tokens)
+ elif model_type == 'OLLAMA':
+ from agno.models.ollama import Ollama
+ return Ollama(id=model_name)
+ elif model_type == 'XAI':
+ from agno.models.xai import xAI
+ return xAI(api_key=api_key, id=model_name, max_tokens=max_tokens)
+ else:
+ raise ValueError(f"Unsupported model type: {model_type}")
+ except ImportError as e:
+ logger.error(f"Import error when creating model type {model_type}: {str(e)}")
+ raise HTTPException(status_code=400, detail=f"Model type {model_type} is not available in this installation")
+ except Exception as e:
+ logger.error(f"Error creating model type {model_type}: {str(e)}")
+ raise HTTPException(status_code=500, detail=f"Failed to initialize model: {str(e)}")
+
+async def test_model_api_key(api_key: str, model_type: str, model_name: str, base_url: Optional[str] = None) -> bool:
+ """
+ Test if the API key is valid for the given model type.
+
+ Args:
+ api_key: The API key to test
+ model_type: The type of model
+ model_name: The name of the model
+ base_url: Custom API endpoint, used by OPENAI_COMPATIBLE
+
+ Returns:
+ bool: True if the API key is valid, False otherwise
+ """
+ try:
+ # Create a simple model and agent for testing
+ model = create_model(model_type, api_key, model_name, base_url=base_url)
+ test_agent = Agent(
+ name="Test Agent",
+ model=model,
+ instructions="You are a test agent. Just respond with 'Test successful.'",
+ debug_mode=False
+ )
+
+ # Run a simple test query
+ await test_agent.arun(message="This is a test message.")
+ return True
+ except Exception as e:
+ logger.error(f"API key test failed: {str(e)}")
+ return False
\ No newline at end of file
diff --git a/backend/app/utils/backoff.py b/backend/app/utils/backoff.py
new file mode 100644
index 0000000..4b0819d
--- /dev/null
+++ b/backend/app/utils/backoff.py
@@ -0,0 +1,33 @@
+"""
+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.
+
+Retry timing for queued work that calls other services (CRM pushes,
+webhooks to external apps).
+"""
+
+import random
+from typing import Optional
+
+
+def next_attempt_delay(attempts: int, *, base_seconds: float, max_seconds: float,
+ jitter_seconds: float, retry_after_seconds: Optional[float] = None) -> float:
+ """Seconds to wait before attempt number `attempts + 1`.
+
+ Exponential (base · 2^attempts) capped at `max_seconds`; a longer
+ Retry-After from the other service wins; random jitter on top so a burst
+ of failures doesn't retry in lockstep.
+ """
+ backoff = min(base_seconds * (2 ** attempts), max_seconds)
+ return max(retry_after_seconds or 0, backoff) + random.uniform(0, jitter_seconds)
diff --git a/backend/app/utils/business_hours.py b/backend/app/utils/business_hours.py
new file mode 100644
index 0000000..c919536
--- /dev/null
+++ b/backend/app/utils/business_hours.py
@@ -0,0 +1,83 @@
+"""
+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
+
+import pytz
+
+from app.core.logger import get_logger
+
+logger = get_logger(__name__)
+
+# Used when an organization has never configured its hours. Weekdays 9-5 in the
+# org's timezone, weekend closed.
+DEFAULT_BUSINESS_HOURS = {
+ '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},
+}
+
+_DEFAULT_START = '09:00'
+_DEFAULT_END = '17:00'
+
+
+def organization_timezone(organization) -> pytz.BaseTzInfo:
+ """The org's timezone, falling back to UTC for a missing or bad value."""
+ tz_name = getattr(organization, 'timezone', None) or 'UTC'
+ try:
+ return pytz.timezone(tz_name)
+ except pytz.UnknownTimeZoneError:
+ logger.warning(f"Invalid timezone {tz_name}, using UTC")
+ return pytz.UTC
+
+
+def is_within_business_hours(organization) -> bool:
+ """Whether the organization is open right now, in its own timezone.
+
+ Pure and cheap — no database work and no model call — so it can answer the
+ widget's "are you around?" on every page load, not just when the AI is
+ deciding whether to hand a chat over.
+
+ Note: hours are compared as minutes-of-day, so a window that wraps midnight
+ (22:00-02:00) never matches. That is the behaviour this was extracted from;
+ changing it would change when the AI offers a transfer.
+ """
+ business_hours = getattr(organization, 'business_hours', None) or DEFAULT_BUSINESS_HOURS
+
+ now = datetime.now(organization_timezone(organization))
+ today = business_hours.get(now.strftime('%A').lower(), {
+ 'start': _DEFAULT_START,
+ 'end': _DEFAULT_END,
+ 'enabled': False,
+ })
+
+ if not today.get('enabled', False):
+ return False
+
+ try:
+ start_hour, start_minute = map(int, today.get('start', _DEFAULT_START).split(':'))
+ end_hour, end_minute = map(int, today.get('end', _DEFAULT_END).split(':'))
+ except (ValueError, AttributeError) as e:
+ logger.error(f"Error parsing business hours: {str(e)}")
+ start_hour, start_minute = 9, 0
+ end_hour, end_minute = 17, 0
+
+ minutes_now = now.hour * 60 + now.minute
+ return (start_hour * 60 + start_minute) <= minutes_now <= (end_hour * 60 + end_minute)
diff --git a/backend/app/utils/client_ip.py b/backend/app/utils/client_ip.py
new file mode 100644
index 0000000..3d130e3
--- /dev/null
+++ b/backend/app/utils/client_ip.py
@@ -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.
+
+The client's IP for keying (rate limits, telling visitors apart). Our nginx
+APPENDS the real client IP to any X-Forwarded-For the client sent, so only
+the RIGHTMOST entry is trustworthy - the first one can be anything.
+"""
+
+from fastapi import Request
+
+
+def forwarded_client_ip(request: Request) -> str:
+ forwarded = request.headers.get("x-forwarded-for")
+ if forwarded:
+ return forwarded.split(",")[-1].strip()
+ return request.client.host if request.client else "unknown"
diff --git a/backend/app/utils/domain_check.py b/backend/app/utils/domain_check.py
new file mode 100644
index 0000000..65a8729
--- /dev/null
+++ b/backend/app/utils/domain_check.py
@@ -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.
+
+Whether a signup domain resolves in DNS - for a soft warning, never a block:
+some people sign up before their site is live.
+"""
+
+import asyncio
+import socket
+import threading
+import time
+from concurrent.futures import ThreadPoolExecutor
+from typing import Dict, Optional, Tuple
+from urllib.parse import urlparse
+
+LOOKUP_TIMEOUT_SECONDS = 2.0
+# getaddrinfo blocks, and a thread can't be cancelled: a dead nameserver holds
+# one until the system resolver gives up, long after our timeout. So lookups
+# get a small pool of their own (never the event loop's shared one) and never
+# queue: when every slot is busy the answer is "can't tell".
+MAX_LOOKUPS = 4
+_executor = ThreadPoolExecutor(max_workers=MAX_LOOKUPS, thread_name_prefix="domain-check")
+_slots = threading.BoundedSemaphore(MAX_LOOKUPS)
+# Only these mean "no such name"; anything else (EAI_AGAIN, a failing
+# resolver) is a temporary failure and says nothing about the domain.
+_NOT_FOUND = {socket.EAI_NONAME, getattr(socket, "EAI_NODATA", socket.EAI_NONAME)}
+# The field re-checks on every pause in typing: remember definite answers
+# for a while so that doesn't cost a lookup each time.
+CACHE_SECONDS = 600
+CACHE_LIMIT = 1000
+_cache: Dict[str, Tuple[float, bool]] = {}
+
+
+def bare_host(domain: str) -> str:
+ """'https://Example.com/path' -> 'example.com'."""
+ value = domain.strip()
+ return (urlparse(value if "//" in value else f"//{value}").hostname or "").lower()
+
+
+def site_host(domain: str) -> str:
+ """An org's site as hostnames compare: 'https://WWW.Acme.com/' -> 'acme.com'.
+ CORS admits this host and its www., however the domain was typed."""
+ return bare_host(domain).removeprefix("www.")
+
+
+def _lookup(host: str) -> Optional[bool]:
+ try:
+ socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
+ return True
+ except socket.gaierror as e:
+ return False if e.errno in _NOT_FOUND else None
+
+
+async def _resolves(host: str) -> Optional[bool]:
+ if not _slots.acquire(blocking=False):
+ return None
+ try:
+ future = _executor.submit(_lookup, host)
+ except RuntimeError: # pool shut down
+ _slots.release()
+ return None
+ # Released when the lookup ends - or is cancelled before it ever ran,
+ # which a release inside _lookup would miss.
+ future.add_done_callback(lambda _: _slots.release())
+ return await asyncio.wrap_future(future)
+
+
+def _combine(answers) -> Optional[bool]:
+ """Any address is enough; "doesn't exist" only when every name said so."""
+ if any(answer is True for answer in answers):
+ return True
+ return False if answers and all(answer is False for answer in answers) else None
+
+
+async def domain_resolves(domain: str) -> Optional[bool]:
+ """True/False when DNS answered, None when it couldn't say in time (or the
+ input isn't a hostname) - callers warn only on a definite False. Asks for
+ the bare domain and www. together (some sites only publish www), within
+ one overall time budget."""
+ host = bare_host(domain)
+ if not host or "." not in host:
+ return None
+ cached = _cache.get(host)
+ if cached and time.monotonic() - cached[0] < CACHE_SECONDS:
+ return cached[1]
+ candidates = {host, f"www.{host.removeprefix('www.')}"}
+ try:
+ result = _combine(await asyncio.wait_for(
+ asyncio.gather(*(_resolves(candidate) for candidate in candidates)), LOOKUP_TIMEOUT_SECONDS
+ ))
+ except asyncio.TimeoutError:
+ result = None
+ if result is not None:
+ if len(_cache) >= CACHE_LIMIT:
+ _cache.clear()
+ _cache[host] = (time.monotonic(), result)
+ return result
diff --git a/backend/app/utils/guardrail_runtime.py b/backend/app/utils/guardrail_runtime.py
new file mode 100644
index 0000000..c08c21a
--- /dev/null
+++ b/backend/app/utils/guardrail_runtime.py
@@ -0,0 +1,285 @@
+"""
+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.
+
+Runtime enforcement for the global chat guardrails.
+
+Two checks around every LLM call on visitor text:
+- check_inbound: strong-signal injection detection BEFORE inference. Blocking
+ is governed by GUARDRAIL_INBOUND_ACTION ("off" / "template_only" / "strict");
+ non-blocking hits are still counted, which is the data that gates ever
+ turning "strict" on.
+- check_output: scans the reply for leaked policy text (replace + record) and
+ for the model's own scope refusal (record only) — the only place scope
+ declines become measurable, since chat messages are encrypted at rest.
+
+Every entry point here FAILS OPEN: a guardrail bug must degrade to
+"no guardrail", never to "no chat".
+"""
+
+from dataclasses import dataclass, field
+from typing import List, Optional, Tuple
+
+from app.agents.guardrail_policy import CANARY_STRINGS, looks_like_scope_refusal
+from app.core.config import settings
+from app.core.logger import get_logger
+from app.repositories.guardrail_event import record_guardrail_events
+from app.utils.guardrails import (
+ RULE_FRAME_TOKENS,
+ RULE_OFFTOPIC_EXERCISE,
+ detect_injection,
+ detect_offtopic_exercise,
+)
+
+logger = get_logger(__name__)
+
+# What the visitor sees when a message is blocked pre-inference.
+BLOCK_REPLY = (
+ "I can't process that message. If you have a question I can help with, "
+ "just ask it in plain words."
+)
+
+# What replaces a reply that leaked policy text.
+LEAK_REPLY = (
+ "I can't share details about how I'm set up. Is there something else I "
+ "can help you with?"
+)
+
+_EXCERPT_MAX = 300
+
+# Output-side rule ids (the inbound ones live with their patterns in guardrails).
+RULE_PROMPT_LEAK = "injection.prompt_leak"
+RULE_MODEL_REFUSED = "offtopic.model_refused"
+
+
+class Surface:
+ """Where a guardrail event came from — the values stored in
+ guardrail_events.surface."""
+
+ WIDGET = "widget"
+ CHANNEL = "channel"
+ WORKFLOW = "workflow"
+ HELP_CENTER = "help_center"
+
+
+def offtopic_reply(org_name: Optional[str]) -> str:
+ """The scope decline, used when an exercise brief is stopped in code. Says
+ the same thing the policy asks the model to say, so a visitor cannot tell
+ which layer answered."""
+ who = (org_name or "").strip() or "this business"
+ return (
+ f"I can only help with questions about {who}. "
+ f"What can I help you with?"
+ )
+
+
+@dataclass(frozen=True)
+class InboundVerdict:
+ rule_ids: Tuple[str, ...] = ()
+ block: bool = False
+ matched: Tuple[str, ...] = ()
+ reply: str = BLOCK_REPLY
+
+ @property
+ def triggered(self) -> bool:
+ return bool(self.rule_ids)
+
+ def as_attributes(self) -> dict:
+ """chat_history.attributes payload — rule ids only, the column is
+ unencrypted plain JSON."""
+ if not self.triggered:
+ return {}
+ return {
+ "guardrail": {
+ "rules": list(self.rule_ids),
+ "action": "blocked" if self.block else "counted",
+ }
+ }
+
+
+def _record(
+ *,
+ surface: str,
+ layer: str,
+ rules: Tuple[str, ...],
+ action: str,
+ ctx=None,
+ session_id: Optional[str] = None,
+ text: Optional[str] = None,
+) -> None:
+ """Log line first (always), then one best-effort write for all rules.
+
+ `matched` carries the rule ids only. The matched substring would be the
+ visitor's own words, and this column is plain JSON while `excerpt` is
+ encrypted — reviewable text belongs there, not here.
+ """
+ org_id = getattr(ctx, "org_id", None)
+ agent_id = getattr(ctx, "agent_id", None)
+ for rule in rules:
+ logger.warning(
+ f"GUARDRAIL_EVENT rule={rule} layer={layer} action={action} "
+ f"surface={surface} org={org_id} agent={agent_id} "
+ f"session={session_id} len={len(text) if text else 0}"
+ )
+ if not getattr(settings, "GUARDRAIL_EVENTS_ENABLED", True):
+ return
+ excerpt = None
+ if text and getattr(settings, "GUARDRAIL_STORE_EXCERPT", True):
+ excerpt = text[:_EXCERPT_MAX]
+ # One session for the whole verdict: an attack tripping several rules used
+ # to open a connection per rule.
+ record_guardrail_events(
+ rules=rules,
+ surface=surface,
+ layer=layer,
+ action=action,
+ org_id=org_id,
+ agent_id=agent_id,
+ session_id=session_id,
+ char_len=len(text) if text else None,
+ excerpt=excerpt,
+ )
+
+
+def _business_terms(ctx) -> Tuple[str, ...]:
+ """Org name and the domain's own label — a message naming either is about
+ the business, whatever else it contains."""
+ terms = []
+ for attr in ("org_name", "domain"):
+ value = getattr(ctx, attr, None)
+ if isinstance(value, str) and value.strip():
+ terms.append(value.strip())
+ if attr == "domain" and "." in value:
+ terms.append(value.split(".", 1)[0])
+ return tuple(terms)
+
+
+def _default_scope_in_force(ctx) -> bool:
+ """True only when the agent runs the shipped scope rule unmodified."""
+ try:
+ if not getattr(ctx, "guardrail_enabled", True):
+ return False
+ custom = getattr(ctx, "guardrail_prompt", None)
+ return not (isinstance(custom, str) and custom.strip())
+ except Exception:
+ return True
+
+
+def _should_block(rule_ids: Tuple[str, ...]) -> bool:
+ mode = (getattr(settings, "GUARDRAIL_INBOUND_ACTION", "template_only") or "off").lower()
+ if mode == "strict":
+ return bool(rule_ids)
+ if mode == "template_only":
+ return RULE_FRAME_TOKENS in rule_ids
+ return False
+
+
+def check_inbound(
+ text: Optional[str],
+ ctx=None,
+ surface: str = Surface.WIDGET,
+ session_id: Optional[str] = None,
+ allow_block: bool = True,
+) -> InboundVerdict:
+ """Pre-LLM injection check on a visitor message.
+
+ `allow_block=False` forces count-only regardless of mode — used on paths
+ that must never short-circuit (the workflow LLM node owns its own control
+ flow and message storage).
+ """
+ try:
+ result = detect_injection(text or "")
+ rules = list(result.rule_ids)
+ block = allow_block and _should_block(result.rule_ids)
+ reply = BLOCK_REPLY
+
+ # Off-topic exercise briefs: prompt text could not hold these, so they
+ # are stopped here — which also avoids paying for the inference the
+ # whole change exists to prevent.
+ #
+ # Only when the tenant is running the DEFAULT scope rule. The detector
+ # encodes our default's assumptions (an algorithms brief is off-topic),
+ # which is wrong for a coding bootcamp or a maths tutor — and blocking
+ # pre-inference gives the model no chance to disagree. If they switched
+ # the rule off or wrote their own, this must not fire, or the dashboard
+ # would say one thing and the code do another.
+ if _default_scope_in_force(ctx) and detect_offtopic_exercise(
+ text or "", _business_terms(ctx)
+ ):
+ rules.append(RULE_OFFTOPIC_EXERCISE)
+ mode = (getattr(settings, "GUARDRAIL_OFFTOPIC_ACTION", "block") or "off").lower()
+ if allow_block and mode == "block":
+ block = True
+ reply = offtopic_reply(getattr(ctx, "org_name", None))
+
+ if not rules:
+ return InboundVerdict()
+ _record(
+ surface=surface,
+ layer="inbound",
+ rules=tuple(rules),
+ action="blocked" if block else "counted",
+ ctx=ctx,
+ session_id=session_id,
+ text=text,
+ )
+ return InboundVerdict(
+ rule_ids=tuple(rules), block=block, matched=result.matched, reply=reply
+ )
+ except Exception as e:
+ logger.error(f"Inbound guardrail check failed open: {e}")
+ return InboundVerdict()
+
+
+def check_output(
+ message: Optional[str],
+ ctx=None,
+ surface: str = Surface.WIDGET,
+ session_id: Optional[str] = None,
+) -> Tuple[Optional[str], List[str]]:
+ """Post-LLM output check.
+
+ Returns (possibly replaced message, rule ids recorded). A canary hit —
+ policy text leaking into the reply — replaces the message; the model's
+ own scope refusal is only counted, never altered.
+ """
+ try:
+ if not message or not getattr(settings, "GUARDRAIL_OUTPUT_CHECK_ENABLED", True):
+ return message, []
+ if any(canary in message for canary in CANARY_STRINGS):
+ _record(
+ surface=surface,
+ layer="output",
+ rules=(RULE_PROMPT_LEAK,),
+ action="replaced",
+ ctx=ctx,
+ session_id=session_id,
+ text=message,
+ )
+ return LEAK_REPLY, [RULE_PROMPT_LEAK]
+ if looks_like_scope_refusal(message):
+ _record(
+ surface=surface,
+ layer="output",
+ rules=(RULE_MODEL_REFUSED,),
+ action="counted",
+ ctx=ctx,
+ session_id=session_id,
+ text=message,
+ )
+ return message, [RULE_MODEL_REFUSED]
+ return message, []
+ except Exception as e:
+ logger.error(f"Output guardrail check failed open: {e}")
+ return message, []
diff --git a/backend/app/utils/guardrails.py b/backend/app/utils/guardrails.py
new file mode 100644
index 0000000..3791481
--- /dev/null
+++ b/backend/app/utils/guardrails.py
@@ -0,0 +1,574 @@
+"""
+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 dataclasses import dataclass
+from typing import Dict, List, Optional, Sequence, Tuple
+from enum import Enum
+from app.core.logger import get_logger
+
+logger = get_logger(__name__)
+
+
+class GuardrailType(str, Enum):
+ """Types of guardrails that can be applied"""
+ PII = "pii"
+ JAILBREAK = "jailbreak"
+
+
+class GuardrailAction(str, Enum):
+ """Actions to take when guardrail is triggered"""
+ BLOCK = "block"
+ REDACT = "redact"
+ WARNING = "warning"
+ LOG = "log"
+
+
+class GuardrailResult:
+ """Result of guardrail evaluation"""
+
+ def __init__(
+ self,
+ passed: bool,
+ guardrail_type: GuardrailType,
+ violations: List[str] = None,
+ redacted_text: Optional[str] = None,
+ confidence: float = 0.0
+ ):
+ self.passed = passed
+ self.guardrail_type = guardrail_type
+ self.violations = violations or []
+ self.redacted_text = redacted_text
+ self.confidence = confidence
+
+ def to_dict(self) -> Dict:
+ """Convert result to dictionary"""
+ return {
+ "passed": self.passed,
+ "guardrail_type": self.guardrail_type.value,
+ "violations": self.violations,
+ "redacted_text": self.redacted_text,
+ "confidence": self.confidence
+ }
+
+
+class PIIDetector:
+ """
+ Detector for Personally Identifiable Information (PII)
+ Detects common PII patterns like emails, phone numbers, SSNs, credit cards, etc.
+ """
+
+ # Regex patterns for common PII types
+ PATTERNS = {
+ "email": r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b',
+ "phone": r'\b(?:\+?1[-.]?)?\(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})\b',
+ "ssn": r'\b\d{3}-\d{2}-\d{4}\b',
+ "credit_card": r'\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b',
+ "ip_address": r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b',
+ "date_of_birth": r'\b(?:0?[1-9]|1[0-2])[/-](?:0?[1-9]|[12][0-9]|3[01])[/-](?:19|20)\d{2}\b',
+ # Add more patterns as needed
+ }
+
+ @staticmethod
+ def detect(text: str, action: GuardrailAction = GuardrailAction.BLOCK) -> GuardrailResult:
+ """
+ Detect PII in text
+
+ Args:
+ text: Text to scan for PII
+ action: Action to take (BLOCK, REDACT, WARNING, LOG)
+
+ Returns:
+ GuardrailResult with detection results
+ """
+ violations = []
+ redacted_text = text
+
+ for pii_type, pattern in PIIDetector.PATTERNS.items():
+ matches = re.finditer(pattern, text)
+ for match in matches:
+ violations.append(f"{pii_type} at position {match.start()} (length {len(match.group())})")
+
+ # Redact if action is REDACT
+ if action == GuardrailAction.REDACT:
+ redacted_text = redacted_text.replace(
+ match.group(),
+ f"[{pii_type.upper()}_REDACTED]"
+ )
+
+ passed = len(violations) == 0
+
+ # Calculate confidence based on pattern matching (simple heuristic)
+ confidence = 1.0 if violations else 0.0
+
+ logger.info(f"PII Detection - Passed: {passed}, Violations: {len(violations)}")
+
+ return GuardrailResult(
+ passed=passed,
+ guardrail_type=GuardrailType.PII,
+ violations=violations,
+ redacted_text=redacted_text if action == GuardrailAction.REDACT else None,
+ confidence=confidence
+ )
+
+
+# --- Injection pattern sets -------------------------------------------------
+#
+# Two tiers. A strong (Tier A) pattern is high-precision evidence of a prompt
+# injection and scores 1.0 on its own; weak (Tier B) signals are phrasings that
+# only matter in combination and score 0.25 each. When the message contains
+# technical content (code, logs, shell commands) the weak subtotal is halved —
+# support chats are full of pasted snippets that echo these phrasings.
+#
+# Deliberately NOT detected — each is normal in a support conversation, and
+# removing them is a contract backed one-for-one by tests
+# (tests/utils/test_guardrails_jailbreak.py):
+# "sudo" self-hosting / CLI help
+# "debug mode", "developer mode" real product features (bare mention)
+# "theoretically" ordinary hedging
+# bare "DAN" the first name Dan (the old pattern matched
+# it because text was lowercased first)
+# "ignore/disregard my previous message|ticket|email"
+# an override needs an instruction NOUN
+# bare "what are your instructions"
+# "...for returning an item?" is a product
+# question; disclosure is the prompt policy's job
+
+# Literal chat-template tokens. A human visitor never types these; they only
+# appear when someone is smuggling a fake system/assistant turn.
+_TEMPLATE_MARKER_PATTERNS = [
+ re.compile(r'<\|im_(?:start|end)\|>'),
+ re.compile(r'<\|eot_id\|>'),
+ re.compile(r'<\|(?:system|user|assistant|endoftext)\|>'),
+ re.compile(r'\[/?INST\]'),
+ re.compile(r'<>'),
+ re.compile(r'\bBEGIN SYSTEM PROMPT\b', re.IGNORECASE),
+ re.compile(r'(?m)^\s*\[SYSTEM\]\s*:?\s*$', re.IGNORECASE),
+]
+
+# "ignore the previous..." only counts with an instruction noun in range:
+# "ignore my previous message" is normal traffic, "ignore your previous
+# instructions" is not.
+_OVERRIDE_PATTERNS = [
+ re.compile(
+ r'\b(?:ignore|disregard|forget|override|bypass)\b[^.\n]{0,40}?'
+ r'\b(?:previous|prior|above|earlier|initial|original|system|all)\b[^.\n]{0,20}?'
+ r'\b(?:instructions?|rules?|prompts?|directives?|guidelines?)\b',
+ re.IGNORECASE,
+ ),
+ re.compile(
+ r'\byour\s+(?:new|real|actual|true|updated)\s+'
+ r'(?:instructions?|rules?|system\s+prompt)\s+(?:are|is)\b',
+ re.IGNORECASE,
+ ),
+ re.compile(
+ r'\bthis\s+(?:overrides|supersedes|replaces|cancels)\s+'
+ r'(?:all|any|everything|your|the)\b',
+ re.IGNORECASE,
+ ),
+]
+
+_EXFIL_PATTERNS = [
+ re.compile(
+ r'\b(?:reveal|repeat|print|output|show|display|dump|recite|reproduce)\b'
+ r'[^.\n]{0,40}?'
+ r'\b(?:system\s+(?:prompt|message)|initial\s+(?:prompt|instructions)'
+ r'|original\s+instructions|instructions\s+verbatim|prompt\s+verbatim'
+ r'|everything\s+above)\b',
+ re.IGNORECASE,
+ ),
+]
+
+_PERSONA_PATTERNS = [
+ re.compile(
+ r'\byou\s+are\s+now\s+(?:an?\s+)?'
+ r'(?:dan\b|unrestricted|unfiltered|jailbroken|uncensored'
+ r'|different\s+(?:assistant|ai|bot|model|persona))',
+ re.IGNORECASE,
+ ),
+ re.compile(r'\bdo\s+anything\s+now\b', re.IGNORECASE),
+ # Case-sensitive on purpose: "DAN mode" is a jailbreak, "dan mode..." in
+ # lowercase prose is far more likely to be about a person called Dan.
+ re.compile(r'\bDAN\s+(?:mode|prompt|jailbreak)\b'),
+ re.compile(
+ r'\b(?:developer|dev|god|admin(?:istrator)?|unrestricted|jailbreak)\s+'
+ r'mode\s+(?:on|enabled|activated|engaged)\b',
+ re.IGNORECASE,
+ ),
+ re.compile(
+ r'\bpretend\s+(?:you\s+are|to\s+be)\s+(?:an?\s+)?'
+ r'(?:unrestricted|uncensored|jailbroken|evil)',
+ re.IGNORECASE,
+ ),
+]
+
+# Stable rule ids, stored in guardrail_events.rule and referenced by the
+# runtime layer — named so the two cannot drift apart.
+RULE_FRAME_TOKENS = "injection.frame_tokens"
+RULE_OVERRIDE_INSTRUCTIONS = "injection.override_instructions"
+RULE_PROMPT_EXFIL = "injection.prompt_exfil"
+RULE_ROLE_HIJACK = "injection.role_hijack"
+
+_STRONG_PATTERNS = {
+ RULE_FRAME_TOKENS: _TEMPLATE_MARKER_PATTERNS,
+ RULE_OVERRIDE_INSTRUCTIONS: _OVERRIDE_PATTERNS,
+ RULE_PROMPT_EXFIL: _EXFIL_PATTERNS,
+ RULE_ROLE_HIJACK: _PERSONA_PATTERNS,
+}
+
+_WEAK_SIGNAL_PATTERNS = [
+ re.compile(r'\bact\s+(?:as|like)\s+an?\b', re.IGNORECASE),
+ re.compile(r'\bfrom\s+now\s+on\b', re.IGNORECASE),
+ re.compile(r'\brole[- ]?play\b', re.IGNORECASE),
+ re.compile(r'\bpretend\s+(?:you\s+are|to\s+be)\b', re.IGNORECASE),
+ re.compile(r'\bhypothetically\b', re.IGNORECASE),
+ re.compile(r'\bfor\s+educational\s+purposes\b', re.IGNORECASE),
+ re.compile(
+ r'\bin\s+an?\s+(?:fictional|hypothetical|alternate)\s+'
+ r'(?:scenario|world|universe)\b',
+ re.IGNORECASE,
+ ),
+ re.compile(
+ r'\bno\s+(?:restrictions?|limitations?|rules|boundaries|filters?)\b',
+ re.IGNORECASE,
+ ),
+ re.compile(
+ r'\bwithout\s+(?:any\s+)?(?:restrictions?|limits?|limitations?|filters?)\b',
+ re.IGNORECASE,
+ ),
+ re.compile(
+ r'\byou\s+(?:must|should|have\s+to)\s+(?:obey|follow|comply)\b',
+ re.IGNORECASE,
+ ),
+ re.compile(r'\bunrestricted\s+mode\b', re.IGNORECASE),
+ re.compile(
+ r'\bwhat\s+(?:is|are)\s+your\s+(?:instructions|rules|system\s+prompt)\b',
+ re.IGNORECASE,
+ ),
+ re.compile(r'\btotally\s+different\s+persona\b', re.IGNORECASE),
+]
+
+# Markers of pasted technical content (code, logs, shell). Their presence
+# halves the weak-signal score — see the suppressor note above.
+_TECHNICAL_CONTEXT_PATTERNS = [
+ re.compile(r'```'),
+ re.compile(r'Traceback \(most recent call last\)'),
+ re.compile(r'File "[^"]+", line \d+'),
+ re.compile(r'(?m)^\s*(?:\$ |sudo |npm |pip |docker |curl |git )'),
+ re.compile(r'\{"'),
+ re.compile(r'\b(?:GET|POST|PUT|PATCH|DELETE)\s+/\S+'),
+ re.compile(r'\bat\s+\w+\('),
+ re.compile(r'\S+\.(?:log|ya?ml|env|conf)\b'),
+]
+
+_WEAK_SIGNAL_WEIGHT = 0.25
+_TECHNICAL_SUPPRESSION = 0.5
+
+
+# --- Off-topic exercise briefs ----------------------------------------------
+#
+# Prompt text alone does not hold here. A long, well-structured algorithms or
+# system-design brief was answered in full in production despite the policy
+# forbidding it, three wordings running — the model reads the length and polish
+# as "technical depth" and helps. This detector catches only that narrow shape,
+# deterministically, before inference.
+#
+# Precision comes from the THIRD condition: a real customer question about the
+# product almost always names something about the business or its failure mode.
+# An exercise brief names none of it. All three must hold, so a pasted traceback
+# ("error", "exception"), a long integration question ("webhook", "api key") or
+# anything naming the org is never caught.
+RULE_OFFTOPIC_EXERCISE = "offtopic.exercise_brief"
+
+_EXERCISE_MIN_CHARS = 500
+_EXERCISE_MIN_MARKERS = 4
+
+_EXERCISE_MARKERS = [
+ re.compile(r'\bO\(\s*(?:1|n|log\s*n|n\s*log\s*n|n\^?2)\s*\)', re.IGNORECASE),
+ re.compile(r'\b(?:time|space)\s+complexity\b', re.IGNORECASE),
+ re.compile(r'\bdata\s+structure\b', re.IGNORECASE),
+ re.compile(r'\balgorithm\b', re.IGNORECASE),
+ re.compile(r'(?m)^\s*constraints\s*:', re.IGNORECASE),
+ re.compile(r'\byour\s+task\s*:', re.IGNORECASE),
+ re.compile(r'\bdesign\s+(?:a|an)\b[^.\n]{0,60}\bthat\s+supports\b', re.IGNORECASE),
+ re.compile(r'\bimplement\s+the\b', re.IGNORECASE),
+ re.compile(r'(?m)^\s*bonus\s*:', re.IGNORECASE),
+ re.compile(r'\bedge\s+cases?\b', re.IGNORECASE),
+ re.compile(r'\bgiven\s+(?:an?\s+)?(?:array|string|list|integer|tree|graph)\b', re.IGNORECASE),
+ re.compile(r'\bduplicates?\s+(?:are\s+)?allowed\b', re.IGNORECASE),
+ re.compile(r'\bworst[- ]case\b', re.IGNORECASE),
+]
+
+# Any one of these means the message is anchored to a real business context.
+_BUSINESS_ANCHORS = [
+ re.compile(r'\b(?:pricing|price|plan|billing|invoice|subscription|refund|trial|upgrade|quote)\b', re.IGNORECASE),
+ re.compile(r'\b(?:account|login|log in|sign ?in|sign ?up|password|onboard)\b', re.IGNORECASE),
+ re.compile(r'\b(?:install|setup|set up|deploy|self[- ]host|docker|compose|container)\b', re.IGNORECASE),
+ re.compile(r'\b(?:widget|dashboard|webhook|api[- ]key|integrat|plugin|embed|sdk)\w*', re.IGNORECASE),
+ re.compile(r'\b(?:error|exception|traceback|stack trace|bug|crash|fail|broken|not working)\w*', re.IGNORECASE),
+ re.compile(r'\b(?:support|ticket|agent|chatbot|knowledge base|faq|inbox|conversation)\b', re.IGNORECASE),
+ re.compile(r'\b(?:your (?:product|service|platform|tool|app)|you offer|do you support)\b', re.IGNORECASE),
+]
+
+
+def detect_offtopic_exercise(text: str, business_terms: Sequence[str] = ()) -> bool:
+ """True for a long, self-contained exercise brief with no business context.
+
+ Deliberately narrow: it exists to catch the generic-LLM-task abuse that the
+ prompt policy cannot hold, not to classify topics in general.
+ """
+ body = text or ""
+ if len(body) < _EXERCISE_MIN_CHARS:
+ return False
+ markers = sum(1 for p in _EXERCISE_MARKERS if p.search(body))
+ if markers < _EXERCISE_MIN_MARKERS:
+ return False
+ if any(p.search(body) for p in _BUSINESS_ANCHORS):
+ return False
+ lowered = body.lower()
+ return not any(term and term.lower() in lowered for term in business_terms)
+
+
+@dataclass(frozen=True)
+class InjectionResult:
+ """Strong-signal (Tier A) injection evidence for the global guardrail layer."""
+
+ rule_ids: Tuple[str, ...]
+ matched: Tuple[str, ...] # short matched tokens only — safe for plain-JSON storage
+
+ @property
+ def triggered(self) -> bool:
+ return bool(self.rule_ids)
+
+ @property
+ def has_template_marker(self) -> bool:
+ return RULE_FRAME_TOKENS in self.rule_ids
+
+
+def detect_injection(text: str) -> InjectionResult:
+ """Scan text for strong injection signals only (no weak/contextual scoring).
+
+ This is the pre-LLM entry point used by the global chat guardrail: weak
+ signals are advisory and never block outside an explicitly configured
+ workflow guardrails node, so they are not reported here.
+ """
+ rule_ids: List[str] = []
+ matched: List[str] = []
+ for rule_id, patterns in _STRONG_PATTERNS.items():
+ for pattern in patterns:
+ match = pattern.search(text or "")
+ if match:
+ if rule_id not in rule_ids:
+ rule_ids.append(rule_id)
+ matched.append(match.group()[:60])
+ break # one hit per rule id is enough
+ return InjectionResult(rule_ids=tuple(rule_ids), matched=tuple(matched))
+
+
+class JailbreakDetector:
+ """
+ Detector for jailbreak / prompt-injection attempts.
+
+ Scoring counts DISTINCT patterns (repeating one phrase scores once):
+ every strong hit scores 1.0, weak signals 0.25 each — halved when the
+ message contains technical content. `passed = score < sensitivity`, so at
+ the default 0.7 a single strong hit or three clean weak signals fail.
+ """
+
+ @staticmethod
+ def detect(text: str, sensitivity: float = 0.7) -> GuardrailResult:
+ """
+ Detect jailbreak attempts in text
+
+ Args:
+ text: Text to scan for jailbreak attempts
+ sensitivity: Detection sensitivity (0.0 to 1.0, default 0.7)
+
+ Returns:
+ GuardrailResult with detection results
+ """
+ violations = []
+
+ strong = detect_injection(text)
+ for rule_id, token in zip(strong.rule_ids, strong.matched):
+ violations.append(f"Jailbreak pattern [{rule_id}]: '{token}'")
+
+ weak_hits = 0
+ for pattern in _WEAK_SIGNAL_PATTERNS:
+ match = pattern.search(text or "")
+ if match:
+ weak_hits += 1
+ violations.append(f"Suspicious phrase: '{match.group()[:60]}'")
+
+ suppression = 1.0
+ if weak_hits and any(p.search(text or "") for p in _TECHNICAL_CONTEXT_PATTERNS):
+ suppression = _TECHNICAL_SUPPRESSION
+
+ confidence = min(
+ 1.0,
+ len(strong.rule_ids) + weak_hits * _WEAK_SIGNAL_WEIGHT * suppression,
+ )
+ passed = confidence < sensitivity
+
+ logger.info(
+ f"Jailbreak Detection - Passed: {passed}, "
+ f"Confidence: {confidence:.2f}, "
+ f"Strong Rules: {list(strong.rule_ids)}, "
+ f"Weak Signals: {weak_hits}"
+ )
+
+ return GuardrailResult(
+ passed=passed,
+ guardrail_type=GuardrailType.JAILBREAK,
+ violations=violations,
+ redacted_text=None,
+ confidence=confidence
+ )
+
+
+class GuardrailsEngine:
+ """
+ Main guardrails engine that orchestrates multiple guardrail checks
+ """
+
+ def __init__(self):
+ self.pii_detector = PIIDetector()
+ self.jailbreak_detector = JailbreakDetector()
+
+ def evaluate(
+ self,
+ text: str,
+ enabled_guardrails: List[GuardrailType],
+ actions: Dict[GuardrailType, GuardrailAction] = None,
+ jailbreak_sensitivity: float = 0.7
+ ) -> Tuple[bool, List[GuardrailResult]]:
+ """
+ Evaluate text against enabled guardrails
+
+ Args:
+ text: Text to evaluate
+ enabled_guardrails: List of guardrail types to check
+ actions: Dictionary mapping guardrail types to actions
+ jailbreak_sensitivity: Sensitivity for jailbreak detection (0.0-1.0)
+
+ Returns:
+ Tuple of (all_passed, list_of_results)
+ """
+ if actions is None:
+ actions = {}
+
+ results = []
+ all_passed = True
+
+ for guardrail_type in enabled_guardrails:
+ action = actions.get(guardrail_type, GuardrailAction.BLOCK)
+
+ if guardrail_type == GuardrailType.PII:
+ result = self.pii_detector.detect(text, action)
+ results.append(result)
+ if not result.passed:
+ all_passed = False
+
+ elif guardrail_type == GuardrailType.JAILBREAK:
+ result = self.jailbreak_detector.detect(text, jailbreak_sensitivity)
+ results.append(result)
+ if not result.passed:
+ all_passed = False
+
+ return all_passed, results
+
+ def get_default_block_message(self, results: List[GuardrailResult]) -> str:
+ """
+ Generate a user-friendly message when content is blocked
+
+ Args:
+ results: List of guardrail results
+
+ Returns:
+ Message to display to user
+ """
+ failed_guardrails = [r for r in results if not r.passed]
+
+ if not failed_guardrails:
+ return "Your message has been processed successfully."
+
+
+ if GuardrailType.PII in [r.guardrail_type for r in failed_guardrails]:
+ return (
+ "I noticed your message contains sensitive personal information. "
+ "For your privacy and security, please avoid sharing personal details "
+ "like email addresses, phone numbers, or other identifying information."
+ )
+
+ if GuardrailType.JAILBREAK in [r.guardrail_type for r in failed_guardrails]:
+ return (
+ "I'm designed to help you with legitimate questions and tasks. "
+ "I can't process requests that ask me to ignore my guidelines or "
+ "behave in ways I'm not designed for. How else can I assist you?"
+ )
+
+ return (
+ "I'm unable to process your message as it doesn't meet our "
+ "content guidelines. Please rephrase your request."
+ )
+
+
+# Convenience function for quick guardrail checks
+def check_guardrails(
+ text: str,
+ guardrail_types: List[str],
+ pii_action: str = "block",
+ jailbreak_sensitivity: float = 0.7
+) -> Tuple[bool, List[Dict], str]:
+ """
+ Convenience function to check text against guardrails
+
+ Args:
+ text: Text to check
+ guardrail_types: List of guardrail type strings ("pii", "jailbreak")
+ pii_action: Action for PII ("block", "redact", "warning", "log")
+ jailbreak_sensitivity: Sensitivity for jailbreak detection
+
+ Returns:
+ Tuple of (passed, results_list, block_message)
+ """
+ engine = GuardrailsEngine()
+
+ # Convert string types to enums
+ enabled_guardrails = []
+ actions = {}
+
+ for gt in guardrail_types:
+ try:
+ guardrail_type = GuardrailType(gt.lower())
+ enabled_guardrails.append(guardrail_type)
+
+ if guardrail_type == GuardrailType.PII:
+ actions[guardrail_type] = GuardrailAction(pii_action.lower())
+ except ValueError:
+ logger.warning(f"Unknown guardrail type: {gt}")
+
+ passed, results = engine.evaluate(
+ text,
+ enabled_guardrails,
+ actions,
+ jailbreak_sensitivity
+ )
+
+ block_message = engine.get_default_block_message(results)
+ results_dict = [r.to_dict() for r in results]
+
+ return passed, results_dict, block_message
diff --git a/backend/app/utils/model_context.py b/backend/app/utils/model_context.py
new file mode 100644
index 0000000..618f4eb
--- /dev/null
+++ b/backend/app/utils/model_context.py
@@ -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.
+
+Curated context-window lookup for the org-configurable chat models, used to
+size FAQ-generation batches to the selected model instead of a fixed constant.
+Deliberately a small hand-maintained table (kept conservative) rather than a
+dependency on litellm's model map; unknown models fall back to a per-provider
+floor, and FAQ_CONTEXT_TOKENS_OVERRIDE forces a value for anything exotic.
+"""
+
+from app.core.config import settings
+
+# Conservative floors when the model name is unrecognised. Keys match
+# AIModelType values, uppercased.
+_PROVIDER_DEFAULT_TOKENS = {
+ "OPENAI": 16_000,
+ "CHATTERMATE": 128_000,
+ "ANTHROPIC": 200_000,
+ "DEEPSEEK": 64_000,
+ "GOOGLE": 32_000,
+ "GOOGLEVERTEX": 32_000,
+ "GROQ": 8_000,
+ "MISTRAL": 32_000,
+ "HUGGINGFACE": 8_000,
+ "OLLAMA": 8_000,
+ "XAI": 128_000,
+}
+_FALLBACK_TOKENS = 8_000
+
+# Lowercase model-name prefixes; the longest matching prefix wins so
+# "gpt-4o" beats "gpt-4" and "mistral-large" beats "mistral".
+_MODEL_PREFIX_TOKENS = {
+ "gpt-5": 400_000,
+ "gpt-4.1": 1_000_000,
+ "gpt-4o": 128_000,
+ "gpt-4-turbo": 128_000,
+ "gpt-4": 8_000,
+ "gpt-3.5": 16_000,
+ "o1": 200_000,
+ "o3": 200_000,
+ "o4": 200_000,
+ "claude": 200_000,
+ "gemini": 1_000_000,
+ "deepseek": 64_000,
+ "llama-3.1": 128_000,
+ "llama-3.2": 128_000,
+ "llama-3.3": 128_000,
+ "llama-4": 128_000,
+ "mixtral": 32_000,
+ "mistral-large": 128_000,
+ "mistral": 32_000,
+ "grok": 128_000,
+ "qwen": 32_000,
+ "kimi": 128_000,
+}
+
+# Batch-size math. CHARS_PER_TOKEN is conservative for English prose (~4);
+# SAFETY absorbs tokenizer variance and the packed-batch separators.
+_PROMPT_OVERHEAD_TOKENS = 2_500 # instructions + existing-questions block
+_OUTPUT_TOKENS_PER_FAQ = 120
+_OUTPUT_TOKENS_BASE = 500
+_CHARS_PER_TOKEN = 3.5
+_SAFETY = 0.8
+_MIN_FAQS_PER_BATCH = 15
+_MAX_FAQS_PER_BATCH = 40
+_CHARS_PER_FAQ = 3_000 # scale FAQ yield with batch size so density stays flat
+
+
+def context_tokens_for(model_type: str, model_name: str) -> int:
+ """Best-known context window (tokens) for the configured model."""
+ if settings.FAQ_CONTEXT_TOKENS_OVERRIDE > 0:
+ return settings.FAQ_CONTEXT_TOKENS_OVERRIDE
+ name = (model_name or "").casefold()
+ best = None
+ for prefix, tokens in _MODEL_PREFIX_TOKENS.items():
+ if name.startswith(prefix) and (best is None or len(prefix) > best[0]):
+ best = (len(prefix), tokens)
+ if best:
+ return best[1]
+ return _PROVIDER_DEFAULT_TOKENS.get((model_type or "").upper(), _FALLBACK_TOKENS)
+
+
+def output_tokens_for(max_faqs: int) -> int:
+ """Output reserve for a structured batch of up to max_faqs FAQs."""
+ return max_faqs * _OUTPUT_TOKENS_PER_FAQ + _OUTPUT_TOKENS_BASE
+
+
+def faq_batch_chars_for(model_type: str, model_name: str) -> tuple[int, int]:
+ """(batch_chars, max_faqs) for FAQ extraction with this model.
+
+ batch_chars is floored at FAQ_MAX_BATCH_CHARS (today's behaviour for
+ small-context models) and ceilinged at FAQ_MAX_BATCH_CHARS_CEILING — a
+ quality guard, not a token limit: grounded extraction degrades on very
+ long contexts. max_faqs scales with the batch so bigger batches don't
+ reduce total FAQ yield.
+ """
+ context = context_tokens_for(model_type, model_name)
+ usable = context - _PROMPT_OVERHEAD_TOKENS - output_tokens_for(_MAX_FAQS_PER_BATCH)
+ raw_chars = int(usable * _CHARS_PER_TOKEN * _SAFETY)
+ batch_chars = max(settings.FAQ_MAX_BATCH_CHARS, min(raw_chars, settings.FAQ_MAX_BATCH_CHARS_CEILING))
+ max_faqs = max(_MIN_FAQS_PER_BATCH, min(batch_chars // _CHARS_PER_FAQ, _MAX_FAQS_PER_BATCH))
+ return batch_chars, max_faqs
diff --git a/backend/app/utils/phone.py b/backend/app/utils/phone.py
new file mode 100644
index 0000000..9a92396
--- /dev/null
+++ b/backend/app/utils/phone.py
@@ -0,0 +1,73 @@
+"""
+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
+
+# E.164: '+', a non-zero country code digit, then 7-14 more digits. Everything
+# that identifies a person by phone stores exactly this shape, because
+# customers.phone is a unique identity key — two spellings of one number would
+# be two "people". re.ASCII because \d otherwise matches every Unicode decimal
+# digit (Arabic-Indic and friends), which would let an undialable, unmatchable
+# string become an identity key. Deliberately not libphonenumber:
+# country-specific length rules aren't worth a dependency when Meta/the SMS
+# provider is the real validator and rejects undialable numbers anyway.
+_E164 = re.compile(r"^\+[1-9]\d{7,14}$", re.ASCII)
+
+# What people paste: spaces, dashes, dots, parentheses.
+_DECORATION = re.compile(r"[\s\-().]")
+
+
+def normalize_phone(value: str | None) -> str | None:
+ """The canonical E.164 form of a human-supplied phone number, or None.
+
+ Strict: the '+' country prefix is required (decoration — spaces, dashes,
+ dots, parentheses — is tolerated). Bare digits are rejected here because a
+ person typing "1234567890" means a national number, and prepending '+'
+ would silently reassign it to whatever country owns that prefix. An
+ identity key corrupted by a guess is worse than a rejected form field.
+
+ Non-strings return None rather than raising: values arrive from AI
+ extraction and form payloads, which can hand over lists or numbers.
+ """
+ if not value or not isinstance(value, str):
+ return None
+ candidate = _DECORATION.sub("", value.strip())
+ return candidate if _E164.fullmatch(candidate) else None
+
+
+def normalize_msisdn(value: str | None) -> str | None:
+ """The canonical E.164 form of a platform-supplied subscriber number.
+
+ Lenient about the prefix in the two shapes platforms actually send:
+ bare digits (a WhatsApp wa_id, a Vonage msisdn — E.164 without the '+')
+ and the ITU '00' international form some SMS gateways use. Both are
+ trustworthy in a way human-typed digits are not. Delegates to
+ normalize_phone so there is exactly one definition of the canonical
+ shape — the two paths must meet in one column.
+ """
+ if not value or not isinstance(value, str):
+ return None
+ candidate = _DECORATION.sub("", value.strip())
+ if candidate.startswith("00") and candidate[2:].isascii() and candidate[2:].isdigit():
+ candidate = f"+{candidate[2:]}"
+ elif not candidate.startswith("+") and candidate.isascii() and candidate.isdigit():
+ candidate = f"+{candidate}"
+ return normalize_phone(candidate)
+
+
+def to_wa_id(phone: str) -> str:
+ """Meta addresses WhatsApp users by E.164 without the '+'."""
+ return phone.lstrip("+")
diff --git a/backend/app/utils/rate_limit.py b/backend/app/utils/rate_limit.py
new file mode 100644
index 0000000..2fbc7d1
--- /dev/null
+++ b/backend/app/utils/rate_limit.py
@@ -0,0 +1,174 @@
+"""
+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 functools
+from fastapi import HTTPException, Request
+from app.core.config import settings
+from app.core.logger import get_logger
+from app.core.redis import get_redis
+from typing import Optional, Callable
+
+logger = get_logger(__name__)
+
+class RateLimitConfig:
+ """Configuration class for rate limiting settings"""
+
+ # Rate limits for instruction generation
+ INSTRUCTION_GEN_LIMIT = 10 # requests
+ INSTRUCTION_GEN_WINDOW = 86400 # 24 hours in seconds
+ INSTRUCTION_GEN_PREFIX = "gen_instruct"
+
+ # Add more rate limit configurations here as needed
+ # Example:
+ # CHAT_MESSAGE_LIMIT = 100
+ # CHAT_MESSAGE_WINDOW = 3600 # 1 hour
+ # CHAT_MESSAGE_PREFIX = "chat_msg"
+
+def format_time_remaining(seconds: int) -> str:
+ """Format remaining time into a human-readable string"""
+ hours = seconds // 3600
+ minutes = (seconds % 3600) // 60
+ seconds = seconds % 60
+
+ if hours > 0:
+ time_msg = f"{hours} hours"
+ if minutes > 0:
+ time_msg += f" and {minutes} minutes"
+ elif minutes > 0:
+ time_msg = f"{minutes} minutes"
+ if seconds > 0:
+ time_msg += f" and {seconds} seconds"
+ else:
+ time_msg = f"{seconds} seconds"
+
+ return time_msg
+
+def check_rate_limit(
+ redis_client: Optional[object],
+ key: str,
+ limit: int,
+ window: int
+) -> None:
+ """
+ Check rate limit for a given key
+
+ Args:
+ redis_client: Redis client instance
+ key: Redis key for rate limiting
+ limit: Maximum number of requests allowed
+ window: Time window in seconds
+
+ Raises:
+ HTTPException: If rate limit is exceeded
+ """
+ if not redis_client:
+ return
+
+ # Get current count
+ current = redis_client.get(key)
+
+ if current is None:
+ # First request, set initial count
+ redis_client.setex(key, window, 1)
+ else:
+ current = int(current)
+ if current >= limit:
+ # Calculate time until reset
+ ttl = redis_client.ttl(key)
+ time_msg = format_time_remaining(ttl)
+
+ logger.warning(f"Rate limit exceeded for key: {key}")
+ raise HTTPException(
+ status_code=429,
+ detail=f"Rate limit exceeded. You can try again in {time_msg}."
+ )
+
+ # Increment counter
+ redis_client.incr(key)
+
+def rate_limit(limit: int, window: int, key_prefix: str):
+ """
+ Rate limiting decorator that uses Redis to track request counts.
+
+ Args:
+ limit (int): Maximum number of requests allowed within the window
+ window (int): Time window in seconds
+ key_prefix (str): Prefix for the Redis key (e.g., 'gen_instruct', 'api_call')
+
+ Returns:
+ function: Decorated function with rate limiting
+ """
+ def decorator(func: Callable):
+ @functools.wraps(func)
+ async def wrapper(*args, **kwargs):
+ redis_client = get_redis()
+ if not settings.REDIS_ENABLED or not redis_client:
+ return await func(*args, **kwargs)
+
+ # Get request object from kwargs
+ request = None
+ for arg in args:
+ if isinstance(arg, Request):
+ request = arg
+ break
+ if not request and 'request' in kwargs:
+ request = kwargs['request']
+
+ # Get client IP
+ client_ip = request.client.host if request and request.client else "unknown"
+
+ # Get organization ID from kwargs
+ org_id = kwargs.get('current_user', {}).organization_id
+ if not org_id:
+ logger.warning("No organization ID found for rate limiting")
+ return await func(*args, **kwargs)
+
+ # Create a key specific to this organization and IP
+ rate_key = f"{key_prefix}:{org_id}:{client_ip}"
+
+ # Check rate limit
+ check_rate_limit(redis_client, rate_key, limit, window)
+
+ return await func(*args, **kwargs)
+ return wrapper
+ return decorator
+
+def limit_instruction_generation(func: Callable):
+ """
+ Specific rate limiting decorator for instruction generation.
+ Limits to 10 requests per day per organization/IP.
+
+ Args:
+ func: The function to be decorated
+
+ Returns:
+ function: Decorated function with rate limiting
+ """
+ return rate_limit(
+ limit=RateLimitConfig.INSTRUCTION_GEN_LIMIT,
+ window=RateLimitConfig.INSTRUCTION_GEN_WINDOW,
+ key_prefix=RateLimitConfig.INSTRUCTION_GEN_PREFIX
+ )(func)
+
+# Add more specific rate limiting decorators here as needed
+# Example:
+# def limit_chat_messages(func: Callable):
+# """Rate limiting decorator for chat messages"""
+# return rate_limit(
+# limit=RateLimitConfig.CHAT_MESSAGE_LIMIT,
+# window=RateLimitConfig.CHAT_MESSAGE_WINDOW,
+# key_prefix=RateLimitConfig.CHAT_MESSAGE_PREFIX
+# )(func)
\ No newline at end of file
diff --git a/backend/app/utils/response_parser.py b/backend/app/utils/response_parser.py
new file mode 100644
index 0000000..da950c4
--- /dev/null
+++ b/backend/app/utils/response_parser.py
@@ -0,0 +1,337 @@
+"""
+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 re
+from typing import Any, Dict, Optional, Type, Union
+from app.core.logger import get_logger
+from app.models.schemas.chat import ChatResponse
+
+logger = get_logger(__name__)
+
+# What the customer sees when a turn fails, instead of anything half-made.
+GENERIC_ERROR_MESSAGE = "I apologize, but I encountered an error, please try again later."
+
+# The reply's "message" string, only when it arrived whole (closing quote and all).
+_MESSAGE_FIELD = re.compile(r'"message"\s*:\s*"((?:[^"\\]|\\.)*)"', re.DOTALL)
+
+def parse_response_content(response: Any, model: Type[ChatResponse] = ChatResponse) -> ChatResponse:
+ """
+ Parse the response content from the LLM into a ChatResponse object.
+ Handles various response formats including dictionaries and strings.
+
+ Args:
+ response: The response from the LLM, which can be in various formats
+
+ Returns:
+ ChatResponse: A properly formatted ChatResponse object
+ """
+ response_content = None
+
+ try:
+ # Handle response with content attribute (common in Agno responses)
+ if hasattr(response, 'content'):
+ # If content is a dictionary, directly convert to ChatResponse
+ if isinstance(response.content, dict):
+ response_content = model(**response.content)
+ # If content is a string, attempt to extract and parse JSON
+ elif isinstance(response.content, str):
+ try:
+ logger.info(f"Response content is string: {response.content}")
+ response_content = extract_json_from_text(response.content, model)
+ except Exception as e:
+ logger.error(f"Failed to parse response content: {e}")
+ response_content = create_basic_chat_response(response.content)
+ # Use content directly if not dict or string
+ else:
+ response_content = response.content
+ # Handle direct response (not wrapped in content attribute)
+ else:
+ # If response is a dictionary, directly convert to ChatResponse
+ if isinstance(response, dict):
+ response_content = model(**response)
+ # If response is a string, attempt to extract and parse JSON
+ elif isinstance(response, str):
+ try:
+ response_content = extract_json_from_text(response, model)
+ except Exception as e:
+ logger.error(f"Failed to parse response: {e}")
+ response_content = create_basic_chat_response(response)
+ # Use response directly if not dict or string
+ else:
+ response_content = response
+ except Exception as e:
+ logger.error(f"Error in parse_response_content: {e}")
+ return create_basic_chat_response(str(response) if response else "Error processing response")
+
+ # If we still don't have a proper response_content, create a basic one
+ if not isinstance(response_content, ChatResponse):
+ text_content = str(response_content) if response_content else "No response generated"
+ response_content = create_basic_chat_response(text_content)
+
+ return response_content
+
+def extract_json_from_text(text: str, model: Type[ChatResponse] = ChatResponse) -> ChatResponse:
+ """
+ Extract JSON from a string and convert it to a ChatResponse object.
+
+ Args:
+ text: The text containing JSON
+
+ Returns:
+ ChatResponse: A properly formatted ChatResponse object
+ """
+ # Clean up the text by removing true/false values outside JSON structure
+ cleaned_text = clean_malformed_output(text)
+
+ # Try to extract JSON in multiple ways
+ content_dict = None
+
+ # Method 1: Handle simple JSON format first
+ if cleaned_text.startswith('{') and '}' in cleaned_text:
+ try:
+ # Try to find a valid JSON substring
+ end_idx = cleaned_text.find('}') + 1
+ json_str = cleaned_text[:end_idx]
+ content_dict = json.loads(json_str)
+ logger.debug(f"Extracted complete JSON: {content_dict}")
+ except json.JSONDecodeError:
+ # If fails, try to handle trailing characters
+ try:
+ # Find all closing braces
+ all_brace_positions = [pos for pos, char in enumerate(cleaned_text) if char == '}']
+
+ # Try each closing brace position until we find a valid JSON
+ for end_pos in all_brace_positions:
+ try:
+ json_str = cleaned_text[:end_pos + 1]
+ content_dict = json.loads(json_str)
+ logger.debug(f"Extracted JSON with truncation at position {end_pos}: {content_dict}")
+ break
+ except json.JSONDecodeError:
+ continue
+ except Exception as e:
+ logger.debug(f"Failed to extract JSON with truncation: {e}")
+
+ # Method 2: Handle function call format (e.g., function=search_knowledge_base>{"query": "the dal"} )
+ if not content_dict:
+ function_pattern = r'function=(\w+)>({.*?})\s*'
+ function_match = re.search(function_pattern, cleaned_text, re.DOTALL)
+ if function_match:
+ try:
+ function_name = function_match.group(1)
+ function_params = json.loads(function_match.group(2))
+ logger.debug(f"Found function call: {function_name} with params: {function_params}")
+ # If this is a knowledge base search, we'll create a basic response
+ if function_name == 'search_knowledge_base':
+ # The rest of the response after the function call should be the actual content
+ rest_of_text = cleaned_text.split('', 1)
+ if len(rest_of_text) > 1 and rest_of_text[1].strip():
+ content = rest_of_text[1].strip()
+ try:
+ # Try to parse the content as JSON
+ content_dict = json.loads(content)
+ logger.debug(f"Parsed content after function call as JSON: {content_dict}")
+ except json.JSONDecodeError:
+ # If it's not valid JSON, use it as a plain message
+ logger.debug(f"Content after function call is not valid JSON, using as message")
+ content_dict = {"message": content}
+ else:
+ # If there's no content after the function call, use the query as a message
+ content_dict = {"message": f"Query: {function_params.get('query', 'No query specified')}"}
+ except json.JSONDecodeError:
+ logger.debug("Failed to extract function parameters as JSON")
+
+ # Method 3: Use regex to find JSON-like structure
+ if not content_dict:
+ json_pattern = r'({.*?})'
+ match = re.search(json_pattern, cleaned_text, re.DOTALL)
+ if match:
+ try:
+ json_str = match.group(1)
+ content_dict = json.loads(json_str)
+ logger.debug(f"Extracted JSON using regex: {content_dict}")
+ except json.JSONDecodeError:
+ logger.debug("Failed to extract JSON using regex")
+
+ # Method 4: Find JSON by braces (if previous methods didn't work)
+ if not content_dict:
+ start_idx = cleaned_text.find('{')
+ end_idx = cleaned_text.rfind('}') + 1
+ if start_idx != -1 and end_idx != -1:
+ try:
+ json_str = cleaned_text[start_idx:end_idx]
+ content_dict = json.loads(json_str)
+ logger.debug(f"Extracted JSON using braces: {content_dict}")
+ except json.JSONDecodeError:
+ logger.debug("Failed to extract JSON using braces")
+
+ # It set out to be the JSON object we asked for and broke (cut off, malformed):
+ # never show the customer raw JSON.
+ if not content_dict and cleaned_text.lstrip().startswith('{'):
+ return salvage_broken_json(cleaned_text)
+
+ # If we failed to extract valid JSON, try to parse potential field values from the text
+ if not content_dict:
+ content_dict = extract_fields_from_text(cleaned_text)
+
+ # If we successfully extracted a dictionary
+ if content_dict:
+ try:
+ return model(**content_dict)
+ except Exception as e:
+ logger.error(f"Failed to create ChatResponse from dictionary: {e}")
+ # In case of error, use the content dict's message or the original text
+ message = content_dict.get('message', cleaned_text)
+ return create_basic_chat_response(message)
+
+ # If no JSON structure found, create a basic response
+ return create_basic_chat_response(cleaned_text)
+
+def salvage_broken_json(text: str) -> ChatResponse:
+ """A reply that began as a JSON object but does not parse. Keeps its message if
+ that field arrived complete; otherwise the generic error, never the fragment."""
+ match = _MESSAGE_FIELD.search(text)
+ if match:
+ try:
+ message = json.loads(f'"{match.group(1)}"')
+ if message.strip():
+ return create_basic_chat_response(message)
+ except json.JSONDecodeError:
+ pass
+ logger.warning(f"Unparseable structured reply, answering with the generic error: {text[:200]}")
+ return create_basic_chat_response(GENERIC_ERROR_MESSAGE)
+
+
+def clean_malformed_output(text: str) -> str:
+ """
+ Clean up malformed output with true/false values outside JSON structure.
+
+ Args:
+ text: The text to clean
+
+ Returns:
+ str: Cleaned text
+ """
+ original_text = text
+
+ # Replace patterns like "truefalse" with proper JSON values
+ # This regex finds patterns like "truefalseXXX" and removes them
+ text = re.sub(r'(true|false)(true|false)', ' ', text)
+
+ # Replace literal "true" and "false" that appear outside of quotes
+ def replace_literals(match):
+ value = match.group(0)
+ if value == "true":
+ return "true"
+ elif value == "false":
+ return "false"
+ return value
+
+ # This is a simplified approach - a more robust solution would need to
+ # identify whether true/false is inside quotes or not
+ text = re.sub(r'\b(true|false)\b', replace_literals, text)
+
+ # Fix malformed image objects in shopify_output
+ # Pattern 1: "src":"url"}," followed by whitespace/newlines and then ,"alt"
+ # Should be: "src":"url","alt"
+ before_fix = text
+ # This handles: }," \n\n ,"alt"
+ text = re.sub(r'(\"src\":\"[^\"]+\")\}[,\s\n]*,[,\s\n]*\"alt\"', r'\1,"alt"', text, flags=re.MULTILINE)
+ if text != before_fix:
+ logger.warning("Fixed malformed image object: },' with newlines before 'alt' pattern")
+
+ # Pattern 2: }," followed by whitespace/newlines and comma before next field
+ before_fix = text
+ text = re.sub(r'\}[,\s\n]+,[,\s\n]*\"', ',"', text, flags=re.MULTILINE)
+ if text != before_fix:
+ logger.warning("Fixed malformed JSON: },' with newlines pattern")
+
+ # Pattern 3: Fix pattern where closing brace appears after URL before comma
+ # "image":{"src":"url"}," should be "image":{"src":"url",
+ before_fix = text
+ text = re.sub(r'(\"image\":\{\"src\":\"[^\"]+\")\},[,\s\n]*\"', r'\1,"', text, flags=re.MULTILINE)
+ if text != before_fix:
+ logger.warning("Fixed malformed image closing brace pattern")
+
+ if text != original_text:
+ logger.debug(f"Cleaned JSON from: {original_text[:200]}... to: {text[:200]}...")
+
+ return text
+
+def extract_fields_from_text(text: str) -> Dict[str, Any]:
+ """
+ Extract field values from text when JSON parsing fails.
+
+ Args:
+ text: The text to extract field values from
+
+ Returns:
+ Dict[str, Any]: Dictionary of extracted fields
+ """
+ fields = {}
+
+ # Extract message content - everything before any markers
+ message_match = re.search(r'^(.*?)(?:true|false|<|$)', text, re.DOTALL)
+ if message_match:
+ fields['message'] = message_match.group(1).strip()
+
+ # Extract transfer_to_human
+ if 'TRANSFER_REQUEST' in text or 'DIRECT_REQUEST' in text:
+ fields['transfer_to_human'] = True
+
+ # Extract transfer reason if present
+ reason_match = re.search(r'(.*?)', text)
+ if reason_match:
+ fields['transfer_reason'] = reason_match.group(1)
+ elif 'DIRECT_REQUEST' in text:
+ fields['transfer_reason'] = 'DIRECT_REQUEST'
+
+ # Extract transfer description if present
+ desc_match = re.search(r'(.*?)', text)
+ if desc_match:
+ fields['transfer_description'] = desc_match.group(1)
+
+ # Extract end_chat flags
+ if 'end_chat_reason' in text:
+ fields['end_chat'] = True
+
+ return fields
+
+def create_basic_chat_response(message: str) -> ChatResponse:
+ """
+ Create a basic ChatResponse with default values.
+
+ Args:
+ message: The message content
+
+ Returns:
+ ChatResponse: A ChatResponse object with the message and default values
+ """
+ # Extract possible flags from the message
+ transfer_to_human = False
+ if "TRANSFER_REQUEST" in message or "DIRECT_REQUEST" in message:
+ transfer_to_human = True
+
+ return ChatResponse(
+ message=message,
+ transfer_to_human=transfer_to_human,
+ end_chat=False,
+ transfer_reason=None,
+ transfer_description=None,
+ request_rating=False,
+ create_ticket=False
+ )
\ No newline at end of file
diff --git a/backend/app/utils/sanitize.py b/backend/app/utils/sanitize.py
new file mode 100644
index 0000000..f080153
--- /dev/null
+++ b/backend/app/utils/sanitize.py
@@ -0,0 +1,194 @@
+"""
+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
+import html
+import urllib.parse
+from typing import Optional
+
+
+def decode_entities(text: str) -> str:
+ """Decode HTML entities and URL encoding to catch obfuscated attacks"""
+ # Decode HTML entities multiple times to catch nested encoding
+ for _ in range(3):
+ text = html.unescape(text)
+
+ # Decode URL encoding multiple times
+ for _ in range(3):
+ try:
+ text = urllib.parse.unquote(text)
+ except Exception:
+ break
+
+ return text
+
+
+def sanitize_message(message: Optional[str]) -> Optional[str]:
+ """
+ Comprehensive sanitization to prevent XSS attacks including encoded payloads.
+
+ Blocks:
+ - All dangerous HTML tags (script, iframe, object, embed, svg, etc.)
+ - JavaScript URIs (including encoded variants)
+ - Data URIs (except data:image)
+ - Event handlers (all on* attributes)
+ - SVG-based XSS vectors
+ - Encoded payloads (HTML entities, URL encoding, Unicode)
+ - CSS expressions and behaviors
+ - Base64 encoded attacks
+ - Template literals and ES6 syntax
+
+ Args:
+ message: The message text to sanitize
+
+ Returns:
+ Sanitized message text or None if input is None
+ """
+ if not message:
+ return message
+
+ # SECURITY LAYER 1: Remove markdown link and image syntax to prevent injection
+ # This must happen BEFORE marked.js processes it on the frontend
+ # Pattern matches: [text](url) including nested brackets
+ message = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', message)
+
+ # SECURITY: Remove markdown image syntax  - capture alt text without the !
+ message = re.sub(r'!\[([^\]]*)\]\([^\)]+\)', r'\1', message)
+
+ # SECURITY: Remove reference-style markdown links [text][ref]
+ message = re.sub(r'\[([^\]]+)\]\[[^\]]+\]', r'\1', message)
+
+ # SECURITY: Remove link reference definitions [ref]: url
+ message = re.sub(r'^\[[^\]]+\]:\s*.+$', '', message, flags=re.MULTILINE)
+
+ # SECURITY LAYER 2: Decode any encoded content to catch obfuscated attacks
+ decoded_message = decode_entities(message)
+
+ # Check for encoded javascript: or data: URIs in decoded content
+ dangerous_patterns = [
+ r'javascript\s*:',
+ r'data\s*:(?!image/)',
+ r'vbscript\s*:',
+ r'file\s*:',
+ r'about\s*:',
+ ]
+
+ for pattern in dangerous_patterns:
+ if re.search(pattern, decoded_message, re.IGNORECASE):
+ # If found in decoded content, sanitize the original too
+ message = re.sub(pattern, '', message, flags=re.IGNORECASE)
+
+ # Remove all dangerous HTML tags (comprehensive list)
+ # SECURITY: Including and
to prevent any external resource loading or clickable links
+ dangerous_tags = [
+ 'script', 'iframe', 'frame', 'frameset', 'object', 'embed',
+ 'applet', 'base', 'link', 'meta', 'style', 'svg', 'math',
+ 'form', 'input', 'button', 'textarea', 'select', 'option',
+ 'xml', 'xss', 'import', 'video', 'audio', 'track', 'source',
+ 'canvas', 'details', 'template', 'slot', 'noscript',
+ 'marquee', 'bgsound', 'keygen', 'command',
+ 'a', 'img', 'area', 'map' # Block links and images to prevent external resource loading
+ ]
+
+ for tag in dangerous_tags:
+ # Remove opening and closing tags with any attributes
+ message = re.sub(rf'<{tag}[^>]*>.*?{tag}>', '', message, flags=re.IGNORECASE | re.DOTALL)
+ message = re.sub(rf'<{tag}[^>]*/?>', '', message, flags=re.IGNORECASE)
+
+ # Remove javascript: URIs (including encoded variants)
+ message = re.sub(r'j\s*a\s*v\s*a\s*s\s*c\s*r\s*i\s*p\s*t\s*:', '', message, flags=re.IGNORECASE)
+ message = re.sub(r'[x]?[0-9a-f]+;?', lambda m: decode_entities(m.group()), message)
+
+ # Remove vbscript: URIs
+ message = re.sub(r'vbscript:', '', message, flags=re.IGNORECASE)
+
+ # Remove data: URIs that could contain HTML/JS (but preserve data:image)
+ message = re.sub(r'data:(?!image/)[^,;]*[,;]', '', message, flags=re.IGNORECASE)
+
+ # Remove expression() and behavior CSS properties
+ message = re.sub(r'expression\s*\([^)]*\)', '', message, flags=re.IGNORECASE)
+ message = re.sub(r'behavior\s*:[^;]*', '', message, flags=re.IGNORECASE)
+ message = re.sub(r'-moz-binding\s*:', '', message, flags=re.IGNORECASE)
+
+ # Remove import statements
+ message = re.sub(r'@import', '', message, flags=re.IGNORECASE)
+
+ # Remove all event handlers (comprehensive list)
+ event_handlers = [
+ 'onabort', 'onactivate', 'onafterprint', 'onafterupdate', 'onbeforeactivate',
+ 'onbeforecopy', 'onbeforecut', 'onbeforedeactivate', 'onbeforeeditfocus',
+ 'onbeforepaste', 'onbeforeprint', 'onbeforeunload', 'onbeforeupdate', 'onblur',
+ 'onbounce', 'oncellchange', 'onchange', 'onclick', 'oncontextmenu', 'oncontrolselect',
+ 'oncopy', 'oncut', 'ondataavailable', 'ondatasetchanged', 'ondatasetcomplete',
+ 'ondblclick', 'ondeactivate', 'ondrag', 'ondragend', 'ondragenter', 'ondragleave',
+ 'ondragover', 'ondragstart', 'ondrop', 'onerror', 'onerrorupdate', 'onfilterchange',
+ 'onfinish', 'onfocus', 'onfocusin', 'onfocusout', 'onhelp', 'oninput', 'oninvalid',
+ 'onkeydown', 'onkeypress', 'onkeyup', 'onlayoutcomplete', 'onload', 'onlosecapture',
+ 'onmessage', 'onmousedown', 'onmouseenter', 'onmouseleave', 'onmousemove',
+ 'onmouseout', 'onmouseover', 'onmouseup', 'onmousewheel', 'onmove', 'onmoveend',
+ 'onmovestart', 'onpaste', 'onpropertychange', 'onreadystatechange', 'onreset',
+ 'onresize', 'onresizeend', 'onresizestart', 'onrowenter', 'onrowexit', 'onrowsdelete',
+ 'onrowsinserted', 'onscroll', 'onsearch', 'onselect', 'onselectionchange',
+ 'onselectstart', 'onstart', 'onstop', 'onsubmit', 'onunload',
+ 'oncanplay', 'oncanplaythrough', 'oncuechange', 'ondurationchange', 'onemptied',
+ 'onended', 'onloadeddata', 'onloadedmetadata', 'onloadstart', 'onpause', 'onplay',
+ 'onplaying', 'onprogress', 'onratechange', 'onseeked', 'onseeking', 'onstalled',
+ 'onsuspend', 'ontimeupdate', 'onvolumechange', 'onwaiting', 'ontoggle',
+ 'onauxclick', 'ongotpointercapture', 'onlostpointercapture', 'onpointercancel',
+ 'onpointerdown', 'onpointerenter', 'onpointerleave', 'onpointermove', 'onpointerout',
+ 'onpointerover', 'onpointerup', 'onwheel', 'onanimationcancel', 'onanimationend',
+ 'onanimationiteration', 'onanimationstart', 'ontransitioncancel', 'ontransitionend',
+ 'ontransitionrun', 'ontransitionstart', 'onshow', 'onsort', 'onpointerrawupdate'
+ ]
+
+ for handler in event_handlers:
+ # Remove with quotes
+ message = re.sub(rf'{handler}\s*=\s*["\'][^"\']*["\']', '', message, flags=re.IGNORECASE)
+ # Remove without quotes
+ message = re.sub(rf'{handler}\s*=\s*[^>\s]*', '', message, flags=re.IGNORECASE)
+ # Remove with backticks (template literals)
+ message = re.sub(rf'{handler}\s*=\s*`[^`]*`', '', message, flags=re.IGNORECASE)
+
+ # Remove dangerous attributes
+ # SECURITY: Including src, href, and all attributes that can load external resources
+ dangerous_attrs = [
+ 'formaction', 'action', 'form', 'srcdoc', 'srcset', 'dynsrc', 'lowsrc',
+ 'ping', 'poster', 'background', 'code', 'codebase', 'archive', 'profile',
+ 'xmlns', 'xlink:href', 'attributename', 'from', 'to', 'values', 'begin',
+ 'autofocus', 'autoplay', 'controls', 'manifest', 'sandbox',
+ 'src', 'href', 'data', 'icon', 'usemap' # Block all resource-loading attributes
+ ]
+
+ for attr in dangerous_attrs:
+ message = re.sub(rf'{attr}\s*=\s*["\'][^"\']*["\']', '', message, flags=re.IGNORECASE)
+ message = re.sub(rf'{attr}\s*=\s*[^>\s]*', '', message, flags=re.IGNORECASE)
+
+ # Remove HTML comments (can be used to hide malicious code)
+ message = re.sub(r'', '', message, flags=re.DOTALL)
+
+ # Remove CDATA sections
+ message = re.sub(r'', '', message, flags=re.DOTALL | re.IGNORECASE)
+
+ # Remove XML processing instructions
+ message = re.sub(r'<\?.*?\?>', '', message, flags=re.DOTALL)
+
+ # Remove null bytes and other control characters that can bypass filters
+ message = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', message)
+
+ # Remove Unicode directional override characters (can hide malicious code)
+ message = re.sub(r'[\u202a-\u202e\u2066-\u2069]', '', message)
+
+ return message
diff --git a/backend/app/utils/urls.py b/backend/app/utils/urls.py
new file mode 100644
index 0000000..2ea204f
--- /dev/null
+++ b/backend/app/utils/urls.py
@@ -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.
+"""
+
+import re
+
+# Any scheme prefix, matched case-insensitively so "HTTP://" can't sneak past
+# scheme checks or get a second scheme prepended.
+_SCHEME_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.-]*)://")
+
+
+def normalize_url(value: str, require_https: bool = False) -> str:
+ """Normalize a user-supplied URL: default the scheme to https and lowercase
+ an existing scheme. Raises ValueError for blank input, non-http(s) schemes,
+ and — when require_https is set — plain http URLs.
+ """
+ value = value.strip()
+ if not value:
+ raise ValueError("URL must not be blank")
+ match = _SCHEME_RE.match(value)
+ if match:
+ scheme = match.group(1).lower()
+ if scheme not in ("http", "https"):
+ raise ValueError("URL must use http or https")
+ if require_https and scheme != "https":
+ raise ValueError("URL must use https")
+ return f"{scheme}://{value[match.end():]}"
+ return f"https://{value}"
diff --git a/backend/app/utils/uuids.py b/backend/app/utils/uuids.py
new file mode 100644
index 0000000..b76d9e4
--- /dev/null
+++ b/backend/app/utils/uuids.py
@@ -0,0 +1,22 @@
+"""
+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
+
+
+def as_uuid(value: UUID | str) -> UUID:
+ """A UUID from either form. Raises ValueError for anything that isn't one."""
+ return value if isinstance(value, UUID) else UUID(str(value))