feat(models): 组织、用户、角色、客户、会话与消息模型
按组织边界定义数据关系,补齐仓储层与通用工具,迁移脚本可重复执行。
This commit is contained in:
1 parent
977783c126
commit
9eb8613c05
126 files changed
+17436
No files matched your search
@@ -0,0 +1,150 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
from .organization import Organization
|
||||||
|
from .user import User
|
||||||
|
from .fcm_token import FCMToken
|
||||||
|
from .auth_session import AuthSession
|
||||||
|
from .customer import Customer
|
||||||
|
from .role import Role
|
||||||
|
from .permission import Permission
|
||||||
|
from .ai_config import AIConfig, AIModelType
|
||||||
|
from .agent import Agent
|
||||||
|
from .knowledge_to_agent import KnowledgeToAgent
|
||||||
|
from .knowledge import Knowledge
|
||||||
|
from .chat_history import ChatHistory
|
||||||
|
from .session_to_agent import SessionToAgent, SessionStatus
|
||||||
|
from .rating import Rating
|
||||||
|
from app.models.jira import JiraToken
|
||||||
|
from app.models.shopify import ShopifyShop
|
||||||
|
from app.models.workflow import Workflow
|
||||||
|
from app.models.workflow_node import WorkflowNode, ExitCondition
|
||||||
|
from app.models.workflow_connection import WorkflowConnection
|
||||||
|
from app.models.mcp_tool import MCPTool, MCPToolToAgent, MCPTransportType
|
||||||
|
from app.models.file_attachment import FileAttachment
|
||||||
|
from app.models.customer import LeadStage
|
||||||
|
from app.models.lead_capture import (
|
||||||
|
LeadCaptureConfig, LeadCaptureResponse,
|
||||||
|
LeadAssignmentMode, CrmSyncTarget,
|
||||||
|
)
|
||||||
|
from app.models.channels import (
|
||||||
|
ChannelAccount, ChannelType, ChannelConversation, AgentChannelConfig,
|
||||||
|
)
|
||||||
|
from app.models.faq_generation_job import (
|
||||||
|
FAQGenerationJob, FAQJobType, FAQJobStatus, FAQJobStage,
|
||||||
|
)
|
||||||
|
from app.models.faq import FAQ, FAQStatus
|
||||||
|
from app.models.help_center import (
|
||||||
|
HelpCenterSettings, HelpCenterQuery, DomainStatus, SSLStatus,
|
||||||
|
)
|
||||||
|
from app.models.ticket import (
|
||||||
|
Ticket, TicketSequence, TicketSession,
|
||||||
|
TicketStatus, TicketPriority, TicketSource, ResolutionOutcome,
|
||||||
|
)
|
||||||
|
from app.models.ticket_activity import TicketActivity, TicketActivityType, TicketActorType
|
||||||
|
from app.models.ticket_settings import OrganizationTicketSettings
|
||||||
|
from app.models.investigation import (
|
||||||
|
InvestigationRun, InvestigationRunType, InvestigationRunStatus, InvestigationTrigger,
|
||||||
|
InvestigationHypothesis, InvestigationEvent, RCADocument, TicketProposal,
|
||||||
|
)
|
||||||
|
from app.models.ticket_db_connector import TicketDBConnector, DBConnectorAuditLog
|
||||||
|
from app.models.crm import (
|
||||||
|
CrmConnection, CrmSyncJob, CrmCustomerSync,
|
||||||
|
CrmProvider, CrmConnectionStatus, CrmSyncJobStatus,
|
||||||
|
)
|
||||||
|
from app.models.notification_settings import UserNotificationSettings
|
||||||
|
from app.models.guardrail_event import GuardrailEvent
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# This ensures all models are imported in the correct order
|
||||||
|
__all__ = [
|
||||||
|
"Organization",
|
||||||
|
"User",
|
||||||
|
"FCMToken",
|
||||||
|
"AuthSession",
|
||||||
|
"Customer",
|
||||||
|
"Permission",
|
||||||
|
"Role",
|
||||||
|
"AIConfig",
|
||||||
|
"AIModelType",
|
||||||
|
"Agent",
|
||||||
|
"KnowledgeToAgent",
|
||||||
|
"Knowledge",
|
||||||
|
"ChatHistory",
|
||||||
|
"SessionToAgent",
|
||||||
|
"SessionStatus",
|
||||||
|
"Rating",
|
||||||
|
"JiraToken",
|
||||||
|
"ShopifyShop",
|
||||||
|
"Workflow",
|
||||||
|
"WorkflowNode",
|
||||||
|
"ExitCondition",
|
||||||
|
"WorkflowConnection",
|
||||||
|
"MCPTool",
|
||||||
|
"MCPToolToAgent",
|
||||||
|
"MCPTransportType",
|
||||||
|
"FileAttachment",
|
||||||
|
"LeadStage",
|
||||||
|
"LeadCaptureConfig",
|
||||||
|
"LeadCaptureResponse",
|
||||||
|
"LeadAssignmentMode",
|
||||||
|
"CrmSyncTarget",
|
||||||
|
"ChannelAccount",
|
||||||
|
"ChannelType",
|
||||||
|
"ChannelConversation",
|
||||||
|
"AgentChannelConfig",
|
||||||
|
"FAQGenerationJob",
|
||||||
|
"FAQJobType",
|
||||||
|
"FAQJobStatus",
|
||||||
|
"FAQJobStage",
|
||||||
|
"FAQ",
|
||||||
|
"FAQStatus",
|
||||||
|
"HelpCenterSettings",
|
||||||
|
"HelpCenterQuery",
|
||||||
|
"DomainStatus",
|
||||||
|
"SSLStatus",
|
||||||
|
"Ticket",
|
||||||
|
"TicketSequence",
|
||||||
|
"TicketSession",
|
||||||
|
"TicketStatus",
|
||||||
|
"TicketPriority",
|
||||||
|
"TicketSource",
|
||||||
|
"ResolutionOutcome",
|
||||||
|
"TicketActivity",
|
||||||
|
"TicketActivityType",
|
||||||
|
"TicketActorType",
|
||||||
|
"OrganizationTicketSettings",
|
||||||
|
"UserNotificationSettings",
|
||||||
|
"InvestigationRun",
|
||||||
|
"InvestigationRunType",
|
||||||
|
"InvestigationRunStatus",
|
||||||
|
"InvestigationTrigger",
|
||||||
|
"InvestigationHypothesis",
|
||||||
|
"InvestigationEvent",
|
||||||
|
"RCADocument",
|
||||||
|
"TicketProposal",
|
||||||
|
"TicketDBConnector",
|
||||||
|
"DBConnectorAuditLog",
|
||||||
|
"CrmConnection",
|
||||||
|
"CrmSyncJob",
|
||||||
|
"CrmCustomerSync",
|
||||||
|
"CrmProvider",
|
||||||
|
"CrmConnectionStatus",
|
||||||
|
"CrmSyncJobStatus",
|
||||||
|
"GuardrailEvent",
|
||||||
|
]
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, Integer, String, Text, Boolean, Enum as SQLEnum, ForeignKey, JSON, Table, Float, func
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from app.database import Base
|
||||||
|
import enum
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class AgentType(str, enum.Enum):
|
||||||
|
CUSTOMER_SUPPORT = "customer_support"
|
||||||
|
SALES = "sales"
|
||||||
|
TECH_SUPPORT = "tech_support"
|
||||||
|
GENERAL = "general"
|
||||||
|
CUSTOM = "custom"
|
||||||
|
|
||||||
|
|
||||||
|
class ChatStyle(str, enum.Enum):
|
||||||
|
CHATBOT = "CHATBOT"
|
||||||
|
ASK_ANYTHING = "ASK_ANYTHING"
|
||||||
|
# Premium design presets
|
||||||
|
GLASS = "GLASS"
|
||||||
|
TERMINAL = "TERMINAL"
|
||||||
|
PLAYFUL = "PLAYFUL"
|
||||||
|
CALM_MINT = "CALM_MINT"
|
||||||
|
AURORA = "AURORA"
|
||||||
|
SUNRISE = "SUNRISE"
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetPosition(str, enum.Enum):
|
||||||
|
FLOATING = "FLOATING"
|
||||||
|
FIXED = "FIXED"
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCustomization(Base):
|
||||||
|
__tablename__ = "agent_customizations"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id", ondelete="CASCADE"))
|
||||||
|
photo_url = Column(String)
|
||||||
|
chat_background_color = Column(String, default="#F8F9FA")
|
||||||
|
chat_bubble_color = Column(String, default="#E9ECEF")
|
||||||
|
chat_text_color = Column(String, default="#212529")
|
||||||
|
icon_url = Column(String)
|
||||||
|
icon_color = Column(String, default="#6C757D")
|
||||||
|
accent_color = Column(String, default="#f34611")
|
||||||
|
font_family = Column(String, default="Inter, system-ui, sans-serif")
|
||||||
|
custom_css = Column(Text)
|
||||||
|
customization_metadata = Column(JSON, default={})
|
||||||
|
chat_style = Column(SQLEnum(ChatStyle), default=ChatStyle.CHATBOT, nullable=False)
|
||||||
|
widget_position = Column(SQLEnum(WidgetPosition), default=WidgetPosition.FLOATING, nullable=False)
|
||||||
|
welcome_title = Column(String, nullable=True)
|
||||||
|
welcome_subtitle = Column(String, nullable=True)
|
||||||
|
# First in-conversation agent bubble shown on open (distinct from welcome_title/subtitle)
|
||||||
|
welcome_message = Column(Text, nullable=True)
|
||||||
|
chat_initiation_messages = Column(JSON, nullable=True)
|
||||||
|
# Predefined quick-action buttons shown beneath the welcome message (list of label strings)
|
||||||
|
quick_actions = Column(JSON, nullable=True)
|
||||||
|
show_citations = Column(Boolean, default=False, nullable=False)
|
||||||
|
# Optionally require the visitor's email before chatting (off by default)
|
||||||
|
collect_email = Column(Boolean, default=False, nullable=False)
|
||||||
|
# Small footer line telling visitors the replies come from AI. On by default:
|
||||||
|
# the honest disclosure is the one users expect, and it is only ever hidden
|
||||||
|
# once a human agent takes the conversation over, where it would be untrue.
|
||||||
|
show_ai_disclaimer = Column(Boolean, default=True, nullable=False, server_default="true")
|
||||||
|
# Lets a visitor abandon the current conversation and start a clean one. Off by
|
||||||
|
# default: it closes the session, so a human agent mid-handover would lose the
|
||||||
|
# thread — operators opt in when that trade is right for them.
|
||||||
|
allow_new_chat = Column(Boolean, default=False, nullable=False, server_default="false")
|
||||||
|
|
||||||
|
# Relationship
|
||||||
|
agent = relationship("Agent", back_populates="customization")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Association table for agent-usergroup relationship
|
||||||
|
agent_usergroup = Table(
|
||||||
|
'agent_usergroup',
|
||||||
|
Base.metadata,
|
||||||
|
Column('agent_id', UUID(as_uuid=True), ForeignKey('agents.id', ondelete='CASCADE')),
|
||||||
|
Column('group_id', UUID(as_uuid=True), ForeignKey('groups.id', ondelete='CASCADE'))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Agent(Base):
|
||||||
|
__tablename__ = "agents"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
display_name = Column(String(100), nullable=True)
|
||||||
|
description = Column(Text)
|
||||||
|
agent_type = Column(SQLEnum(AgentType), nullable=False)
|
||||||
|
_instructions = Column('instructions', Text, nullable=False)
|
||||||
|
tools = Column(Text) # Stored as JSON array of tool configurations
|
||||||
|
is_active = Column(Boolean, default=False)
|
||||||
|
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"))
|
||||||
|
is_default = Column(Boolean, default=False)
|
||||||
|
transfer_to_human = Column(Boolean, default=False, nullable=False)
|
||||||
|
# Off means the AI never answers on this agent: every chat is queued for the
|
||||||
|
# team from the customer's first message. Distinct from transfer_to_human,
|
||||||
|
# which only lets the AI hand over when it decides to.
|
||||||
|
ai_replies_enabled = Column(Boolean, default=True, nullable=False, server_default="true")
|
||||||
|
# Offer the AI's choices as tappable buttons on messaging channels that
|
||||||
|
# can show them (WhatsApp, Messenger, Instagram, Telegram); not the widget
|
||||||
|
channel_quick_replies = Column(Boolean, default=False, nullable=False, server_default="false")
|
||||||
|
ask_for_rating = Column(Boolean, default=True, nullable=True)
|
||||||
|
# On human handoff, optionally collect the visitor's contact details
|
||||||
|
handoff_collect_email = Column(Boolean, default=True, nullable=False)
|
||||||
|
handoff_collect_name = Column(Boolean, default=True, nullable=False)
|
||||||
|
enable_rate_limiting = Column(Boolean, default=False, nullable=True)
|
||||||
|
overall_limit_per_ip = Column(Integer, default=100, nullable=True)
|
||||||
|
requests_per_sec = Column(Float, default=1, nullable=True)
|
||||||
|
use_workflow = Column(Boolean, default=False, nullable=True)
|
||||||
|
active_workflow_id = Column(UUID(as_uuid=True), ForeignKey("workflows.id"), nullable=True)
|
||||||
|
allow_attachments = Column(Boolean, default=False, nullable=False)
|
||||||
|
# JSON array of allowed attachment type categories: ['images', 'documents', 'office', 'text']
|
||||||
|
# If empty or null, all types are allowed when attachments are enabled
|
||||||
|
allowed_attachment_types = Column(JSON, default=None, nullable=True)
|
||||||
|
require_token_auth = Column(Boolean, default=False, nullable=False)
|
||||||
|
# Per-agent switch for native AI ticketing (org plan must also allow it).
|
||||||
|
# Defaults on so paid orgs get ticketing on every agent out of the box.
|
||||||
|
ticketing_enabled = Column(Boolean, default=True, nullable=False, server_default="true")
|
||||||
|
# Optional one-line business remit for the platform guardrail's topic-scope
|
||||||
|
# line (see app.agents.guardrail_policy). Null -> derived from the agent
|
||||||
|
# description or the organization name/domain, so it works with zero config.
|
||||||
|
# Only the scope DESCRIPTION is tenant-editable; the enforcement text is code.
|
||||||
|
topic_scope = Column(Text, nullable=True)
|
||||||
|
# Tenant-editable scope rule shown under Instructions in the dashboard.
|
||||||
|
# NULL means "use the shipped default" (guardrail_policy.DEFAULT_GUARDRAIL_PROMPT),
|
||||||
|
# so the wording can be improved centrally without rewriting every row.
|
||||||
|
# Editable because a code-owned topic list is a guess about what a business
|
||||||
|
# is NOT — it refused maths for a maths tutor, algorithms for a bootcamp.
|
||||||
|
guardrail_prompt = Column(Text, nullable=True)
|
||||||
|
# Off means no scope rule at all. Injection and disclosure rules are not
|
||||||
|
# covered by this toggle; they stay code-owned.
|
||||||
|
guardrail_enabled = Column(Boolean, default=True, nullable=False, server_default="true")
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
# Relationships
|
||||||
|
organization = relationship("Organization", back_populates="agents")
|
||||||
|
knowledge_links = relationship(
|
||||||
|
"KnowledgeToAgent", back_populates="agent", cascade="all, delete-orphan")
|
||||||
|
mcp_tool_links = relationship(
|
||||||
|
"MCPToolToAgent", back_populates="agent", cascade="all, delete-orphan")
|
||||||
|
customization = relationship(
|
||||||
|
"AgentCustomization", back_populates="agent", uselist=False)
|
||||||
|
lead_capture_config = relationship(
|
||||||
|
"LeadCaptureConfig", back_populates="agent", uselist=False,
|
||||||
|
cascade="all, delete-orphan")
|
||||||
|
widgets = relationship("Widget", back_populates="agent")
|
||||||
|
chat_histories = relationship("ChatHistory", back_populates="agent")
|
||||||
|
session_assignments = relationship("SessionToAgent", back_populates="agent")
|
||||||
|
ratings = relationship("Rating", back_populates="agent")
|
||||||
|
groups = relationship(
|
||||||
|
"UserGroup",
|
||||||
|
secondary=agent_usergroup,
|
||||||
|
backref="agents",
|
||||||
|
lazy="joined"
|
||||||
|
)
|
||||||
|
active_workflow = relationship(
|
||||||
|
"Workflow",
|
||||||
|
foreign_keys=[active_workflow_id],
|
||||||
|
backref="active_agents"
|
||||||
|
)
|
||||||
|
workflows = relationship(
|
||||||
|
"Workflow",
|
||||||
|
foreign_keys="[Workflow.agent_id]",
|
||||||
|
back_populates="agent"
|
||||||
|
)
|
||||||
|
@property
|
||||||
|
def instructions(self):
|
||||||
|
"""Get instructions as a list"""
|
||||||
|
if not self._instructions:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return json.loads(self._instructions)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return [self._instructions]
|
||||||
|
|
||||||
|
@instructions.setter
|
||||||
|
def instructions(self, value):
|
||||||
|
"""Set instructions, converting to JSON string if needed"""
|
||||||
|
if isinstance(value, list):
|
||||||
|
self._instructions = json.dumps(value)
|
||||||
|
elif isinstance(value, str):
|
||||||
|
try:
|
||||||
|
# Try to parse as JSON first
|
||||||
|
parsed = json.loads(value)
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
self._instructions = value
|
||||||
|
else:
|
||||||
|
self._instructions = json.dumps([value])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# If not valid JSON, treat as single instruction
|
||||||
|
self._instructions = json.dumps([value])
|
||||||
|
else:
|
||||||
|
raise ValueError("Instructions must be a list or string")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
orm_mode = True
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES 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"
|
||||||
|
REPLYPIER = "REPLYPIER" # own model for enterprise customers
|
||||||
|
|
||||||
|
DEFAULT_AI_SETTINGS = {
|
||||||
|
"instructions": [
|
||||||
|
"You are a helpful customer service agent.",
|
||||||
|
"Be concise and professional.",
|
||||||
|
"If you don't know something, say so.",
|
||||||
|
"Always maintain a friendly tone."
|
||||||
|
],
|
||||||
|
"tools": ["web_search"],
|
||||||
|
"memory": True,
|
||||||
|
"markdown": True
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AIConfig(Base):
|
||||||
|
__tablename__ = "ai_configs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
organization_id = Column(UUID(as_uuid=True), ForeignKey(
|
||||||
|
"organizations.id"), nullable=False)
|
||||||
|
model_type = Column(SQLEnum(AIModelType), nullable=False)
|
||||||
|
model_name = Column(String, nullable=False) # e.g. "gpt-4", "claude-3"
|
||||||
|
encrypted_api_key = Column(String, nullable=False)
|
||||||
|
# For additional model-specific settings
|
||||||
|
settings = Column(JSON, nullable=True, default=DEFAULT_AI_SETTINGS)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(),
|
||||||
|
onupdate=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
organization = relationship("Organization", back_populates="ai_configs")
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, ForeignKey, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSession(Base):
|
||||||
|
"""One signed-in dashboard session: a login and every refresh after it.
|
||||||
|
|
||||||
|
Access and refresh JWTs carry the row's id as their `sid` claim. The tokens
|
||||||
|
themselves are stateless, so this row is what makes signing out stick:
|
||||||
|
revoking it rejects both tokens before they expire.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "auth_sessions"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
revoked_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.models.channels.channel_account import ChannelAccount, ChannelType
|
||||||
|
from app.models.channels.channel_conversation import ChannelConversation
|
||||||
|
from app.models.channels.agent_channel_config import AgentChannelConfig
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ChannelAccount",
|
||||||
|
"ChannelType",
|
||||||
|
"ChannelConversation",
|
||||||
|
"AgentChannelConfig",
|
||||||
|
]
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Boolean, DateTime, ForeignKey, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AgentChannelConfig(Base):
|
||||||
|
"""Routes a connected channel account to the AI agent that answers it.
|
||||||
|
|
||||||
|
One config per channel account: every inbound conversation on that
|
||||||
|
account is handled by the mapped agent.
|
||||||
|
"""
|
||||||
|
__tablename__ = "agent_channel_configs"
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('channel_account_id', name='uq_agent_channel_config_account'),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
agent_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("agents.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
channel_account_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("channel_accounts.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
is_active = Column(Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
agent = relationship("Agent")
|
||||||
|
account = relationship("ChannelAccount")
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import Column, String, Text, Boolean, DateTime, ForeignKey, JSON, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelType(str, enum.Enum):
|
||||||
|
"""Messaging channels a customer conversation can arrive from.
|
||||||
|
|
||||||
|
Stored as plain strings (not a DB enum) so adding a channel never
|
||||||
|
requires a schema migration. 'web' is the embedded widget and has no
|
||||||
|
ChannelAccount row.
|
||||||
|
"""
|
||||||
|
WEB = "web"
|
||||||
|
TELEGRAM = "telegram"
|
||||||
|
WHATSAPP = "whatsapp"
|
||||||
|
MESSENGER = "messenger"
|
||||||
|
INSTAGRAM = "instagram"
|
||||||
|
SLACK = "slack"
|
||||||
|
EMAIL = "email"
|
||||||
|
SMS = "sms"
|
||||||
|
LINE = "line"
|
||||||
|
API = "api"
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelAccount(Base):
|
||||||
|
"""An organization's connected account on an external messaging channel.
|
||||||
|
|
||||||
|
One row per connected bot/number/page. Channel-specific secrets
|
||||||
|
(bot tokens, page access tokens, WABA ids, ...) live together in
|
||||||
|
`encrypted_credentials` as a Fernet-encrypted JSON blob so new channels
|
||||||
|
never need schema changes.
|
||||||
|
"""
|
||||||
|
__tablename__ = "channel_accounts"
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('channel_type', 'external_account_id', name='uq_channel_account_external'),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
organization_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
channel_type = Column(String, nullable=False, index=True)
|
||||||
|
# Platform-side identifier: Telegram bot id, WhatsApp phone_number_id,
|
||||||
|
# Facebook page id, Slack team id, inbound email address, ...
|
||||||
|
external_account_id = Column(String, nullable=False)
|
||||||
|
# Human-readable label shown in settings: "@acme_bot", "Acme (+44...)"
|
||||||
|
display_name = Column(String, nullable=True)
|
||||||
|
# Fernet-encrypted JSON of channel-specific secrets
|
||||||
|
encrypted_credentials = Column(Text, nullable=False)
|
||||||
|
# Per-account secret used to authenticate inbound webhooks
|
||||||
|
webhook_secret = Column(String, nullable=False)
|
||||||
|
# Non-secret channel settings (greeting, template defaults, storage mode, ...)
|
||||||
|
settings = Column(JSON, default=dict)
|
||||||
|
is_active = Column(Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
organization = relationship("Organization")
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT 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 ReplyPier session.
|
||||||
|
|
||||||
|
The generic analog of SlackConversation: one row per
|
||||||
|
(connected account, platform conversation, session). A new session for
|
||||||
|
the same platform conversation (e.g. after the previous one closes)
|
||||||
|
gets a new row.
|
||||||
|
"""
|
||||||
|
__tablename__ = "channel_conversations"
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
'channel_account_id', 'external_conversation_id', 'session_id',
|
||||||
|
name='uq_channel_conversation_session'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
channel_account_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("channel_accounts.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
channel_type = Column(String, nullable=False)
|
||||||
|
# Platform conversation id: Telegram chat_id, WhatsApp wa_id,
|
||||||
|
# Messenger PSID, Instagram IGSID, Slack thread key, email thread id, ...
|
||||||
|
external_conversation_id = Column(String, nullable=False, index=True)
|
||||||
|
# Platform user id of the customer (often equals the conversation id)
|
||||||
|
external_user_id = Column(String, nullable=False)
|
||||||
|
|
||||||
|
session_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("session_to_agents.session_id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Denormalized for faster webhook-path lookups
|
||||||
|
organization_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
agent_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("agents.id", ondelete="SET NULL"),
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
customer_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("customers.id", ondelete="SET NULL"),
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamp of the customer's latest inbound message; drives the
|
||||||
|
# 24h (Meta) delivery-window checks.
|
||||||
|
last_inbound_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
# Channel-specific extras (message thread ids, profile snapshots, ...)
|
||||||
|
extra = Column(JSON, default=dict)
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
account = relationship("ChannelAccount")
|
||||||
|
session = relationship("SessionToAgent")
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, String, JSON, ForeignKey, TIMESTAMP, func, UUID, Interval, Float, Index, text
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.core.encryption import EncryptedText
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ChatHistory(Base):
|
||||||
|
__tablename__ = "chat_history"
|
||||||
|
__table_args__ = (
|
||||||
|
# Every read of a conversation filters by session_id and orders by
|
||||||
|
# (created_at, id) — the inbox previews, get_session_history, the AI's
|
||||||
|
# context window. Declared here as well as in the migration so autogenerate
|
||||||
|
# never proposes dropping it and the sqlite test schema matches Postgres.
|
||||||
|
Index('ix_chat_history_session_created', 'session_id',
|
||||||
|
text('created_at DESC'), text('id DESC')),
|
||||||
|
# Delivery receipts look their message up by platform id
|
||||||
|
Index('ix_chat_history_org_external_message', 'organization_id', 'external_message_id',
|
||||||
|
postgresql_where=text('external_message_id IS NOT NULL'),
|
||||||
|
sqlite_where=text('external_message_id IS NOT NULL')),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
organization_id = Column(UUID(as_uuid=True), ForeignKey(
|
||||||
|
"organizations.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
user_id = Column(UUID(as_uuid=True), ForeignKey(
|
||||||
|
"users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
customer_id = Column(UUID(as_uuid=True), ForeignKey(
|
||||||
|
"customers.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
agent_id = Column(UUID(as_uuid=True), ForeignKey(
|
||||||
|
"agents.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
session_id = Column(UUID(as_uuid=True), ForeignKey("session_to_agents.session_id"), nullable=True)
|
||||||
|
# Encrypted at rest — see app.core.encryption. Storage stays text, so rows
|
||||||
|
# written before the change read back as plaintext until the backfill lands.
|
||||||
|
message = Column(EncryptedText, nullable=False)
|
||||||
|
# 'user', 'bot', or 'agent'
|
||||||
|
message_type = Column(String, nullable=False)
|
||||||
|
attributes = Column(JSON, default={})
|
||||||
|
sentiment_label = Column(String, nullable=True) # 'positive', 'neutral', 'negative'
|
||||||
|
sentiment_score = Column(Float, nullable=True) # -1.0 to 1.0
|
||||||
|
# Channel messages only: the platform's id for this message (WhatsApp wamid)
|
||||||
|
# and its latest delivery receipt — app.channels.base.DeliveryState values.
|
||||||
|
external_message_id = Column(String, nullable=True)
|
||||||
|
channel_status = Column(String(16), nullable=True)
|
||||||
|
channel_status_at = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||||
|
created_at = Column(TIMESTAMP(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(TIMESTAMP(timezone=True), server_default=func.now(),
|
||||||
|
onupdate=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = relationship("User", back_populates="chat_histories")
|
||||||
|
customer = relationship("Customer", back_populates="chat_histories")
|
||||||
|
agent = relationship("Agent", back_populates="chat_histories")
|
||||||
|
organization = relationship("Organization", back_populates="chat_histories")
|
||||||
|
session_assignment = relationship("SessionToAgent")
|
||||||
|
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Column, Integer, String, Text, DateTime, ForeignKey, JSON,
|
||||||
|
UniqueConstraint, Index,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class _ValueStrEnum(str, enum.Enum):
|
||||||
|
"""str-enum whose str() is the value — in-memory enum instances and
|
||||||
|
refreshed-from-DB plain strings render identically (plain-string columns)."""
|
||||||
|
|
||||||
|
def __str__(self) -> str: # pragma: no cover - trivial
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
|
||||||
|
class CrmProvider(_ValueStrEnum):
|
||||||
|
"""CRMs a lead can be pushed to.
|
||||||
|
|
||||||
|
Stored as plain strings (not a DB enum) so adding a provider never
|
||||||
|
requires a schema migration — mirrors ChannelType.
|
||||||
|
"""
|
||||||
|
HUBSPOT = "hubspot"
|
||||||
|
PIPEDRIVE = "pipedrive"
|
||||||
|
|
||||||
|
|
||||||
|
class CrmConnectionStatus(_ValueStrEnum):
|
||||||
|
ACTIVE = "active"
|
||||||
|
EXPIRED = "expired" # refresh token dead — needs a reconnect
|
||||||
|
REVOKED = "revoked" # uninstalled from the CRM side
|
||||||
|
|
||||||
|
|
||||||
|
class CrmSyncJobStatus(_ValueStrEnum):
|
||||||
|
PENDING = "pending"
|
||||||
|
PROCESSING = "processing"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
SKIPPED = "skipped" # intentionally not pushed (no email, plan downgrade, uninstall)
|
||||||
|
|
||||||
|
|
||||||
|
class CrmConnection(Base):
|
||||||
|
"""An organization's OAuth connection to a CRM (one per provider per org).
|
||||||
|
|
||||||
|
All provider secrets (access/refresh tokens, scopes, per-tenant API domain)
|
||||||
|
live together in `encrypted_credentials` as a Fernet-encrypted JSON blob so
|
||||||
|
new providers never need schema changes. Token expiries are mirrored into
|
||||||
|
plain columns so background sweeps can query without decrypting.
|
||||||
|
"""
|
||||||
|
__tablename__ = "crm_connections"
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('organization_id', 'provider', name='uq_crm_connection_org_provider'),
|
||||||
|
UniqueConstraint('provider', 'external_account_id', name='uq_crm_connection_external'),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
organization_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
provider = Column(String(32), nullable=False, index=True)
|
||||||
|
# Platform-side identifier: HubSpot hub_id, Pipedrive company_id, ...
|
||||||
|
external_account_id = Column(String, nullable=False)
|
||||||
|
# Human-readable label shown in settings: portal / company name
|
||||||
|
display_name = Column(String, nullable=True)
|
||||||
|
# Fernet-encrypted JSON of provider secrets (tokens, scopes, api_domain)
|
||||||
|
encrypted_credentials = Column(Text, nullable=False)
|
||||||
|
status = Column(String(16), nullable=False, default=CrmConnectionStatus.ACTIVE.value,
|
||||||
|
server_default=CrmConnectionStatus.ACTIVE.value)
|
||||||
|
last_error = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
access_token_expires_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
# Pipedrive refresh tokens have a 60-day sliding expiry; NULL for HubSpot.
|
||||||
|
refresh_token_expires_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
last_refreshed_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
connected_by_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
organization = relationship("Organization")
|
||||||
|
|
||||||
|
|
||||||
|
class CrmCustomerSync(Base):
|
||||||
|
"""The current CRM link for a person (one row per customer + provider).
|
||||||
|
|
||||||
|
Written on every successful push — auto (lead capture) and manual ("Sync
|
||||||
|
now" on the People drawer) — so the People page can show whether and where
|
||||||
|
a person is synced without walking the job history.
|
||||||
|
"""
|
||||||
|
__tablename__ = "crm_customer_syncs"
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('customer_id', 'provider', name='uq_crm_customer_sync_customer_provider'),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
organization_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
customer_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("customers.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
provider = Column(String(32), nullable=False)
|
||||||
|
contact_id = Column(String, nullable=True) # HubSpot contact / Pipedrive person
|
||||||
|
secondary_id = Column(String, nullable=True) # Pipedrive lead id
|
||||||
|
record_url = Column(String, nullable=True) # deep link into the CRM
|
||||||
|
synced_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class CrmSyncJob(Base):
|
||||||
|
"""Queue row for pushing one captured lead to one CRM.
|
||||||
|
|
||||||
|
The (lead_response_id, provider) unique constraint is the idempotency
|
||||||
|
backbone: enqueue uses ON CONFLICT DO NOTHING, so double-invocation and
|
||||||
|
worker replays can never produce a second push for the same lead.
|
||||||
|
"""
|
||||||
|
__tablename__ = "crm_sync_jobs"
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('lead_response_id', 'provider', name='uq_crm_sync_job_lead_provider'),
|
||||||
|
Index('ix_crm_sync_jobs_claim', 'status', 'next_attempt_at'),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
organization_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
lead_response_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("lead_capture_responses.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
# UI context only; the push itself resolves everything from the lead response.
|
||||||
|
agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
provider = Column(String(32), nullable=False)
|
||||||
|
# Resolved at process time (not enqueue) so a reconnect between capture and
|
||||||
|
# push still delivers; stamped for audit once the job runs.
|
||||||
|
connection_id = Column(UUID(as_uuid=True), ForeignKey("crm_connections.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
|
||||||
|
status = Column(String(16), nullable=False, default=CrmSyncJobStatus.PENDING.value,
|
||||||
|
server_default=CrmSyncJobStatus.PENDING.value)
|
||||||
|
attempts = Column(Integer, nullable=False, default=0, server_default='0')
|
||||||
|
next_attempt_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
# Set on claim; stale-claim recovery resets rows stuck in processing.
|
||||||
|
started_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
last_error = Column(Text, nullable=True)
|
||||||
|
# {"action": "created|updated", "contact_id": ..., "secondary_id": ..., "record_url": ...}
|
||||||
|
result = Column(JSON, nullable=True)
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
lead_response = relationship("LeadCaptureResponse")
|
||||||
|
connection = relationship("CrmConnection")
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Index, JSON, Enum as SQLEnum, func, text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class LeadStage(str, enum.Enum):
|
||||||
|
"""Lifecycle stage of a person. Visitor -> Lead is automatic on a qualifying
|
||||||
|
capture; Lead -> Customer is a manual transition (phase 1)."""
|
||||||
|
VISITOR = "visitor"
|
||||||
|
LEAD = "lead"
|
||||||
|
CUSTOMER = "customer"
|
||||||
|
|
||||||
|
|
||||||
|
class Customer(Base):
|
||||||
|
__tablename__ = "customers"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('email', 'organization_id', name='uix_customer_email_org'),
|
||||||
|
# Phone is the second identity key: unique per org, but only where
|
||||||
|
# present (rows without a phone are the overwhelming majority and must
|
||||||
|
# never collide). Declared here — not only in the migration — so
|
||||||
|
# autogenerate never sees it as unknown and drops it, and so the
|
||||||
|
# sqlite test schema enforces the same uniqueness Postgres does
|
||||||
|
# (sqlite_where works, and both dialects treat NULLs as distinct).
|
||||||
|
Index('uix_customer_phone_org', 'organization_id', 'phone', unique=True,
|
||||||
|
postgresql_where=text('phone IS NOT NULL'),
|
||||||
|
sqlite_where=text('phone IS NOT NULL')),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
email = Column(String, nullable=False)
|
||||||
|
full_name = Column(String)
|
||||||
|
# Second identity key beside email, normalized E.164 with leading '+'
|
||||||
|
# (see app/utils/phone.py). Set by phone-bearing channels (WhatsApp, SMS,
|
||||||
|
# Telegram share-contact) and by lead capture; uniqueness via the partial
|
||||||
|
# index in __table_args__. Name is never an identity key.
|
||||||
|
phone = Column(String, nullable=True)
|
||||||
|
# Arbitrary integrator-supplied fields (e.g. student_name, center_name) passed via
|
||||||
|
# POST /generate-token's `custom_data` and surfaced to agents in the chat inbox.
|
||||||
|
meta_data = Column(JSON, nullable=True)
|
||||||
|
# Widget conversation key of the browser that created this customer (see
|
||||||
|
# conversation_token.conversation_scope). A widget token the embedding app
|
||||||
|
# did not vouch for may only edit or merge a customer its own scope created.
|
||||||
|
conversation_key = Column(String(64), nullable=True)
|
||||||
|
# True when the embedding app identified this person via POST /generate-token with a
|
||||||
|
# known identity (customer_email) — an existing/authenticated customer of the business,
|
||||||
|
# not an organic lead the agent captured. Used to exclude them from the People/leads
|
||||||
|
# views. Deliberately an explicit flag, NOT inferred from meta_data: meta_data can be
|
||||||
|
# absent for an authenticated user and present for an anonymous one, and SQLAlchemy
|
||||||
|
# persists an empty JSON value as JSON null (not SQL NULL), so `meta_data IS NULL`
|
||||||
|
# never matched reliably.
|
||||||
|
is_authenticated = Column(Boolean, default=False, server_default="false", nullable=False)
|
||||||
|
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
# Lead lifecycle — denormalized onto the customer so the People page can filter/aggregate
|
||||||
|
# by stage without joining lead_capture_responses on every list request.
|
||||||
|
lead_stage = Column(SQLEnum(LeadStage), default=LeadStage.VISITOR, server_default="VISITOR", nullable=False)
|
||||||
|
lead_source = Column(JSON, nullable=True) # {"page_url": ..., "channel": "widget", "captured_at": iso}
|
||||||
|
lead_qualified_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
# Set when this (anonymous) customer was merged into an existing identified customer —
|
||||||
|
# e.g. the visitor gave an email that already belongs to another record. Merged rows are
|
||||||
|
# hidden from the People page; old device tokens carrying this id resolve to the target.
|
||||||
|
merged_into_customer_id = Column(UUID(as_uuid=True), ForeignKey("customers.id"), nullable=True)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(),
|
||||||
|
onupdate=func.now())
|
||||||
|
|
||||||
|
# Define relationships
|
||||||
|
organization = relationship("Organization", back_populates="customers")
|
||||||
|
chat_histories = relationship("ChatHistory", back_populates="customer")
|
||||||
|
session_assignments = relationship("SessionToAgent", back_populates="customer")
|
||||||
|
ratings = relationship("Rating", back_populates="customer")
|
||||||
|
lead_capture_responses = relationship(
|
||||||
|
"LeadCaptureResponse", back_populates="customer", cascade="all, delete-orphan")
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, ForeignKey, Index, Integer, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
DEFAULT_FAQ_CATEGORY = "General"
|
||||||
|
# Column widths, kept here so the API schemas bound input to exactly what the
|
||||||
|
# columns accept instead of restating the numbers (see models.schemas.faq).
|
||||||
|
FAQ_SLUG_MAX_LENGTH = 80
|
||||||
|
FAQ_META_TITLE_MAX_LENGTH = 120
|
||||||
|
FAQ_META_DESCRIPTION_MAX_LENGTH = 300
|
||||||
|
FAQ_URL_PATH_MAX_LENGTH = 400
|
||||||
|
FAQ_SOURCE_URL_MAX_LENGTH = 2048
|
||||||
|
|
||||||
|
|
||||||
|
class FAQStatus(str, enum.Enum):
|
||||||
|
DRAFT = "draft"
|
||||||
|
PUBLISHED = "published"
|
||||||
|
|
||||||
|
|
||||||
|
class FAQ(Base):
|
||||||
|
"""A single question/answer pair shown on the org's public help center.
|
||||||
|
|
||||||
|
FAQs are a standalone projection of the knowledge base: generation only
|
||||||
|
reads knowledge content, and editing/deleting FAQs never touches the
|
||||||
|
knowledge tables. Drafts stay private until explicitly published.
|
||||||
|
"""
|
||||||
|
__tablename__ = "faqs"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
# No single-column index: both composite indexes below lead with org id.
|
||||||
|
organization_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
question = Column(Text, nullable=False)
|
||||||
|
# Answer is authored/stored as Markdown; rendered to sanitized HTML on the
|
||||||
|
# public article page (app.services.help_center_content).
|
||||||
|
answer = Column(Text, nullable=False)
|
||||||
|
# URL slug for the public article page (/a/{slug}). Assigned from the
|
||||||
|
# question at creation and kept stable so published article URLs don't churn
|
||||||
|
# on edits — but editable by hand for SEO (app.api.help_center.faqs).
|
||||||
|
# Unique per org (see ix_faqs_org_slug); NULL until assigned.
|
||||||
|
slug = Column(String(FAQ_SLUG_MAX_LENGTH), nullable=True)
|
||||||
|
# The original URL path of an article migrated in from another help center.
|
||||||
|
# When set, the article is SERVED at this exact path and /a/{slug} 301s to
|
||||||
|
# it, so the org keeps the URLs it already ranks for instead of losing that
|
||||||
|
# SEO to a migration. Root-relative, no trailing slash, and percent-DECODED
|
||||||
|
# because that is the form the ASGI scope hands us to match against (every
|
||||||
|
# emitted URL re-encodes it — see help_center_seo.article_url).
|
||||||
|
# Unique per org (see ix_faqs_org_url_path); NULL means "served at /a/{slug}",
|
||||||
|
# which is every article that wasn't migrated.
|
||||||
|
url_path = Column(String(FAQ_URL_PATH_MAX_LENGTH), nullable=True)
|
||||||
|
# Where an imported article was fetched from, post-redirect. Provenance only
|
||||||
|
# — never routed on — and kept even when url_path had to be dropped, so the
|
||||||
|
# original URL is still recoverable.
|
||||||
|
source_url = Column(String(FAQ_SOURCE_URL_MAX_LENGTH), nullable=True)
|
||||||
|
# Per-article SEO overrides for the public page's <title> and meta
|
||||||
|
# description. NULL means "derive from the content": the question and an
|
||||||
|
# excerpt of the answer (see app.api.help_center_public). Lengths mirror
|
||||||
|
# help_center_settings.title/description.
|
||||||
|
meta_title = Column(String(FAQ_META_TITLE_MAX_LENGTH), nullable=True)
|
||||||
|
meta_description = Column(String(FAQ_META_DESCRIPTION_MAX_LENGTH), nullable=True)
|
||||||
|
# Free-form category assigned by the LLM grouping step (or the user).
|
||||||
|
category = Column(String(100), nullable=False, default=DEFAULT_FAQ_CATEGORY)
|
||||||
|
# Plain string (values from FAQStatus), like knowledge_queue.status — new
|
||||||
|
# states never need an ALTER TYPE migration.
|
||||||
|
status = Column(String, nullable=False, default=FAQStatus.DRAFT)
|
||||||
|
# Provenance: the knowledge source it was generated from (if any) plus a
|
||||||
|
# human-readable label ("example.com/docs", "Imported from support.x.com",
|
||||||
|
# "Added manually") that survives source deletion.
|
||||||
|
knowledge_id = Column(Integer, ForeignKey("knowledge.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
source_label = Column(String(255), nullable=True)
|
||||||
|
generation_job_id = Column(
|
||||||
|
Integer, ForeignKey("faq_generation_jobs.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
sort_order = Column(Integer, nullable=False, default=0)
|
||||||
|
# "Was this helpful?" tallies (de-duplicated per IP/day at the API).
|
||||||
|
helpful_yes = Column(Integer, nullable=False, default=0, server_default="0")
|
||||||
|
helpful_no = Column(Integer, nullable=False, default=0, server_default="0")
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
organization = relationship("Organization")
|
||||||
|
knowledge = relationship("Knowledge")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_faqs_org_status", "organization_id", "status"),
|
||||||
|
Index("ix_faqs_org_category", "organization_id", "category"),
|
||||||
|
# Serves the public /a/{slug} lookup and enforces per-org uniqueness.
|
||||||
|
# Multiple NULL slugs coexist (Postgres treats NULLs as distinct).
|
||||||
|
Index("ix_faqs_org_slug", "organization_id", "slug", unique=True),
|
||||||
|
# Serves the preserved-path lookup made for any URL no other public
|
||||||
|
# route claimed, and enforces per-org uniqueness. NULLs coexist.
|
||||||
|
Index("ix_faqs_org_url_path", "organization_id", "url_path", unique=True),
|
||||||
|
)
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, ForeignKey, Index, Integer, String
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.sql import expression, func
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class FAQJobType(str, enum.Enum):
|
||||||
|
# Generate from every knowledge source in the org (the "Generate" button).
|
||||||
|
GENERATE_ALL = "generate_all"
|
||||||
|
# Generate from one newly processed source (the auto-generation hook).
|
||||||
|
GENERATE_SOURCE = "generate_source"
|
||||||
|
# Extract Q&A pairs from an external FAQ page URL (migration).
|
||||||
|
IMPORT_URL = "import_url"
|
||||||
|
# Crawl an external help-center index and import linked articles as-is
|
||||||
|
# (HTML→Markdown, no LLM).
|
||||||
|
IMPORT_ARTICLES = "import_articles"
|
||||||
|
# Extract Q&A pairs from an uploaded PDF.
|
||||||
|
IMPORT_PDF = "import_pdf"
|
||||||
|
|
||||||
|
|
||||||
|
class FAQJobStatus(str, enum.Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
PROCESSING = "processing"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class FAQJobStage(str, enum.Enum):
|
||||||
|
NOT_STARTED = "not_started"
|
||||||
|
ANALYZING_SOURCES = "analyzing_sources"
|
||||||
|
EXTRACTING = "extracting"
|
||||||
|
DRAFTING = "drafting"
|
||||||
|
GROUPING = "grouping"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
|
||||||
|
|
||||||
|
# Statuses that count as "still running" for enqueue guards and UI polling —
|
||||||
|
# derived so a future status defaults to "active" rather than silently
|
||||||
|
# escaping the duplicate-job guard.
|
||||||
|
TERMINAL_FAQ_JOB_STATUSES = {FAQJobStatus.COMPLETED, FAQJobStatus.FAILED}
|
||||||
|
ACTIVE_FAQ_JOB_STATUSES = [s.value for s in FAQJobStatus if s not in TERMINAL_FAQ_JOB_STATUSES]
|
||||||
|
|
||||||
|
|
||||||
|
class FAQGenerationJob(Base):
|
||||||
|
"""Async FAQ generation/import job, processed by the FAQ worker.
|
||||||
|
|
||||||
|
Mirrors the KnowledgeQueue shape (string status/stage + float progress) so
|
||||||
|
the established worker and frontend polling patterns carry over.
|
||||||
|
"""
|
||||||
|
__tablename__ = "faq_generation_jobs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
organization_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
# Nullable: auto-generation jobs may be enqueued without an acting user.
|
||||||
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
job_type = Column(String, nullable=False, default=FAQJobType.GENERATE_ALL)
|
||||||
|
# GENERATE_SOURCE: the knowledge source to read. NULL for GENERATE_ALL/
|
||||||
|
# IMPORT_URL. SET NULL (not CASCADE) so deleting a source mid-run doesn't
|
||||||
|
# yank the job row out from under the worker and the UI's polling.
|
||||||
|
knowledge_id = Column(Integer, ForeignKey("knowledge.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
# GENERATE_ALL: optional explicit narrowing to specific knowledge sources
|
||||||
|
# (overrides the default skip-already-generated behaviour).
|
||||||
|
knowledge_ids = Column(JSON, nullable=True)
|
||||||
|
# IMPORT_URL: the external page to extract from. IMPORT_PDF: the stored
|
||||||
|
# upload path/URL the worker reads the file from.
|
||||||
|
source_url = Column(String, nullable=True)
|
||||||
|
# IMPORT_PDF: the original client filename, used for source_label.
|
||||||
|
source_file_name = Column(String(255), nullable=True)
|
||||||
|
# IMPORT_ARTICLES: keep every imported article at the URL path it already
|
||||||
|
# has on the source help center (FAQ.url_path), so migrating in doesn't
|
||||||
|
# move — and cost the org — the URLs it already ranks for.
|
||||||
|
preserve_source_urls = Column(
|
||||||
|
Boolean, nullable=False, default=False, server_default=expression.false()
|
||||||
|
)
|
||||||
|
# LLM calls attempted by this job (retries included) — the usage unit for
|
||||||
|
# hosted-model metering. `metered` is stamped at enqueue time from the
|
||||||
|
# org's AI config so the period sum never depends on later config changes.
|
||||||
|
llm_calls = Column(Integer, nullable=False, default=0, server_default="0")
|
||||||
|
metered = Column(Boolean, nullable=False, default=True, server_default=expression.true())
|
||||||
|
status = Column(String, nullable=False, default=FAQJobStatus.PENDING)
|
||||||
|
stage = Column(String, nullable=False, default=FAQJobStage.NOT_STARTED)
|
||||||
|
progress_percentage = Column(Float, nullable=False, default=0.0)
|
||||||
|
faqs_created = Column(Integer, nullable=False, default=0)
|
||||||
|
error = Column(String, nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
# Partial index keeping the worker's poll O(active rows).
|
||||||
|
Index(
|
||||||
|
"ix_faq_generation_jobs_active",
|
||||||
|
"status",
|
||||||
|
postgresql_where=(status.in_(ACTIVE_FAQ_JOB_STATUSES)),
|
||||||
|
),
|
||||||
|
# NOTE: production also has uq_faq_generation_jobs_one_active — a
|
||||||
|
# UNIQUE partial index on (organization_id, job_type,
|
||||||
|
# COALESCE(knowledge_id, -1)) WHERE status is active, closing the
|
||||||
|
# enqueue check-then-insert race. It lives only in the migration:
|
||||||
|
# declaring it here would create it WITHOUT the partial WHERE on
|
||||||
|
# sqlite (tests), wrongly blocking re-enqueues after completion.
|
||||||
|
)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, String, DateTime, ForeignKey, func
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class FCMToken(Base):
|
||||||
|
"""One web-push token per signed-in browser.
|
||||||
|
|
||||||
|
Replaces the old single `users.fcm_token_web` column, which held one token
|
||||||
|
for the whole account: signing in on a laptop overwrote the phone's token
|
||||||
|
and signing out anywhere cleared it for every device, so the phone silently
|
||||||
|
stopped receiving pushes.
|
||||||
|
|
||||||
|
The token is minted by the browser, not by us, and FCM returns the *same*
|
||||||
|
value for a given browser install regardless of who is signed in — hence the
|
||||||
|
unique constraint plus reassign-on-register in the repository, so a shared
|
||||||
|
browser moves its token to the new owner instead of delivering that user's
|
||||||
|
notifications to the previous one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "fcm_tokens"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
token = Column(String, nullable=False, unique=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="fcm_tokens")
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, String, BigInteger, ForeignKey, TIMESTAMP, func, UUID
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class FileAttachment(Base):
|
||||||
|
__tablename__ = "file_attachments"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
file_url = Column(String, nullable=False) # AWS S3 URL or local path
|
||||||
|
filename = Column(String, nullable=False) # Original filename
|
||||||
|
content_type = Column(String, nullable=False) # MIME type
|
||||||
|
file_size = Column(BigInteger, nullable=False) # File size in bytes
|
||||||
|
|
||||||
|
# Links to chat history message
|
||||||
|
chat_history_id = Column(Integer, ForeignKey("chat_history.id", ondelete="CASCADE"), nullable=True)
|
||||||
|
|
||||||
|
# Additional metadata
|
||||||
|
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
uploaded_by_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
uploaded_by_customer_id = Column(UUID(as_uuid=True), ForeignKey("customers.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
|
||||||
|
created_at = Column(TIMESTAMP(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(TIMESTAMP(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
chat_history = relationship("ChatHistory", backref="attachments")
|
||||||
|
organization = relationship("Organization")
|
||||||
|
uploaded_by_user = relationship("User", foreign_keys=[uploaded_by_user_id])
|
||||||
|
uploaded_by_customer = relationship("Customer", foreign_keys=[uploaded_by_customer_id])
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
|
TIMESTAMP,
|
||||||
|
Boolean,
|
||||||
|
Column,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
UUID,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.core.encryption import EncryptedText
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class GuardrailEvent(Base):
|
||||||
|
"""One row per guardrail trigger across every surface (widget, channels,
|
||||||
|
workflow, help center).
|
||||||
|
|
||||||
|
Append-only: this is the record that answers "is off-topic abuse actually
|
||||||
|
material?" and "is a rule wrongly refusing real customers?" — the review
|
||||||
|
gate before any detection rule is promoted from counting to blocking.
|
||||||
|
|
||||||
|
`matched` holds rule ids / short match tokens ONLY — the column is plain
|
||||||
|
JSON (unencrypted), so raw visitor text must never land in it. The
|
||||||
|
reviewable text lives in `excerpt`, which is encrypted at rest exactly
|
||||||
|
like ChatHistory.message.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "guardrail_events"
|
||||||
|
__table_args__ = (
|
||||||
|
# Declared here as well as in the migration so autogenerate never
|
||||||
|
# proposes dropping them and the sqlite test schema matches Postgres.
|
||||||
|
Index("ix_guardrail_events_org_created", "organization_id", "created_at"),
|
||||||
|
Index("ix_guardrail_events_rule_created", "rule", "created_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
organization_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
agent_id = Column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("agents.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
# No FK: a help-center /ask event has no chat session at all.
|
||||||
|
session_id = Column(UUID(as_uuid=True), nullable=True)
|
||||||
|
# widget | channel | workflow | help_center
|
||||||
|
surface = Column(String(20), nullable=False)
|
||||||
|
# inbound | output
|
||||||
|
layer = Column(String(16), nullable=False)
|
||||||
|
# Stable rule id, e.g. injection.frame_tokens, offtopic.model_refused
|
||||||
|
rule = Column(String(64), nullable=False)
|
||||||
|
# blocked | counted | replaced
|
||||||
|
action = Column(String(16), nullable=False)
|
||||||
|
score = Column(Float, nullable=True)
|
||||||
|
# Rule ids / short match tokens only — NEVER raw visitor text (plain JSON).
|
||||||
|
matched = Column(JSON, default=None, nullable=True)
|
||||||
|
# Input length so wasted-token cost is estimable (~ char_len / 4).
|
||||||
|
char_len = Column(Integer, nullable=True)
|
||||||
|
# First 300 chars of the offending text, encrypted at rest; gated by
|
||||||
|
# GUARDRAIL_STORE_EXCERPT.
|
||||||
|
excerpt = Column(EncryptedText, nullable=True)
|
||||||
|
# Review workflow: precision denominator for the strict-mode decision.
|
||||||
|
reviewed = Column(Boolean, default=False, nullable=False)
|
||||||
|
false_positive = Column(Boolean, default=False, nullable=False)
|
||||||
|
created_at = Column(TIMESTAMP(timezone=True), server_default=func.now())
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, CheckConstraint, Column, DateTime, ForeignKey, JSON, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
DEFAULT_BRAND_COLOR = "#4338CA"
|
||||||
|
DEFAULT_CTA_TEXT = "Contact us"
|
||||||
|
|
||||||
|
|
||||||
|
class DomainStatus(str, enum.Enum):
|
||||||
|
UNVERIFIED = "unverified"
|
||||||
|
PENDING = "pending"
|
||||||
|
VERIFIED = "verified"
|
||||||
|
|
||||||
|
|
||||||
|
class SSLStatus(str, enum.Enum):
|
||||||
|
NONE = "none"
|
||||||
|
PENDING = "pending"
|
||||||
|
ACTIVE = "active"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class HelpCenterSettings(Base):
|
||||||
|
"""Per-org public help center configuration (branding, agent mapping,
|
||||||
|
subdomain slug and optional custom domain). One row per organization,
|
||||||
|
created lazily the first time the org opens the FAQ admin page.
|
||||||
|
"""
|
||||||
|
__tablename__ = "help_center_settings"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
organization_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
# Whether the public site is live at {slug}.{HELP_CENTER_BASE_DOMAIN}.
|
||||||
|
enabled = Column(Boolean, nullable=False, default=False)
|
||||||
|
slug = Column(String(63), unique=True, nullable=True, index=True)
|
||||||
|
|
||||||
|
# Branding
|
||||||
|
title = Column(String(120), nullable=True)
|
||||||
|
description = Column(String(300), nullable=True)
|
||||||
|
logo_url = Column(String, nullable=True)
|
||||||
|
favicon_url = Column(String, nullable=True)
|
||||||
|
brand_color = Column(String(9), nullable=False, default=DEFAULT_BRAND_COLOR)
|
||||||
|
# [{"label": str, "url": str}, ...]
|
||||||
|
header_links = Column(JSON, nullable=False, default=lambda: [])
|
||||||
|
cta_text = Column(String(40), nullable=True, default=DEFAULT_CTA_TEXT)
|
||||||
|
cta_url = Column(String, nullable=True)
|
||||||
|
# Whether the primary button (CTA) is shown in the public header.
|
||||||
|
cta_enabled = Column(Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
# Draft FAQs are auto-generated when a new knowledge source finishes.
|
||||||
|
auto_generate = Column(Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
# Which agent grounds "Ask AI" answers and whose chat widget is embedded on
|
||||||
|
# the public page. The two surfaces toggle independently:
|
||||||
|
# - ai_search_enabled: the inline AI quick-summary answer in search.
|
||||||
|
# - chat_widget_enabled: the embedded chat widget ("Start a chat").
|
||||||
|
agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
ai_search_enabled = Column(Boolean, nullable=False, default=True)
|
||||||
|
chat_widget_enabled = Column(Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
# Custom domain. Verification state is the two per-record booleans; the
|
||||||
|
# aggregate status is derived (see domain_status) so the pieces can never
|
||||||
|
# disagree.
|
||||||
|
custom_domain = Column(String(255), unique=True, nullable=True, index=True)
|
||||||
|
domain_verification_token = Column(String(64), nullable=True)
|
||||||
|
txt_record_verified = Column(Boolean, nullable=False, default=False)
|
||||||
|
cname_record_verified = Column(Boolean, nullable=False, default=False)
|
||||||
|
# Plain string (values from SSLStatus) — an external fact set by the
|
||||||
|
# verification probe, stored like knowledge_queue's string statuses.
|
||||||
|
ssl_status = Column(String, nullable=False, default=SSLStatus.NONE)
|
||||||
|
domain_verified_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
organization = relationship("Organization")
|
||||||
|
agent = relationship("Agent")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint("slug = lower(slug)", name="ck_help_center_slug_lowercase"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def domain_verified(self) -> bool:
|
||||||
|
return bool(self.custom_domain) and self.txt_record_verified and self.cname_record_verified
|
||||||
|
|
||||||
|
@property
|
||||||
|
def domain_status(self) -> DomainStatus:
|
||||||
|
if not self.custom_domain:
|
||||||
|
return DomainStatus.UNVERIFIED
|
||||||
|
return DomainStatus.VERIFIED if self.domain_verified else DomainStatus.PENDING
|
||||||
|
|
||||||
|
|
||||||
|
class HelpCenterQuery(Base):
|
||||||
|
"""Log of public "Ask AI" questions — used for per-period metering on
|
||||||
|
hosted models and (later) search analytics. No answer text is stored.
|
||||||
|
"""
|
||||||
|
__tablename__ = "help_center_queries"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
organization_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
query = Column(Text, nullable=False)
|
||||||
|
answered = Column(Boolean, nullable=False, default=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT 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).
|
||||||
|
ReplyPier never executes infrastructure changes — approval records the
|
||||||
|
decision, humans/runbooks perform any mutation."""
|
||||||
|
__tablename__ = "ticket_proposals"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
ticket_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("tickets.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
organization_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
run_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("investigation_runs.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
# Internal proposal: what the AI believes resolves the ticket and why.
|
||||||
|
summary = Column(Text, nullable=False)
|
||||||
|
# Plain-language message sent to the customer on approval.
|
||||||
|
customer_message = Column(Text, nullable=True)
|
||||||
|
confidence = Column(Float, nullable=True)
|
||||||
|
status = Column(String, nullable=False, default=ProposalStatus.PENDING)
|
||||||
|
decided_by_user_id = Column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
decided_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
reject_reason = Column(Text, nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
decided_by = relationship("User", foreign_keys=[decided_by_user_id])
|
||||||
|
|
||||||
|
|
||||||
|
class RCADocument(Base):
|
||||||
|
"""Structured, versioned root-cause analysis generated from captured
|
||||||
|
evidence. customer_summary is the plain-language section a human edits and
|
||||||
|
sends; reviewed_by_user_id powers the "reviewed by" byline."""
|
||||||
|
__tablename__ = "rca_documents"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
ticket_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("tickets.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
run_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("investigation_runs.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
version = Column(Integer, nullable=False, default=1)
|
||||||
|
|
||||||
|
summary = Column(Text, nullable=True)
|
||||||
|
impact = Column(Text, nullable=True)
|
||||||
|
# [{"time": "...", "event": "..."}] — ISO timestamps or relative labels.
|
||||||
|
timeline = Column(JSON, nullable=True)
|
||||||
|
investigation_log = Column(Text, nullable=True)
|
||||||
|
# ["...", ...]
|
||||||
|
contributing_factors = Column(JSON, nullable=True)
|
||||||
|
# Cites hypotheses/evidence inline as [H1 · 0.92].
|
||||||
|
conclusion = Column(Text, nullable=True)
|
||||||
|
remediation = Column(Text, nullable=True)
|
||||||
|
prevention = Column(Text, nullable=True)
|
||||||
|
customer_summary = Column(Text, nullable=True)
|
||||||
|
confidence = Column(Float, nullable=True)
|
||||||
|
# Set when the investigation ended early (wall-clock/tool budget).
|
||||||
|
is_partial = Column(Boolean, nullable=False, default=False, server_default=expression.false())
|
||||||
|
|
||||||
|
generated_by = Column(String, nullable=False, default="ai", server_default="ai")
|
||||||
|
reviewed_by_user_id = Column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
reviewed_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
reviewed_by = relationship("User", foreign_keys=[reviewed_by_user_id])
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("ticket_id", "version", name="uq_rca_documents_ticket_version"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
class JiraToken(Base):
|
||||||
|
"""Model for storing Jira OAuth tokens."""
|
||||||
|
__tablename__ = "jira_tokens"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False)
|
||||||
|
access_token = Column(String, nullable=False)
|
||||||
|
refresh_token = Column(String, nullable=False)
|
||||||
|
token_type = Column(String, nullable=False)
|
||||||
|
expires_at = Column(DateTime, nullable=False)
|
||||||
|
cloud_id = Column(String, nullable=False) # Jira Cloud instance ID
|
||||||
|
site_url = Column(String, nullable=False) # Jira instance URL
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationship
|
||||||
|
organization = relationship("Organization", back_populates="jira_tokens")
|
||||||
|
|
||||||
|
class AgentJiraConfig(Base):
|
||||||
|
"""Model for storing agent-to-Jira configuration."""
|
||||||
|
__tablename__ = "agent_jira_configs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
agent_id = Column(String, nullable=False, index=True)
|
||||||
|
enabled = Column(Boolean, default=False, nullable=False)
|
||||||
|
project_key = Column(String, nullable=True)
|
||||||
|
issue_type_id = Column(String, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, func, Enum as SQLEnum
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.database import Base
|
||||||
|
import enum
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
class SourceType(str, enum.Enum):
|
||||||
|
DATABASE = "database"
|
||||||
|
FILE = "file"
|
||||||
|
API = "api"
|
||||||
|
WEBSITE = "website"
|
||||||
|
CUSTOM = "custom"
|
||||||
|
|
||||||
|
|
||||||
|
class Knowledge(Base):
|
||||||
|
__tablename__ = "knowledge"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
# URL, file path, or connection string
|
||||||
|
source = Column(String, nullable=False)
|
||||||
|
source_type = Column(SQLEnum(SourceType), nullable=False)
|
||||||
|
schema = Column(String) # JSON schema of the data structure
|
||||||
|
table_name = Column(String)
|
||||||
|
organization_id = Column(UUID(as_uuid=True), ForeignKey(
|
||||||
|
"organizations.id"), nullable=False)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(),
|
||||||
|
onupdate=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
organization = relationship(
|
||||||
|
"Organization", back_populates="knowledge_sources")
|
||||||
|
agent_links = relationship(
|
||||||
|
"KnowledgeToAgent", back_populates="knowledge", cascade="all, delete-orphan")
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, String, JSON, DateTime, Enum, ForeignKey, Float, Index
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.database import Base
|
||||||
|
import enum
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
class QueueStatus(str, enum.Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
PROCESSING = "processing"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
class ProcessingStage(str, enum.Enum):
|
||||||
|
NOT_STARTED = "not_started"
|
||||||
|
CRAWLING = "crawling"
|
||||||
|
EMBEDDING = "embedding"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeQueue(Base):
|
||||||
|
__tablename__ = "knowledge_queue"
|
||||||
|
# The plan's source limit counts the crawls still in flight, so this is
|
||||||
|
# read on every add.
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_knowledge_queue_org_status", "organization_id", "status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
organization_id = Column(UUID(as_uuid=True), nullable=False)
|
||||||
|
agent_id = Column(UUID(as_uuid=True), nullable=True)
|
||||||
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) # Nullable for Shopify session token auth
|
||||||
|
# 'pdf_file', 'pdf_url', 'website'
|
||||||
|
source_type = Column(String, nullable=False)
|
||||||
|
source = Column(String, nullable=False) # File path or URL
|
||||||
|
status = Column(String, default=QueueStatus.PENDING)
|
||||||
|
error = Column(String, nullable=True)
|
||||||
|
# Renamed from metadata to queue_metadata
|
||||||
|
queue_metadata = Column(JSON, nullable=True)
|
||||||
|
# Priority: higher number = higher priority (default 0, explore URLs get 10)
|
||||||
|
priority = Column(Integer, default=0, index=True)
|
||||||
|
# Progress tracking
|
||||||
|
processing_stage = Column(String, default=ProcessingStage.NOT_STARTED)
|
||||||
|
progress_percentage = Column(Float, default=0.0)
|
||||||
|
total_items = Column(Integer, default=0)
|
||||||
|
processed_items = Column(Integer, default=0)
|
||||||
|
crawled_urls = Column(JSON, default=lambda: [])
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
# Add relationship
|
||||||
|
user = relationship("User", back_populates="knowledge_queue_items")
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, ForeignKey, DateTime, func
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.database import Base
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
class KnowledgeToAgent(Base):
|
||||||
|
__tablename__ = "knowledge_to_agents"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
knowledge_id = Column(Integer, ForeignKey(
|
||||||
|
"knowledge.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
agent_id = Column(UUID(as_uuid=True), ForeignKey(
|
||||||
|
"agents.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(),
|
||||||
|
onupdate=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
knowledge = relationship("Knowledge", back_populates="agent_links")
|
||||||
|
agent = relationship("Agent", back_populates="knowledge_links")
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Column, Integer, String, Boolean, DateTime, ForeignKey,
|
||||||
|
Enum as SQLEnum, JSON, Text, func, false, true,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class LeadAssignmentMode(str, enum.Enum):
|
||||||
|
"""Where a qualified lead is routed. Phase 1: stored only, not acted on."""
|
||||||
|
NONE = "none"
|
||||||
|
SALES_TEAM = "sales_team"
|
||||||
|
SPECIFIC_PERSON = "specific_person"
|
||||||
|
ROUND_ROBIN = "round_robin"
|
||||||
|
|
||||||
|
|
||||||
|
class CrmSyncTarget(str, enum.Enum):
|
||||||
|
"""Which CRM a qualified lead syncs to.
|
||||||
|
|
||||||
|
Stored as a plain string column (not a DB enum) so adding a CRM never
|
||||||
|
requires a schema migration — mirrors ChannelType. str() is the value so
|
||||||
|
in-memory enum instances and refreshed-from-DB strings render identically.
|
||||||
|
"""
|
||||||
|
|
||||||
|
NONE = "none"
|
||||||
|
HUBSPOT = "hubspot"
|
||||||
|
PIPEDRIVE = "pipedrive"
|
||||||
|
SALESFORCE = "salesforce"
|
||||||
|
|
||||||
|
def __str__(self) -> str: # pragma: no cover - trivial
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
|
||||||
|
class LeadCaptureConfig(Base):
|
||||||
|
"""Per-agent lead-capture configuration (1:1 with Agent).
|
||||||
|
|
||||||
|
Prompt-driven, like transfer_to_human: a simple on/off toggle. When enabled, the
|
||||||
|
agent collects the configured fields conversationally (no form) and emits them as
|
||||||
|
structured output plus an AI-written lead summary.
|
||||||
|
"""
|
||||||
|
__tablename__ = "lead_capture_configs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
agent_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("agents.id", ondelete="CASCADE"),
|
||||||
|
unique=True,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
# Master toggle (mirrors Agent.transfer_to_human).
|
||||||
|
enabled = Column(Boolean, default=False, server_default=false(), nullable=False)
|
||||||
|
# GDPR: require the visitor's explicit "yes" before a lead is recorded (default on).
|
||||||
|
require_consent = Column(Boolean, default=True, server_default=true(), nullable=False)
|
||||||
|
|
||||||
|
# Free-text steering appended to the agent's system prompt (e.g. "prioritise
|
||||||
|
# pricing-page visitors, be proactive after hours"). The agent decides *when* to ask.
|
||||||
|
guidance = Column(Text, nullable=True)
|
||||||
|
# Which details to collect — feeds the prompt and defines the expected lead_data keys:
|
||||||
|
# {"key": "email", "standard": true, "required": true, "enabled": true}
|
||||||
|
# {"key": "custom_1", "standard": false, "label": "Team size", "enabled": true}
|
||||||
|
fields = Column(JSON, nullable=True)
|
||||||
|
|
||||||
|
# Routing config — persisted so the shape is ready for real integration later,
|
||||||
|
# but never acted on in phase 1 (no CRM/Slack/assignment side effects).
|
||||||
|
assignment_mode = Column(SQLEnum(LeadAssignmentMode), default=LeadAssignmentMode.NONE, server_default="NONE", nullable=False)
|
||||||
|
assignment_target_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
|
crm_sync_target = Column(String(32), default=CrmSyncTarget.NONE.value, server_default="none", nullable=False)
|
||||||
|
slack_notify_enabled = Column(Boolean, default=False, server_default=false(), nullable=False)
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
agent = relationship("Agent", back_populates="lead_capture_config", uselist=False)
|
||||||
|
|
||||||
|
|
||||||
|
class LeadCaptureResponse(Base):
|
||||||
|
"""One row per captured lead — the fields the agent extracted conversationally,
|
||||||
|
plus an AI-written qualification summary. Kept separate from Customer.meta_data
|
||||||
|
(integrator-supplied) so AI-captured leads have a clean home."""
|
||||||
|
__tablename__ = "lead_capture_responses"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False)
|
||||||
|
agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
customer_id = Column(UUID(as_uuid=True), ForeignKey("customers.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
session_id = Column(UUID(as_uuid=True), ForeignKey("session_to_agents.session_id"), nullable=True)
|
||||||
|
|
||||||
|
field_values = Column(JSON, nullable=True) # {"email": ..., "company": ..., "custom_1": ...}
|
||||||
|
# AI-written qualification narrative (e.g. "40-person fintech replacing Intercom, wants a demo").
|
||||||
|
summary = Column(Text, nullable=True)
|
||||||
|
# Whether the visitor gave explicit consent to be contacted (GDPR).
|
||||||
|
consent = Column(Boolean, default=False, nullable=False)
|
||||||
|
qualified = Column(Boolean, default=False, nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
customer = relationship("Customer", back_populates="lead_capture_responses")
|
||||||
|
agent = relationship("Agent")
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, func, Enum as SQLEnum, Text, Boolean
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.database import Base
|
||||||
|
import enum
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID, JSON
|
||||||
|
|
||||||
|
class MCPTransportType(str, enum.Enum):
|
||||||
|
STDIO = "stdio"
|
||||||
|
SSE = "sse"
|
||||||
|
HTTP = "http"
|
||||||
|
|
||||||
|
|
||||||
|
class MCPTool(Base):
|
||||||
|
__tablename__ = "mcp_tools"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String, nullable=False) # Display name for the MCP server
|
||||||
|
description = Column(Text) # Optional description of what this MCP server does
|
||||||
|
# What this source holds and how to query it. Reaches the model as agno
|
||||||
|
# per-toolkit instructions, in the system prompt — unlike `description`,
|
||||||
|
# which is a preset-populated UI subtitle the model never sees.
|
||||||
|
usage_guidance = Column(Text)
|
||||||
|
transport_type = Column(SQLEnum(MCPTransportType), nullable=False)
|
||||||
|
enabled = Column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
# For STDIO transport
|
||||||
|
command = Column(String) # e.g., "npx", "uvx", "node"
|
||||||
|
args = Column(JSON) # List of arguments, e.g., ["-y", "@modelcontextprotocol/server-filesystem"]
|
||||||
|
env_vars = Column(JSON) # Environment variables as key-value pairs
|
||||||
|
|
||||||
|
# For SSE/HTTP transports
|
||||||
|
url = Column(String) # Server URL
|
||||||
|
headers = Column(JSON) # HTTP headers as key-value pairs
|
||||||
|
timeout = Column(Integer) # Connection timeout in seconds
|
||||||
|
sse_read_timeout = Column(Integer) # SSE read timeout in seconds
|
||||||
|
terminate_on_close = Column(Boolean, default=True) # For HTTP transport
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
organization = relationship("Organization", back_populates="mcp_tools")
|
||||||
|
agent_links = relationship("MCPToolToAgent", back_populates="mcp_tool", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class MCPToolToAgent(Base):
|
||||||
|
__tablename__ = "mcp_tool_to_agent"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
mcp_tool_id = Column(Integer, ForeignKey("mcp_tools.id"), nullable=False)
|
||||||
|
agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id"), nullable=False)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
mcp_tool = relationship("MCPTool", back_populates="agent_links")
|
||||||
|
agent = relationship("Agent", back_populates="mcp_tool_links")
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, String, JSON, DateTime, ForeignKey, Boolean, Enum as SQLEnum
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.database import Base
|
||||||
|
import enum
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
class NotificationType(str, enum.Enum):
|
||||||
|
KNOWLEDGE_PROCESSED = "knowledge_processed"
|
||||||
|
KNOWLEDGE_FAILED = "knowledge_failed"
|
||||||
|
FAQ_GENERATED = "faq_generated"
|
||||||
|
FAQ_GENERATION_FAILED = "faq_generation_failed"
|
||||||
|
SYSTEM = "system"
|
||||||
|
CHAT = "chat"
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(Base):
|
||||||
|
__tablename__ = "notifications"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
type = Column(SQLEnum(NotificationType), nullable=False)
|
||||||
|
title = Column(String, nullable=False)
|
||||||
|
message = Column(String, nullable=False)
|
||||||
|
# For additional data like queue_id, knowledge_id etc
|
||||||
|
notification_metadata = Column(JSON, nullable=True)
|
||||||
|
is_read = Column(Boolean, default=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = relationship("User", back_populates="notifications")
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Column, DateTime, ForeignKey
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import expression, func
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
# Preference column per chat event, and the value assumed for a user who has
|
||||||
|
# no row yet. Transfer/assignment default on so existing users keep the
|
||||||
|
# notifications they already get; new-chat is opt-in because it fires on every
|
||||||
|
# conversation, including the ones the AI handles alone.
|
||||||
|
NOTIFY_NEW_CHAT = "notify_new_chat"
|
||||||
|
NOTIFY_CHAT_TRANSFER = "notify_chat_transfer"
|
||||||
|
NOTIFY_CHAT_ASSIGNED = "notify_chat_assigned"
|
||||||
|
|
||||||
|
NOTIFICATION_PREFERENCE_DEFAULTS = {
|
||||||
|
NOTIFY_NEW_CHAT: False,
|
||||||
|
NOTIFY_CHAT_TRANSFER: True,
|
||||||
|
NOTIFY_CHAT_ASSIGNED: True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class UserNotificationSettings(Base):
|
||||||
|
"""Per-user chat push preferences. One row per user, created lazily with
|
||||||
|
defaults on first read — absence of a row means the defaults above apply.
|
||||||
|
"""
|
||||||
|
__tablename__ = "user_notification_settings"
|
||||||
|
|
||||||
|
user_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
notify_new_chat = Column(Boolean, nullable=False, default=False, server_default=expression.false())
|
||||||
|
notify_chat_transfer = Column(Boolean, nullable=False, default=True, server_default=expression.true())
|
||||||
|
notify_chat_assigned = Column(Boolean, nullable=False, default=True, server_default=expression.true())
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
user = relationship("User")
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime, JSON, func, event
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.database import Base
|
||||||
|
from app.models.role import Role
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
class Organization(Base):
|
||||||
|
__tablename__ = "organizations"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, index=True,default=uuid.uuid4)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
domain = Column(String(100), unique=True, nullable=False)
|
||||||
|
timezone = Column(String(50), nullable=False, default='UTC')
|
||||||
|
business_hours = Column(JSON, default=lambda: {
|
||||||
|
'monday': {'start': '09:00', 'end': '17:00', 'enabled': True},
|
||||||
|
'tuesday': {'start': '09:00', 'end': '17:00', 'enabled': True},
|
||||||
|
'wednesday': {'start': '09:00', 'end': '17:00', 'enabled': True},
|
||||||
|
'thursday': {'start': '09:00', 'end': '17:00', 'enabled': True},
|
||||||
|
'friday': {'start': '09:00', 'end': '17:00', 'enabled': True},
|
||||||
|
'saturday': {'start': '09:00', 'end': '17:00', 'enabled': False},
|
||||||
|
'sunday': {'start': '09:00', 'end': '17:00', 'enabled': False}
|
||||||
|
}) # callable: a fresh dict per row, never a shared mutable default
|
||||||
|
settings = Column(JSON, default=dict) # callable: never a shared mutable default
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(),
|
||||||
|
onupdate=func.now())
|
||||||
|
|
||||||
|
# Define relationships
|
||||||
|
chat_histories = relationship("ChatHistory", back_populates="organization")
|
||||||
|
users = relationship("User", back_populates="organization",
|
||||||
|
cascade="all, delete-orphan")
|
||||||
|
customers = relationship(
|
||||||
|
"Customer", back_populates="organization", cascade="all, delete-orphan")
|
||||||
|
roles = relationship("Role", back_populates="organization",
|
||||||
|
cascade="all, delete-orphan")
|
||||||
|
ai_configs = relationship("AIConfig", back_populates="organization",
|
||||||
|
cascade="all, delete-orphan")
|
||||||
|
agents = relationship("Agent", back_populates="organization")
|
||||||
|
knowledge_sources = relationship(
|
||||||
|
"Knowledge", back_populates="organization")
|
||||||
|
mcp_tools = relationship("MCPTool", back_populates="organization")
|
||||||
|
widgets = relationship("Widget", back_populates="organization")
|
||||||
|
widget_apps = relationship("WidgetApp", back_populates="organization", cascade="all, delete-orphan")
|
||||||
|
groups = relationship("UserGroup", back_populates="organization")
|
||||||
|
workflows = relationship("Workflow", back_populates="organization")
|
||||||
|
|
||||||
|
jira_tokens = relationship("JiraToken", back_populates="organization", cascade="all, delete-orphan")
|
||||||
|
shopify_shops = relationship("ShopifyShop", back_populates="organization", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
orm_mode = True
|
||||||
|
|
||||||
|
# Try to set up enterprise relationships after mapper configuration
|
||||||
|
@event.listens_for(Organization, 'mapper_configured')
|
||||||
|
def setup_enterprise_relationships(mapper, class_):
|
||||||
|
try:
|
||||||
|
from app.enterprise.models.subscription import Subscription
|
||||||
|
from app.enterprise.models.order import PayPalOrder
|
||||||
|
|
||||||
|
if not hasattr(class_, 'subscription'):
|
||||||
|
class_.subscription = relationship(
|
||||||
|
Subscription,
|
||||||
|
back_populates="organization",
|
||||||
|
uselist=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if not hasattr(class_, 'paypal_orders'):
|
||||||
|
class_.paypal_orders = relationship(
|
||||||
|
PayPalOrder,
|
||||||
|
back_populates="organization",
|
||||||
|
cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
logger.info("Enterprise subscription and paypal_orders relationships configured")
|
||||||
|
except ImportError:
|
||||||
|
logger.info("Enterprise module not available - subscription relationship not configured")
|
||||||
|
class_.subscription = None
|
||||||
|
|
||||||
|
# Set up Jira relationships after mapper configuration
|
||||||
|
@event.listens_for(Organization, 'mapper_configured')
|
||||||
|
def setup_jira_relationships(mapper, class_):
|
||||||
|
try:
|
||||||
|
from app.models.jira import JiraToken
|
||||||
|
|
||||||
|
if not hasattr(class_, 'jira_tokens'):
|
||||||
|
class_.jira_tokens = relationship(
|
||||||
|
JiraToken,
|
||||||
|
back_populates="organization",
|
||||||
|
cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
logger.info("Jira tokens relationship configured")
|
||||||
|
except ImportError:
|
||||||
|
logger.info("Jira module not available - jira_tokens relationship not configured")
|
||||||
|
class_.jira_tokens = None
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, String, Table, ForeignKey
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
# Junction table for role-permission many-to-many relationship
|
||||||
|
role_permissions = Table(
|
||||||
|
'role_permissions',
|
||||||
|
Base.metadata,
|
||||||
|
Column('role_id', Integer, ForeignKey('roles.id')),
|
||||||
|
Column('permission_id', Integer, ForeignKey('permissions.id'))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# What a newly seeded "Agent" role can do: work the chats assigned to them, pick
|
||||||
|
# up whatever the AI is still handling, and look up who they are talking to.
|
||||||
|
#
|
||||||
|
# Named here rather than spelled out in each signup handler because there are
|
||||||
|
# two of them — community (api/organizations.py) and hosted (enterprise
|
||||||
|
# signup) — and they drifted. Hosted seeded only the first two for a year, so
|
||||||
|
# its agents could not see the unclaimed queue or open People at all.
|
||||||
|
DEFAULT_AGENT_ROLE_PERMISSIONS = (
|
||||||
|
"view_assigned_chats",
|
||||||
|
"manage_assigned_chats",
|
||||||
|
"view_unassigned_chats",
|
||||||
|
"view_people",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Permission(Base):
|
||||||
|
__tablename__ = "permissions"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String, unique=True, nullable=False)
|
||||||
|
description = Column(String)
|
||||||
|
|
||||||
|
# Define default permissions
|
||||||
|
@classmethod
|
||||||
|
def default_permissions(cls):
|
||||||
|
return [
|
||||||
|
("view_all", "Can view all resources"),
|
||||||
|
("manage_users", "Can manage users"),
|
||||||
|
("manage_roles", "Can manage roles"),
|
||||||
|
("manage_agents", "Can manage chat agents"),
|
||||||
|
("view_agents", "Can view chat agents"),
|
||||||
|
("view_analytics", "Can view analytics"),
|
||||||
|
("view_assigned_chats", "Can view assigned chats only"),
|
||||||
|
("manage_assigned_chats", "Can manage assigned chats"),
|
||||||
|
# The unclaimed queue: AI-handled sessions nobody has taken yet.
|
||||||
|
# Separate from view_assigned_chats so widening an agent's inbox to
|
||||||
|
# the queue never silently exposes another agent's conversations.
|
||||||
|
("view_unassigned_chats", "Can view unassigned AI chats"),
|
||||||
|
("manage_knowledge", "Can manage knowledge base"),
|
||||||
|
("view_knowledge", "Can view knowledge base"),
|
||||||
|
("manage_ai_config", "Can manage AI configuration"),
|
||||||
|
("view_ai_config", "Can view AI configuration"),
|
||||||
|
("view_all_chats", "Can view all chat history"),
|
||||||
|
("manage_all_chats", "Can manage all chat sessions"),
|
||||||
|
# The people directory, decoupled from chat visibility: an agent can
|
||||||
|
# look up who they are talking to without being able to read every
|
||||||
|
# other agent's conversations.
|
||||||
|
("view_people", "Can view the people directory"),
|
||||||
|
("manage_organization", "Can manage organization settings"),
|
||||||
|
("view_organization", "Can view organization details"),
|
||||||
|
("manage_subscription", "Can manage subscription plans and billing"),
|
||||||
|
("view_subscription", "Can view subscription details"),
|
||||||
|
("view_tickets", "Can view tickets"),
|
||||||
|
("manage_tickets", "Can manage tickets"),
|
||||||
|
("approve_ticket_actions", "Can approve AI-proposed ticket resolutions"),
|
||||||
|
("manage_ticket_connectors", "Can manage ticket investigation connectors"),
|
||||||
|
("super_admin", "Has all permissions")
|
||||||
|
]
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from app.database import Base
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
|
||||||
|
class Rating(Base):
|
||||||
|
__tablename__ = "ratings"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, index=True,default=uuid.uuid4)
|
||||||
|
session_id = Column(UUID(as_uuid=True), ForeignKey("session_to_agents.session_id", ondelete="CASCADE"), nullable=False)
|
||||||
|
customer_id = Column(UUID(as_uuid=True), ForeignKey("customers.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
rating = Column(Integer, nullable=False) # 1-5 star rating
|
||||||
|
feedback = Column(Text, nullable=True) # Optional feedback text
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
session_assignments = relationship("SessionToAgent", back_populates="ratings")
|
||||||
|
customer = relationship("Customer", back_populates="ratings")
|
||||||
|
agent = relationship("Agent", back_populates="ratings")
|
||||||
|
user = relationship("User", back_populates="ratings")
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, Integer, String, ForeignKey, Boolean, func, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
class Role(Base):
|
||||||
|
__tablename__ = "roles"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('name', 'organization_id', name='uq_role_name_org'),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
description = Column(String)
|
||||||
|
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"))
|
||||||
|
is_default = Column(Boolean, default=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
organization = relationship("Organization", back_populates="roles")
|
||||||
|
permissions = relationship("Permission", secondary="role_permissions")
|
||||||
|
users = relationship("User", back_populates="role")
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convert role object to dictionary"""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"permissions": [{"id": p.id, "name": p.name, "description": p.description} for p in self.permissions] if self.permissions else []
|
||||||
|
}
|
||||||
|
|
||||||
|
def has_permission(self, permission_name: str) -> bool:
|
||||||
|
"""Check if role has a specific permission"""
|
||||||
|
return any(p.name == permission_name for p in self.permissions)
|
||||||
|
|
||||||
|
def is_super_admin(self) -> bool:
|
||||||
|
"""Check if role has super admin permission"""
|
||||||
|
return self.has_permission("super_admin")
|
||||||
|
|
||||||
|
def can_manage_subscription(self) -> bool:
|
||||||
|
"""Check if role can manage subscriptions"""
|
||||||
|
return self.is_super_admin() or self.has_permission("manage_subscription")
|
||||||
|
|
||||||
|
def can_view_subscription(self) -> bool:
|
||||||
|
"""Check if role can view subscription details"""
|
||||||
|
return self.is_super_admin() or self.has_permission("view_subscription")
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .organization import (
|
||||||
|
OrganizationBase,
|
||||||
|
OrganizationCreate,
|
||||||
|
OrganizationUpdate,
|
||||||
|
OrganizationResponse
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'OrganizationBase',
|
||||||
|
'OrganizationCreate',
|
||||||
|
'OrganizationUpdate',
|
||||||
|
'OrganizationResponse',
|
||||||
|
'AgentSessionBase',
|
||||||
|
'AgentSessionCreate',
|
||||||
|
'AgentSessionUpdate',
|
||||||
|
'AgentSessionResponse'
|
||||||
|
]
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import List, Optional, Dict
|
||||||
|
from enum import Enum
|
||||||
|
from uuid import UUID
|
||||||
|
from app.models.agent import AgentType
|
||||||
|
from app.agents.guardrail_policy import GUARDRAIL_PROMPT_MAX
|
||||||
|
from app.models.schemas.agent_customization import CustomizationResponse
|
||||||
|
from app.models.schemas.user_group import UserGroupResponse
|
||||||
|
|
||||||
|
class KnowledgeItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBase(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
|
description: Optional[str] = None
|
||||||
|
agent_type: AgentType
|
||||||
|
instructions: List[str]
|
||||||
|
tools: Optional[List[Dict]] = None
|
||||||
|
is_active: bool = True
|
||||||
|
is_default: bool = False
|
||||||
|
transfer_to_human: bool = False
|
||||||
|
ai_replies_enabled: bool = True
|
||||||
|
channel_quick_replies: bool = False
|
||||||
|
ask_for_rating: bool = False
|
||||||
|
handoff_collect_email: bool = True
|
||||||
|
handoff_collect_name: bool = True
|
||||||
|
enable_rate_limiting: bool = False
|
||||||
|
overall_limit_per_ip: int = Field(default=100, description="Maximum number of requests allowed per IP address")
|
||||||
|
requests_per_sec: float = Field(default=1.0, description="Number of requests allowed per second")
|
||||||
|
use_workflow: bool = False
|
||||||
|
active_workflow_id: Optional[UUID] = None
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
allow_attachments: bool = False
|
||||||
|
allowed_attachment_types: Optional[List[str]] = Field(
|
||||||
|
default=None,
|
||||||
|
description="List of allowed attachment type categories: 'images', 'documents', 'office', 'text'. If null/empty, all types allowed."
|
||||||
|
)
|
||||||
|
require_token_auth: bool = False
|
||||||
|
ticketing_enabled: bool = True
|
||||||
|
topic_scope: Optional[str] = Field(
|
||||||
|
default=None, max_length=500,
|
||||||
|
description="Optional one-line business remit used by the platform guardrail's topic-scope line."
|
||||||
|
)
|
||||||
|
guardrail_prompt: Optional[str] = Field(
|
||||||
|
default=None, max_length=GUARDRAIL_PROMPT_MAX,
|
||||||
|
description="Scope rule for this agent. Null uses the platform default; edit it to fit your business."
|
||||||
|
)
|
||||||
|
guardrail_enabled: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="Apply the scope rule. Injection and disclosure protections are always on."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCreate(AgentBase):
|
||||||
|
organization_id: Optional[UUID] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentUpdate(BaseModel):
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
instructions: Optional[List[str]] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
transfer_to_human: Optional[bool] = None
|
||||||
|
ai_replies_enabled: Optional[bool] = None
|
||||||
|
channel_quick_replies: Optional[bool] = None
|
||||||
|
ask_for_rating: Optional[bool] = None
|
||||||
|
handoff_collect_email: Optional[bool] = None
|
||||||
|
handoff_collect_name: Optional[bool] = None
|
||||||
|
enable_rate_limiting: Optional[bool] = None
|
||||||
|
overall_limit_per_ip: Optional[int] = None
|
||||||
|
requests_per_sec: Optional[float] = None
|
||||||
|
use_workflow: Optional[bool] = None
|
||||||
|
active_workflow_id: Optional[UUID] = None
|
||||||
|
allow_attachments: Optional[bool] = None
|
||||||
|
allowed_attachment_types: Optional[List[str]] = Field(
|
||||||
|
default=None,
|
||||||
|
description="List of allowed attachment type categories: 'images', 'documents', 'office', 'text'. If null/empty, all types allowed."
|
||||||
|
)
|
||||||
|
require_token_auth: Optional[bool] = None
|
||||||
|
ticketing_enabled: Optional[bool] = None
|
||||||
|
topic_scope: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
guardrail_prompt: Optional[str] = Field(default=None, max_length=GUARDRAIL_PROMPT_MAX)
|
||||||
|
guardrail_enabled: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentKnowledge(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRosterItem(BaseModel):
|
||||||
|
"""An AI agent as the inbox filter needs it: a name to group chats by.
|
||||||
|
|
||||||
|
Deliberately none of AgentResponse's configuration — `instructions`, and
|
||||||
|
especially `guardrail_prompt`, which tells a reader how to phrase a bypass.
|
||||||
|
"""
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class AgentResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
display_name: Optional[str]
|
||||||
|
description: Optional[str]
|
||||||
|
agent_type: AgentType
|
||||||
|
instructions: List[str]
|
||||||
|
is_active: bool
|
||||||
|
organization_id: UUID
|
||||||
|
knowledge: List[AgentKnowledge] = []
|
||||||
|
transfer_to_human: bool = False
|
||||||
|
ai_replies_enabled: bool = True
|
||||||
|
channel_quick_replies: bool = False
|
||||||
|
ask_for_rating: bool = False
|
||||||
|
handoff_collect_email: bool = True
|
||||||
|
handoff_collect_name: bool = True
|
||||||
|
enable_rate_limiting: Optional[bool] = None
|
||||||
|
overall_limit_per_ip: Optional[int] = None
|
||||||
|
requests_per_sec: Optional[float] = None
|
||||||
|
use_workflow: Optional[bool] = False
|
||||||
|
active_workflow_id: Optional[UUID] = None
|
||||||
|
allow_attachments: bool = False
|
||||||
|
allowed_attachment_types: Optional[List[str]] = None
|
||||||
|
require_token_auth: bool = False
|
||||||
|
ticketing_enabled: bool = True
|
||||||
|
topic_scope: Optional[str] = None
|
||||||
|
guardrail_prompt: Optional[str] = None
|
||||||
|
guardrail_enabled: bool = True
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class AgentWithCustomizationResponse(AgentResponse):
|
||||||
|
customization: Optional[CustomizationResponse] = None
|
||||||
|
groups: List[UserGroupResponse] = []
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES 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 (replypier.js) merges these under any options the
|
||||||
|
installing developer set on the page. Bounds keep saved values renderable
|
||||||
|
on any reasonable screen; the loader clamps again client-side.
|
||||||
|
"""
|
||||||
|
mode: Optional[WidgetDisplayMode] = None
|
||||||
|
side: Optional[WidgetDisplaySide] = None
|
||||||
|
launcher: Optional[bool] = None
|
||||||
|
width: Optional[int] = Field(default=None, ge=280, le=800)
|
||||||
|
height: Optional[int] = Field(default=None, ge=400, le=900)
|
||||||
|
sidebar_width: Optional[int] = Field(default=None, ge=320, le=640)
|
||||||
|
search_placeholder: Optional[str] = Field(default=None, max_length=80)
|
||||||
|
offset_bottom: Optional[int] = Field(default=None, ge=0, le=200)
|
||||||
|
offset_side: Optional[int] = Field(default=None, ge=0, le=200)
|
||||||
|
z_index: Optional[int] = Field(default=None, ge=1, le=2147483647)
|
||||||
|
|
||||||
|
|
||||||
|
# Predefined chat initiation messages
|
||||||
|
DEFAULT_CHAT_INITIATIONS = [
|
||||||
|
"👋 Hi! Need help? Ask me anything!",
|
||||||
|
"💬 Have a question? I'm here to help!",
|
||||||
|
"🤝 Welcome! How can I assist you today?",
|
||||||
|
"✨ Got questions? Let's chat!",
|
||||||
|
"👨💼 Need support? Click to chat with us!"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Predefined quick-action buttons (clicking sends the label as a message)
|
||||||
|
DEFAULT_QUICK_ACTIONS = [
|
||||||
|
"Track my order",
|
||||||
|
"Start a return",
|
||||||
|
"Talk to a human"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CustomizationBase(BaseModel):
|
||||||
|
photo_url: Optional[str] = None
|
||||||
|
|
||||||
|
@field_validator('photo_url')
|
||||||
|
@classmethod
|
||||||
|
def _strip_photo_url_signature(cls, v: Optional[str]) -> Optional[str]:
|
||||||
|
"""Keep the bare S3 URL as the stored value.
|
||||||
|
|
||||||
|
CustomizationResponse serializes photo_url signed, and the agent
|
||||||
|
customization form POSTs the value it was given straight back, so
|
||||||
|
without this the expiring signature would be written to the column.
|
||||||
|
"""
|
||||||
|
return strip_s3_signature(v)
|
||||||
|
chat_background_color: Optional[str] = "#F8F9FA"
|
||||||
|
chat_bubble_color: Optional[str] = "#E9ECEF"
|
||||||
|
chat_text_color: Optional[str] = "#212529"
|
||||||
|
icon_url: Optional[str] = None
|
||||||
|
icon_color: Optional[str] = "#6C757D"
|
||||||
|
accent_color: Optional[str] = "#f34611"
|
||||||
|
font_family: Optional[str] = "Inter, system-ui, sans-serif"
|
||||||
|
custom_css: Optional[str] = None
|
||||||
|
customization_metadata: Optional[Dict] = {}
|
||||||
|
chat_style: Optional[ChatStyle] = ChatStyle.CHATBOT
|
||||||
|
widget_position: Optional[WidgetPosition] = WidgetPosition.FLOATING
|
||||||
|
# NOTE: length caps live on CustomizationCreate (input), NOT here — the response
|
||||||
|
# model also inherits this base, and existing rows may exceed a newly-lowered cap;
|
||||||
|
# enforcing on the response would 500 the whole agent list.
|
||||||
|
welcome_title: Optional[str] = None
|
||||||
|
welcome_subtitle: Optional[str] = None
|
||||||
|
welcome_message: Optional[str] = None
|
||||||
|
chat_initiation_messages: Optional[List[str]] = None
|
||||||
|
quick_actions: Optional[List[str]] = None
|
||||||
|
show_citations: Optional[bool] = False
|
||||||
|
collect_email: Optional[bool] = False
|
||||||
|
show_ai_disclaimer: Optional[bool] = True
|
||||||
|
allow_new_chat: Optional[bool] = False
|
||||||
|
|
||||||
|
|
||||||
|
class CustomizationCreate(CustomizationBase):
|
||||||
|
# Enforce length caps on write only, so the UI can't submit values the widget
|
||||||
|
# can't display. Reads (CustomizationResponse) intentionally stay unconstrained.
|
||||||
|
welcome_title: Optional[str] = Field(default=None, max_length=100)
|
||||||
|
welcome_subtitle: Optional[str] = Field(default=None, max_length=250)
|
||||||
|
welcome_message: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
chat_initiation_messages: Optional[List[InitiationMessage]] = None
|
||||||
|
|
||||||
|
@field_validator('customization_metadata')
|
||||||
|
@classmethod
|
||||||
|
def _validate_widget_display(cls, v: Optional[Dict]) -> Optional[Dict]:
|
||||||
|
"""Validate and normalize the structured widget_display key on write.
|
||||||
|
|
||||||
|
The rest of customization_metadata stays free-form (avatar_style etc.);
|
||||||
|
widget_display is consumed by the embed loader on customer pages, so bad
|
||||||
|
values must be rejected here. Unknown keys inside it are dropped and
|
||||||
|
unset keys aren't stored. An explicit null clears the settings.
|
||||||
|
"""
|
||||||
|
if not v or v.get('widget_display') is None:
|
||||||
|
return v
|
||||||
|
validated = WidgetDisplayConfig.model_validate(v['widget_display'])
|
||||||
|
# mode='json' turns enums into plain strings — the dict goes straight into
|
||||||
|
# a JSON column, so it must hold JSON primitives, not enum members.
|
||||||
|
v['widget_display'] = validated.model_dump(mode='json', exclude_none=True)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class CustomizationResponse(CustomizationBase):
|
||||||
|
id: int
|
||||||
|
agent_id: UUID
|
||||||
|
|
||||||
|
@field_serializer('photo_url')
|
||||||
|
def _sign_photo_url(self, v: Optional[str]) -> Optional[str]:
|
||||||
|
"""Sign on the way out, every response. Signing is a local HMAC (~0.05ms),
|
||||||
|
so there is nothing to gain by caching the result in the database."""
|
||||||
|
return sign_s3_url(v) if v else v
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, SecretStr
|
||||||
|
from typing import Optional, Dict
|
||||||
|
from app.models.ai_config import AIModelType
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
|
class AIConfigBase(BaseModel):
|
||||||
|
model_type: AIModelType
|
||||||
|
model_name: str
|
||||||
|
settings: Optional[Dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class AIConfigCreate(AIConfigBase):
|
||||||
|
api_key: SecretStr
|
||||||
|
# Required for OPENAI_COMPATIBLE (a self-hosted or third-party endpoint);
|
||||||
|
# ignored by providers that don't need it.
|
||||||
|
base_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AIConfigUpdate(BaseModel):
|
||||||
|
model_type: Optional[AIModelType] = None
|
||||||
|
model_name: Optional[str] = None
|
||||||
|
api_key: Optional[SecretStr] = None
|
||||||
|
settings: Optional[Dict] = None
|
||||||
|
base_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AIConfigResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
organization_id: UUID
|
||||||
|
model_type: AIModelType
|
||||||
|
model_name: str
|
||||||
|
is_active: bool
|
||||||
|
settings: Dict = {}
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class AISetupResponse(BaseModel):
|
||||||
|
message: str
|
||||||
|
config: AIConfigResponse
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
|
from app.channels.constants import DEFAULT_TEMPLATE_LANGUAGE
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelAccountOut(BaseModel):
|
||||||
|
"""Connected channel account as exposed to the settings UI (no secrets)."""
|
||||||
|
id: UUID
|
||||||
|
channel_type: str
|
||||||
|
external_account_id: str
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
is_active: bool
|
||||||
|
agent_id: Optional[UUID] = None # agent currently routed to this account
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
# For channels the customer must point at us (email inbound-parse, SMS,
|
||||||
|
# Meta accounts from the customer's own app): the exact webhook URL to
|
||||||
|
# configure on their provider, and the verify token Meta asks for.
|
||||||
|
webhook_url: Optional[str] = None
|
||||||
|
# The platform refused the account's credentials; replies fail until the
|
||||||
|
# account is reconnected
|
||||||
|
needs_reconnect: bool = False
|
||||||
|
webhook_verify_token: Optional[str] = None
|
||||||
|
# WhatsApp from the customer's own app: whether Meta took our per-number
|
||||||
|
# callback (else the customer sets the URL and token by hand)
|
||||||
|
webhook_auto_configured: Optional[bool] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramConnectRequest(BaseModel):
|
||||||
|
bot_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class OwnMetaAppSecret(BaseModel):
|
||||||
|
"""The secret of the customer's own Meta app, for manual credentials:
|
||||||
|
Meta signs that app's webhooks with it. Blank means not given."""
|
||||||
|
app_secret: Optional[str] = None
|
||||||
|
|
||||||
|
@field_validator("app_secret")
|
||||||
|
@classmethod
|
||||||
|
def _blank_is_none(cls, value: Optional[str]) -> Optional[str]:
|
||||||
|
return (value or "").strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
class WhatsAppConnectRequest(OwnMetaAppSecret):
|
||||||
|
"""Manual WhatsApp Cloud API credentials, from the customer's own Meta app."""
|
||||||
|
phone_number_id: str
|
||||||
|
access_token: str
|
||||||
|
waba_id: Optional[str] = None
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MessengerConnectRequest(OwnMetaAppSecret):
|
||||||
|
page_id: str
|
||||||
|
page_access_token: str
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class InstagramConnectRequest(BaseModel):
|
||||||
|
ig_id: str
|
||||||
|
page_access_token: str
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateSendRequest(BaseModel):
|
||||||
|
"""Reopen an expired WhatsApp window with an approved template."""
|
||||||
|
session_id: UUID
|
||||||
|
template_name: str
|
||||||
|
language: str = DEFAULT_TEMPLATE_LANGUAGE
|
||||||
|
components: Optional[list] = None
|
||||||
|
|
||||||
|
|
||||||
|
class OutboundConversationRequest(BaseModel):
|
||||||
|
"""Start a WhatsApp conversation with a phone number — the customer has
|
||||||
|
not messaged us; an approved template is the only thing Meta will deliver.
|
||||||
|
|
||||||
|
customer_id links the conversation to a person the agent picked in People;
|
||||||
|
without it the customer is resolved by phone or created. customer_name
|
||||||
|
only names a newly created person (never renames an existing one)."""
|
||||||
|
to: str
|
||||||
|
template_name: str
|
||||||
|
language: str = DEFAULT_TEMPLATE_LANGUAGE
|
||||||
|
components: Optional[list] = None
|
||||||
|
customer_id: Optional[UUID] = None
|
||||||
|
customer_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class OutboundConversationOut(BaseModel):
|
||||||
|
"""The session the outbound send created (or reused): everything else —
|
||||||
|
the inbox thread, template resend, AI takeover on reply — keys off it."""
|
||||||
|
session_id: UUID
|
||||||
|
|
||||||
|
|
||||||
|
class EmbeddedSignupRequest(BaseModel):
|
||||||
|
"""What the Embedded Signup JS SDK hands back: a short-lived code to trade
|
||||||
|
for the customer's business token, plus the assets it created."""
|
||||||
|
code: str
|
||||||
|
waba_id: str
|
||||||
|
phone_number_id: str
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MessengerSignupRequest(BaseModel):
|
||||||
|
"""The code Facebook Login for Business hands back. Unlike WhatsApp's
|
||||||
|
Embedded Signup it names no assets — the Pages are ours to look up.
|
||||||
|
|
||||||
|
redirect_uri is the OAuth callback URL the login popup used; the code is
|
||||||
|
bound to it, so the token exchange must send the exact same value back. The
|
||||||
|
client owns it (it must also be a registered Valid OAuth Redirect URI)."""
|
||||||
|
code: str
|
||||||
|
redirect_uri: str
|
||||||
|
|
||||||
|
|
||||||
|
class InstagramLoginRequest(BaseModel):
|
||||||
|
"""What Instagram Business Login hands back. No Page and no asset ids — the
|
||||||
|
token itself identifies the single account that signed in, so unlike the
|
||||||
|
Messenger flow there is nothing to list or pick.
|
||||||
|
|
||||||
|
redirect_uri is the OAuth callback the popup used; the code is bound to it,
|
||||||
|
so the token exchange must send the exact same value back."""
|
||||||
|
code: str
|
||||||
|
redirect_uri: str
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MessengerPageOut(BaseModel):
|
||||||
|
"""A Page offered in the picker. Its access token is deliberately absent:
|
||||||
|
it travels back inside the opaque signup_token instead."""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class MessengerSignupPagesOut(BaseModel):
|
||||||
|
"""Step 1's result: the Pages to choose from, plus an opaque token that
|
||||||
|
carries their access tokens (encrypted) into step 2."""
|
||||||
|
pages: list[MessengerPageOut]
|
||||||
|
signup_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class MessengerSignupConnectRequest(BaseModel):
|
||||||
|
"""Step 2: the Page picked out of step 1's list. page_id is a selector into
|
||||||
|
that list, not a credential — the token is the one we sealed."""
|
||||||
|
signup_token: str
|
||||||
|
page_id: str
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class EmbeddedSignupConfigOut(BaseModel):
|
||||||
|
"""Tells the connect UI whether to offer Embedded Signup. Everything but
|
||||||
|
`enabled` is None when it isn't, so nothing leaks to orgs without it."""
|
||||||
|
enabled: bool
|
||||||
|
config_id: Optional[str] = None
|
||||||
|
app_id: Optional[str] = None
|
||||||
|
graph_version: str
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateOut(BaseModel):
|
||||||
|
"""A message template as it exists on the customer's WhatsApp Business
|
||||||
|
Account. Only APPROVED templates can actually be sent."""
|
||||||
|
id: Optional[str] = None
|
||||||
|
name: str
|
||||||
|
status: Optional[str] = None
|
||||||
|
category: Optional[str] = None
|
||||||
|
language: Optional[str] = None
|
||||||
|
components: Optional[list] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateLibraryOut(BaseModel):
|
||||||
|
"""A deep link into Meta's Template Library for one WhatsApp Business
|
||||||
|
Account — which is where templates get written. We list and send them; Meta
|
||||||
|
authors them."""
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
class AgentChannelConfigRequest(BaseModel):
|
||||||
|
agent_id: UUID
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class EmailConnectRequest(BaseModel):
|
||||||
|
inbound_address: str # e.g. support@acme.com (the address customers write to)
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
# Optional per-inbox outbound SMTP. When omitted, replies use the platform
|
||||||
|
# SMTP settings. Provide these to send from the inbox's own domain with
|
||||||
|
# correct SPF/DKIM alignment.
|
||||||
|
smtp_host: Optional[str] = None
|
||||||
|
smtp_port: Optional[int] = None
|
||||||
|
smtp_username: Optional[str] = None
|
||||||
|
smtp_password: Optional[str] = None
|
||||||
|
from_email: Optional[str] = None # defaults to inbound_address
|
||||||
|
smtp_use_ssl: Optional[bool] = None # None = auto (port 465 → implicit TLS)
|
||||||
|
|
||||||
|
|
||||||
|
class SmsConnectRequest(BaseModel):
|
||||||
|
provider: str # 'twilio' | 'vonage' | 'messagebird' | ...
|
||||||
|
phone_number: str # sender number / id
|
||||||
|
credentials: dict = {} # provider-specific secrets
|
||||||
|
|
||||||
|
|
||||||
|
class SmsProviderInfo(BaseModel):
|
||||||
|
name: str
|
||||||
|
label: str
|
||||||
|
fields: list # [{key,label,secret,optional}] for the UI
|
||||||
|
|
||||||
|
|
||||||
|
class LineConnectRequest(BaseModel):
|
||||||
|
channel_secret: str
|
||||||
|
channel_access_token: str
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from app.models.session_to_agent import SessionStatus
|
||||||
|
from app.channels.constants import MAX_CHOICE_LENGTH, MAX_CHOICE_OPTIONS
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerInfo(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
email: Optional[str]
|
||||||
|
full_name: Optional[str]
|
||||||
|
# Integrator-supplied fields (e.g. student_name, center_name) set via
|
||||||
|
# POST /generate-token's `custom_data`, shown to agents in the chat inbox.
|
||||||
|
meta_data: Optional[Dict[str, Any]] = None
|
||||||
|
class AgentInfo(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
display_name: Optional[str]
|
||||||
|
# False means this agent never answers with AI, so an unclaimed chat of its
|
||||||
|
# is waiting for a person rather than being handled.
|
||||||
|
ai_replies_enabled: bool = True
|
||||||
|
|
||||||
|
class TransferReasonType(str, Enum):
|
||||||
|
UNABLE_TO_ANSWER = "UNABLE_TO_ANSWER"
|
||||||
|
NEED_MORE_INFO = "NEED_MORE_INFO"
|
||||||
|
KNOWLEDGE_GAP = "KNOWLEDGE_GAP"
|
||||||
|
NEED_TO_CALL = "NEED_TO_CALL"
|
||||||
|
NEED_TO_EMAIL = "NEED_TO_EMAIL"
|
||||||
|
NEED_TO_MEET = "NEED_TO_MEET"
|
||||||
|
FRUSTRATED = "FRUSTRATED"
|
||||||
|
REPEAT_INSTRUCTIONS = "REPEAT_INSTRUCTIONS"
|
||||||
|
DIRECT_REQUEST = "DIRECT_REQUEST"
|
||||||
|
HIGH_PRIORITY_ISSUE = "HIGH_PRIORITY_ISSUE"
|
||||||
|
COMPLIANCE_ISSUE = "COMPLIANCE_ISSUE"
|
||||||
|
|
||||||
|
class EndChatReasonType(str, Enum):
|
||||||
|
ISSUE_RESOLVED = "ISSUE_RESOLVED"
|
||||||
|
CUSTOMER_REQUEST = "CUSTOMER_REQUEST"
|
||||||
|
CONFIRMATION_RECEIVED = "CONFIRMATION_RECEIVED"
|
||||||
|
FAREWELL = "FAREWELL"
|
||||||
|
THANK_YOU = "THANK_YOU"
|
||||||
|
NATURAL_CONCLUSION = "NATURAL_CONCLUSION"
|
||||||
|
TASK_COMPLETED = "TASK_COMPLETED"
|
||||||
|
|
||||||
|
# Define a model for a single Shopify product image
|
||||||
|
class ShopifyProductImage(BaseModel):
|
||||||
|
src: Optional[str] = Field(default=None)
|
||||||
|
alt: Optional[str] = Field(default=None)
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_encoders = {
|
||||||
|
UUID: str # Convert UUID to string
|
||||||
|
}
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
# Define a model for a single Shopify product
|
||||||
|
class ShopifyProduct(BaseModel):
|
||||||
|
id: Optional[str] = Field(default=None)
|
||||||
|
title: Optional[str] = Field(default=None)
|
||||||
|
description: Optional[str] = Field(default=None)
|
||||||
|
handle: Optional[str] = Field(default=None)
|
||||||
|
product_type: Optional[str] = Field(default=None)
|
||||||
|
vendor: Optional[str] = Field(default=None)
|
||||||
|
total_inventory: Optional[int] = Field(default=None)
|
||||||
|
price: Optional[str] = Field(default=None)
|
||||||
|
price_max: Optional[str] = Field(default=None)
|
||||||
|
currency: Optional[str] = Field(default=None)
|
||||||
|
image: Optional[ShopifyProductImage] = Field(default=None)
|
||||||
|
tags: Optional[List[str]] = Field(default_factory=list)
|
||||||
|
created_at: Optional[str] = Field(default=None)
|
||||||
|
updated_at: Optional[str] = Field(default=None)
|
||||||
|
price_formatted: Optional[str] = Field(default=None)
|
||||||
|
variant_title: Optional[str] = Field(default=None)
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_encoders = {
|
||||||
|
UUID: str # Convert UUID to string
|
||||||
|
}
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
# LLM Response Model - ONLY cache key and product IDs (ultra-minimal)
|
||||||
|
class ShopifyOutputDataLLM(BaseModel):
|
||||||
|
"""Shopify output model for LLM responses - ONLY cache key and product IDs to minimize tokens"""
|
||||||
|
product_cache_key: str = Field(description="Redis cache key - REQUIRED when Shopify tools return products")
|
||||||
|
product_ids: List[str] = Field(description="List of product IDs - REQUIRED when Shopify tools return products")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_encoders = {
|
||||||
|
UUID: str
|
||||||
|
}
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
# Socket/Frontend Response Model - WITH products field (what frontend receives)
|
||||||
|
class ShopifyOutputData(BaseModel):
|
||||||
|
"""Shopify output model for socket/frontend responses - includes full product data"""
|
||||||
|
products: Optional[List[ShopifyProduct]] = Field(default_factory=list)
|
||||||
|
search_query: Optional[str] = Field(default=None)
|
||||||
|
search_type: Optional[str] = Field(default=None)
|
||||||
|
total_count: Optional[int] = Field(default=None)
|
||||||
|
has_more: Optional[bool] = Field(default=None)
|
||||||
|
shop_domain: Optional[str] = Field(default=None, description="Shopify shop domain for constructing product URLs")
|
||||||
|
product_cache_key: Optional[str] = Field(default=None, description="Redis cache key for backend reference")
|
||||||
|
product_ids: Optional[List[str]] = Field(default=None, description="List of product IDs for backend reference")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_encoders = {
|
||||||
|
UUID: str # Convert UUID to string
|
||||||
|
}
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
def dict(self, *args, **kwargs):
|
||||||
|
# Override dict method to ensure proper serialization
|
||||||
|
d = super().dict(*args, **kwargs)
|
||||||
|
# Convert any remaining non-serializable objects to strings
|
||||||
|
return d
|
||||||
|
|
||||||
|
def json(self, *args, **kwargs):
|
||||||
|
# Override json method to ensure proper serialization
|
||||||
|
return self.dict()
|
||||||
|
|
||||||
|
class Message(BaseModel):
|
||||||
|
id: Optional[int] = None
|
||||||
|
message: Optional[str] = None
|
||||||
|
message_type: str
|
||||||
|
created_at: datetime
|
||||||
|
attributes: Optional[dict] = None
|
||||||
|
# Channel messages: the platform's id and its latest delivery receipt
|
||||||
|
external_message_id: Optional[str] = None
|
||||||
|
channel_status: Optional[str] = None
|
||||||
|
shopify_output: Optional[ShopifyOutputData] = None
|
||||||
|
end_chat: Optional[bool] = None
|
||||||
|
end_chat_reason: Optional[EndChatReasonType] = None
|
||||||
|
end_chat_description: Optional[str] = None
|
||||||
|
agent_name: Optional[str] = None
|
||||||
|
user_name: Optional[str] = None
|
||||||
|
attachments: Optional[list] = None # List of file attachments with signed URLs
|
||||||
|
|
||||||
|
@model_validator(mode='before')
|
||||||
|
@classmethod
|
||||||
|
def validate_end_chat_reason(cls, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Validate end_chat_reason field to ensure it's a valid enum value or None."""
|
||||||
|
if isinstance(data, dict) and 'end_chat_reason' in data and data['end_chat_reason'] is not None:
|
||||||
|
valid_reasons = [reason.value for reason in EndChatReasonType]
|
||||||
|
if data['end_chat_reason'] not in valid_reasons:
|
||||||
|
# If invalid value, set to None
|
||||||
|
data['end_chat_reason'] = None
|
||||||
|
return data
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
json_encoders = {
|
||||||
|
UUID: str,
|
||||||
|
datetime: lambda v: v.isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChatOverviewResponse(BaseModel):
|
||||||
|
customer: CustomerInfo
|
||||||
|
agent: AgentInfo
|
||||||
|
last_message: Optional[str] = None
|
||||||
|
updated_at: datetime
|
||||||
|
message_count: int
|
||||||
|
status: SessionStatus
|
||||||
|
channel: str = 'web'
|
||||||
|
group_id: Optional[UUID]
|
||||||
|
# The human agent handling the chat, if one has taken it over. None means
|
||||||
|
# the AI still has it — the inbox needs this to label the row.
|
||||||
|
user_id: Optional[UUID] = None
|
||||||
|
user_name: Optional[str] = None
|
||||||
|
session_id: UUID
|
||||||
|
|
||||||
|
class ChatDetailResponse(BaseModel):
|
||||||
|
customer: CustomerInfo
|
||||||
|
agent: AgentInfo
|
||||||
|
messages: List[Message]
|
||||||
|
status: SessionStatus
|
||||||
|
channel: str = 'web'
|
||||||
|
# Which connected channel account this conversation arrived on; None for web.
|
||||||
|
channel_account_id: Optional[UUID] = None
|
||||||
|
group_id: Optional[UUID]
|
||||||
|
session_id: UUID
|
||||||
|
user_id: Optional[UUID]
|
||||||
|
user_name: Optional[str]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
json_encoders = {
|
||||||
|
UUID: str,
|
||||||
|
datetime: lambda v: v.isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
class SourceRef(BaseModel):
|
||||||
|
"""A knowledge-base source used to ground an answer (for citation chips)."""
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
|
||||||
|
|
||||||
|
class ChatResponse(BaseModel):
|
||||||
|
message: str = Field(description="The response from the agent. IMPORTANT: When shopify_output is present, DO NOT include product images, URLs, prices, or product details in this field - all product info should ONLY go in the shopify_output field. Keep the message conversational.")
|
||||||
|
transfer_to_human: bool = Field(description="Whether to transfer the conversation to a human")
|
||||||
|
end_chat: bool = Field(description="Whether to end the chat and request rating")
|
||||||
|
transfer_reason: Optional[TransferReasonType] = Field(default=None, description="Transfer reason Type should be one of the following: UNABLE_TO_ANSWER, NEED_MORE_INFO, KNOWLEDGE_GAP, NEED_TO_CALL, NEED_TO_EMAIL, NEED_TO_MEET, FRUSTRATED, REPEAT_INSTRUCTIONS, DIRECT_REQUEST, HIGH_PRIORITY_ISSUE, COMPLIANCE_ISSUE")
|
||||||
|
transfer_description: Optional[str] = Field(default=None, description="Detailed description for the transfer")
|
||||||
|
end_chat_reason: Optional[EndChatReasonType] = Field(default=None, description="End chat reason Type should be one of the following: ISSUE_RESOLVED, CUSTOMER_REQUEST, CONFIRMATION_RECEIVED, FAREWELL, THANK_YOU, NATURAL_CONCLUSION, TASK_COMPLETED")
|
||||||
|
end_chat_description: Optional[str] = Field(default=None, description="Detailed description for ending the chat")
|
||||||
|
request_rating: bool = Field(description="Whether to request a rating from the customer")
|
||||||
|
|
||||||
|
# Ticket creation fields
|
||||||
|
create_ticket: bool = Field(description="Whether to create a ticket in the integrated system (Jira, Zendesk, etc.)")
|
||||||
|
ticket_summary: Optional[str] = Field(default=None, description="Summary/title for the ticket to be created")
|
||||||
|
ticket_description: Optional[str] = Field(default=None, description="Detailed description for the ticket to be created")
|
||||||
|
integration_type: Optional[str] = Field(default=None, description="Type of integration to use for ticket creation")
|
||||||
|
ticket_id: Optional[str] = Field(default=None, description="ID of the created ticket (filled after creation)")
|
||||||
|
ticket_status: Optional[str] = Field(default=None, description="Status of the created ticket (filled after creation)")
|
||||||
|
ticket_priority: Optional[str] = Field(default=None, description="Priority level for the ticket (e.g., 'High', 'Medium', 'Low')")
|
||||||
|
|
||||||
|
# Shopify Output Field - Uses LLM model (no products field)
|
||||||
|
shopify_output: Optional[ShopifyOutputDataLLM] = Field(default=None, description="Shopify product cache info. ONLY include: product_cache_key, product_ids, shop_domain, total_count. DO NOT include products array.")
|
||||||
|
|
||||||
|
# Knowledge-base citations (system-managed). The LLM must NOT populate this; it is
|
||||||
|
# filled automatically from the knowledge search tool after the response is generated.
|
||||||
|
sources: Optional[List[SourceRef]] = Field(default=None, description="System-managed knowledge citations. Do not populate — set automatically.")
|
||||||
|
|
||||||
|
# System-managed: set when a handoff/ticket happens so the widget shows the contact form
|
||||||
|
# (even when no agents are available and transfer_to_human ends up False).
|
||||||
|
request_contact: Optional[bool] = Field(default=False, description="System-managed. Do not populate — set automatically on handoff.")
|
||||||
|
|
||||||
|
# LLM-set (only when lead capture is enabled): the agent collects details
|
||||||
|
# conversationally and reports them here, mirroring how transfer_to_human works.
|
||||||
|
request_lead_capture: bool = Field(default=False, description="Set to true ONLY when you have collected the visitor's contact details (at least a valid email in lead_email) and — if consent is required — they have explicitly agreed to be contacted. This records the lead.")
|
||||||
|
# Explicit scalar fields for the standard contact details. These are used INSTEAD of a
|
||||||
|
# free-form dict because OpenAI strict structured outputs cannot emit an open-ended
|
||||||
|
# object (additionalProperties) — a Dict field always comes back empty. Named scalar
|
||||||
|
# fields populate reliably. The widget records the lead from these.
|
||||||
|
lead_email: Optional[str] = Field(default=None, description="The visitor's email address exactly as they gave it, e.g. 'jane@acme.com'. Fill this as soon as they share it.")
|
||||||
|
lead_name: Optional[str] = Field(default=None, description="The visitor's name, if they share it.")
|
||||||
|
lead_company: Optional[str] = Field(default=None, description="The visitor's company/organization, if they share it.")
|
||||||
|
lead_phone: Optional[str] = Field(default=None, description="The visitor's phone number, if they share it.")
|
||||||
|
lead_data: Optional[Dict[str, Any]] = Field(default=None, description="System-managed. Do not populate — use lead_email/lead_name/lead_company/lead_phone instead.")
|
||||||
|
lead_summary: Optional[str] = Field(default=None, description="A 1-2 sentence qualification summary of this lead from the whole conversation (e.g. '40-person fintech replacing Intercom, ~800 Shopify orders/week, wants a demo this week').")
|
||||||
|
lead_consent: bool = Field(default=False, description="Set to true only when the visitor has explicitly agreed to be contacted / share their details.")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
@model_validator(mode='before')
|
||||||
|
@classmethod
|
||||||
|
def normalize_fields(cls, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Normalize incoming fields and provide default values for missing fields."""
|
||||||
|
if isinstance(data, dict):
|
||||||
|
normalized = dict(data)
|
||||||
|
|
||||||
|
key_mappings = {
|
||||||
|
'requestRating': 'request_rating',
|
||||||
|
'requestrating': 'request_rating',
|
||||||
|
'transferToHuman': 'transfer_to_human',
|
||||||
|
'transfertohuman': 'transfer_to_human',
|
||||||
|
'endChat': 'end_chat',
|
||||||
|
'endchat': 'end_chat',
|
||||||
|
'createTicket': 'create_ticket',
|
||||||
|
'createticket': 'create_ticket',
|
||||||
|
'requestLeadCapture': 'request_lead_capture',
|
||||||
|
'requestleadcapture': 'request_lead_capture',
|
||||||
|
'transferReason': 'transfer_reason',
|
||||||
|
'transferreason': 'transfer_reason',
|
||||||
|
'transferDescription': 'transfer_description',
|
||||||
|
'transferdescription': 'transfer_description',
|
||||||
|
'endChatReason': 'end_chat_reason',
|
||||||
|
'endchatreason': 'end_chat_reason',
|
||||||
|
'endChatDescription': 'end_chat_description',
|
||||||
|
'endchatdescription': 'end_chat_description',
|
||||||
|
'ticketSummary': 'ticket_summary',
|
||||||
|
'ticketsummary': 'ticket_summary',
|
||||||
|
'ticketDescription': 'ticket_description',
|
||||||
|
'ticketdescription': 'ticket_description',
|
||||||
|
'integrationType': 'integration_type',
|
||||||
|
'integrationtype': 'integration_type',
|
||||||
|
'ticketId': 'ticket_id',
|
||||||
|
'ticketid': 'ticket_id',
|
||||||
|
'ticketStatus': 'ticket_status',
|
||||||
|
'ticketstatus': 'ticket_status',
|
||||||
|
'ticketpriority': 'ticket_priority',
|
||||||
|
'content': 'message',
|
||||||
|
# Map incoming shopifyOutput/shopify_output to the new field name
|
||||||
|
'shopifyOutput': 'shopify_output',
|
||||||
|
'shopifyoutput': 'shopify_output',
|
||||||
|
}
|
||||||
|
|
||||||
|
for key in list(normalized.keys()):
|
||||||
|
if key in key_mappings:
|
||||||
|
normalized[key_mappings[key]] = normalized.pop(key)
|
||||||
|
|
||||||
|
# Drop explicit nulls so each field's default applies. Models often emit
|
||||||
|
# `"transfer_to_human": null` / `"create_ticket": null` for the flags they
|
||||||
|
# aren't setting; without this, pydantic rejects null on the non-nullable
|
||||||
|
# boolean fields and the whole response is discarded (losing end_chat,
|
||||||
|
# lead_email, etc.). Optional fields default to None anyway, so dropping is safe.
|
||||||
|
normalized = {k: v for k, v in normalized.items() if v is not None}
|
||||||
|
|
||||||
|
# Set default values for missing fields
|
||||||
|
if 'message' not in normalized:
|
||||||
|
normalized['message'] = "No response generated"
|
||||||
|
if 'transfer_to_human' not in normalized:
|
||||||
|
normalized['transfer_to_human'] = False
|
||||||
|
if 'end_chat' not in normalized:
|
||||||
|
normalized['end_chat'] = False
|
||||||
|
if 'request_rating' not in normalized:
|
||||||
|
normalized['request_rating'] = False
|
||||||
|
if 'create_ticket' not in normalized:
|
||||||
|
normalized['create_ticket'] = False
|
||||||
|
|
||||||
|
# Validate enum fields
|
||||||
|
# Validate end_chat_reason
|
||||||
|
if 'end_chat_reason' in normalized and normalized['end_chat_reason'] is not None:
|
||||||
|
valid_reasons = [reason.value for reason in EndChatReasonType]
|
||||||
|
if normalized['end_chat_reason'] not in valid_reasons:
|
||||||
|
# If invalid value, set to None
|
||||||
|
normalized['end_chat_reason'] = None
|
||||||
|
|
||||||
|
# Validate transfer_reason
|
||||||
|
if 'transfer_reason' in normalized and normalized['transfer_reason'] is not None:
|
||||||
|
valid_reasons = [reason.value for reason in TransferReasonType]
|
||||||
|
if normalized['transfer_reason'] not in valid_reasons:
|
||||||
|
# If invalid value, set to None
|
||||||
|
normalized['transfer_reason'] = None
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
return data
|
||||||
|
|
||||||
|
# Keys a model might wrap a choice in when it returns objects, not strings
|
||||||
|
_CHOICE_TEXT_KEYS = ("title", "text", "label")
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelChatResponse(ChatResponse):
|
||||||
|
"""The reply schema for agents on a channel that can show tappable
|
||||||
|
choices — the only ones whose schema offers them, so every other agent's
|
||||||
|
output contract is unchanged."""
|
||||||
|
|
||||||
|
# A list of plain strings: strict structured outputs handle it on every
|
||||||
|
# provider, unlike a list of objects.
|
||||||
|
quick_replies: Optional[List[str]] = Field(default=None, description="Leave empty unless your instructions tell you to offer choices. The short choices the customer can tap, in order.")
|
||||||
|
|
||||||
|
@field_validator('quick_replies', mode='before')
|
||||||
|
@classmethod
|
||||||
|
def clean_quick_replies(cls, value: Any) -> Optional[List[str]]:
|
||||||
|
"""Keep usable options only, never raise: a validation error here
|
||||||
|
would turn the whole reply into the generic error message."""
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return None
|
||||||
|
options: List[str] = []
|
||||||
|
seen = set()
|
||||||
|
for item in value:
|
||||||
|
text = _choice_text(item)
|
||||||
|
if text and text.casefold() not in seen:
|
||||||
|
seen.add(text.casefold())
|
||||||
|
options.append(text)
|
||||||
|
return options[:MAX_CHOICE_OPTIONS] or None
|
||||||
|
|
||||||
|
|
||||||
|
def _choice_text(item: Any) -> str:
|
||||||
|
"""One choice as clean text: a string, or the text inside an object a
|
||||||
|
model wrapped it in; anything else is dropped rather than shown raw."""
|
||||||
|
if isinstance(item, dict):
|
||||||
|
item = next((item[k] for k in _CHOICE_TEXT_KEYS if isinstance(item.get(k), str)), None)
|
||||||
|
if not isinstance(item, str):
|
||||||
|
return ""
|
||||||
|
return " ".join(item.split())[:MAX_CHOICE_LENGTH]
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class CrmConnectionOut(BaseModel):
|
||||||
|
"""A connected CRM as shown on the integrations page. Never exposes
|
||||||
|
credentials — only identity and health."""
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
provider: str
|
||||||
|
status: str
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
external_account_id: str
|
||||||
|
last_error: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
recent_failures: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class CrmTestResult(BaseModel):
|
||||||
|
ok: bool
|
||||||
|
account_name: Optional[str] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CrmCustomerSyncOut(BaseModel):
|
||||||
|
"""One CRM link for a person, shown on the People drawer."""
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
provider: str
|
||||||
|
contact_id: Optional[str] = None
|
||||||
|
record_url: Optional[str] = None
|
||||||
|
synced_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PersonCrmStatus(BaseModel):
|
||||||
|
"""CRM state for a person: which CRMs the org has connected, and where this
|
||||||
|
person is already synced."""
|
||||||
|
connected_providers: List[str]
|
||||||
|
synced: List[CrmCustomerSyncOut]
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Literal, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
# Reuse the ORM enums so the API contract can never drift from the DB.
|
||||||
|
from app.models.faq import (
|
||||||
|
DEFAULT_FAQ_CATEGORY,
|
||||||
|
FAQ_META_DESCRIPTION_MAX_LENGTH,
|
||||||
|
FAQ_META_TITLE_MAX_LENGTH,
|
||||||
|
FAQ_SLUG_MAX_LENGTH,
|
||||||
|
FAQ_URL_PATH_MAX_LENGTH,
|
||||||
|
FAQStatus,
|
||||||
|
)
|
||||||
|
from app.models.schemas.pagination import Pagination
|
||||||
|
from app.utils.urls import normalize_url
|
||||||
|
|
||||||
|
MAX_QUESTION_LENGTH = 300
|
||||||
|
# Articles imported as-is (help-center pages with steps/images) routinely run
|
||||||
|
# long; the column is TEXT and the public renderer paginates nothing, so the
|
||||||
|
# cap only guards against runaway payloads.
|
||||||
|
MAX_ANSWER_LENGTH = 20000
|
||||||
|
MAX_BULK_IDS = 200
|
||||||
|
|
||||||
|
|
||||||
|
class SeoFields(BaseModel):
|
||||||
|
"""Optional per-article SEO overrides, shared by create and update.
|
||||||
|
|
||||||
|
Blank strings are coerced to None so clearing a field in the admin UI
|
||||||
|
restores the derived default instead of publishing an empty tag. `slug` is
|
||||||
|
normalized and de-duplicated server-side (see resolve_faq_slug) — validation
|
||||||
|
here only bounds the input, to exactly what the columns accept (values that
|
||||||
|
are too long are rejected rather than silently truncated).
|
||||||
|
"""
|
||||||
|
slug: Optional[str] = Field(default=None, max_length=FAQ_SLUG_MAX_LENGTH)
|
||||||
|
# A URL path carried over from a help center the org migrated from, so the
|
||||||
|
# article stays reachable at the URL it already ranks for. Accepts a full
|
||||||
|
# URL or a bare path; normalized and checked server-side (normalize_url_path
|
||||||
|
# / resolve_faq_url_path). Blank clears it, restoring /a/{slug}.
|
||||||
|
url_path: Optional[str] = Field(default=None, max_length=FAQ_URL_PATH_MAX_LENGTH)
|
||||||
|
meta_title: Optional[str] = Field(default=None, max_length=FAQ_META_TITLE_MAX_LENGTH)
|
||||||
|
meta_description: Optional[str] = Field(
|
||||||
|
default=None, max_length=FAQ_META_DESCRIPTION_MAX_LENGTH
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("slug", "url_path", "meta_title", "meta_description")
|
||||||
|
@classmethod
|
||||||
|
def _blank_to_none(cls, v: Optional[str]) -> Optional[str]:
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
return v.strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
class FAQBase(BaseModel):
|
||||||
|
question: str = Field(min_length=1, max_length=MAX_QUESTION_LENGTH)
|
||||||
|
answer: str = Field(min_length=1, max_length=MAX_ANSWER_LENGTH)
|
||||||
|
category: str = Field(default=DEFAULT_FAQ_CATEGORY, min_length=1, max_length=100)
|
||||||
|
|
||||||
|
@field_validator("question", "answer", "category")
|
||||||
|
@classmethod
|
||||||
|
def _strip(cls, v: str) -> str:
|
||||||
|
v = v.strip()
|
||||||
|
if not v:
|
||||||
|
raise ValueError("must not be blank")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class FAQCreate(FAQBase, SeoFields):
|
||||||
|
status: FAQStatus = FAQStatus.DRAFT
|
||||||
|
|
||||||
|
|
||||||
|
class FAQUpdate(SeoFields):
|
||||||
|
"""Partial update — omitted fields keep their current values (apply with
|
||||||
|
model_dump(exclude_unset=True)), so e.g. a status-only PATCH can't silently
|
||||||
|
reset the category to its default."""
|
||||||
|
question: Optional[str] = Field(default=None, min_length=1, max_length=MAX_QUESTION_LENGTH)
|
||||||
|
answer: Optional[str] = Field(default=None, min_length=1, max_length=MAX_ANSWER_LENGTH)
|
||||||
|
category: Optional[str] = Field(default=None, min_length=1, max_length=100)
|
||||||
|
status: Optional[FAQStatus] = None
|
||||||
|
|
||||||
|
@field_validator("question", "answer", "category")
|
||||||
|
@classmethod
|
||||||
|
def _strip(cls, v: Optional[str]) -> Optional[str]:
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
v = v.strip()
|
||||||
|
if not v:
|
||||||
|
raise ValueError("must not be blank")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class FAQResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
question: str
|
||||||
|
answer: str
|
||||||
|
category: str
|
||||||
|
slug: Optional[str] = None
|
||||||
|
url_path: Optional[str] = None
|
||||||
|
source_url: Optional[str] = None
|
||||||
|
meta_title: Optional[str] = None
|
||||||
|
meta_description: Optional[str] = None
|
||||||
|
status: FAQStatus
|
||||||
|
knowledge_id: Optional[int] = None
|
||||||
|
source_label: Optional[str] = None
|
||||||
|
helpful_yes: int = 0
|
||||||
|
helpful_no: int = 0
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FAQListResponse(BaseModel):
|
||||||
|
faqs: List[FAQResponse]
|
||||||
|
pagination: Pagination
|
||||||
|
|
||||||
|
|
||||||
|
class FAQBulkStatusRequest(BaseModel):
|
||||||
|
faq_ids: List[UUID] = Field(min_length=1, max_length=MAX_BULK_IDS)
|
||||||
|
status: FAQStatus
|
||||||
|
|
||||||
|
|
||||||
|
class FAQBulkDeleteRequest(BaseModel):
|
||||||
|
faq_ids: List[UUID] = Field(min_length=1, max_length=MAX_BULK_IDS)
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateRequest(BaseModel):
|
||||||
|
"""Optional narrowing to specific knowledge sources; empty = new sources
|
||||||
|
only (those without FAQs yet)."""
|
||||||
|
knowledge_ids: Optional[List[int]] = Field(default=None, max_length=MAX_BULK_IDS)
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationSourceResponse(BaseModel):
|
||||||
|
"""One knowledge source in the generation picker."""
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
source_type: str
|
||||||
|
has_faqs: bool
|
||||||
|
pages: int
|
||||||
|
estimated_calls: int
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationEstimateResponse(BaseModel):
|
||||||
|
"""Confirm-dialog / source-picker numbers for a generation run.
|
||||||
|
remaining_credits is None when unlimited (OSS, no cap, or own model key)."""
|
||||||
|
total_sources: int
|
||||||
|
new_sources: int
|
||||||
|
pages: int
|
||||||
|
estimated_calls: int
|
||||||
|
metered: bool
|
||||||
|
remaining_credits: Optional[int] = None
|
||||||
|
sources: List[GenerationSourceResponse] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ImportRequest(BaseModel):
|
||||||
|
url: str = Field(min_length=1, max_length=2048)
|
||||||
|
# qa = single page, LLM extracts Q&A pairs (uses credits).
|
||||||
|
# articles = crawl the page's linked articles and import each as-is
|
||||||
|
# (HTML -> Markdown, images re-hosted, no LLM).
|
||||||
|
mode: Literal["qa", "articles"] = "qa"
|
||||||
|
# articles mode only: serve each imported article at the URL path it already
|
||||||
|
# has on the source help center, so the org's search rankings and inbound
|
||||||
|
# links survive the migration. Ignored for qa.
|
||||||
|
preserve_urls: bool = False
|
||||||
|
|
||||||
|
@field_validator("url")
|
||||||
|
@classmethod
|
||||||
|
def _https_url(cls, v: str) -> str:
|
||||||
|
# Scheme-level validation only; the import worker re-checks the URL
|
||||||
|
# against the SSRF guards in app.knowledge.url_safety before fetching.
|
||||||
|
return normalize_url(v, require_https=True)
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationJobResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
job_type: str
|
||||||
|
status: str
|
||||||
|
stage: str
|
||||||
|
progress_percentage: float
|
||||||
|
faqs_created: int
|
||||||
|
source_url: Optional[str] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
from app.models.help_center import DomainStatus, SSLStatus
|
||||||
|
from app.utils.urls import normalize_url
|
||||||
|
|
||||||
|
MAX_HEADER_LINKS = 6
|
||||||
|
_HEX_COLOR_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$")
|
||||||
|
# RFC 1035-ish hostname: labels of letters/digits/hyphens, at least one dot.
|
||||||
|
# The TLD alternation admits punycode labels (xn--...) that IDNA encoding of
|
||||||
|
# internationalized domains produces.
|
||||||
|
_HOSTNAME_RE = re.compile(
|
||||||
|
r"^(?=.{4,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,}|xn--[a-z0-9-]{2,})$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HeaderLink(BaseModel):
|
||||||
|
label: str = Field(min_length=1, max_length=40)
|
||||||
|
url: str = Field(min_length=1, max_length=2048)
|
||||||
|
|
||||||
|
@field_validator("url")
|
||||||
|
@classmethod
|
||||||
|
def _url(cls, v: str) -> str:
|
||||||
|
# Raises on blank/whitespace input rather than returning None into a
|
||||||
|
# non-optional str field.
|
||||||
|
return normalize_url(v)
|
||||||
|
|
||||||
|
|
||||||
|
class HelpCenterSettingsUpdate(BaseModel):
|
||||||
|
enabled: Optional[bool] = None
|
||||||
|
title: Optional[str] = Field(default=None, max_length=120)
|
||||||
|
description: Optional[str] = Field(default=None, max_length=300)
|
||||||
|
brand_color: Optional[str] = None
|
||||||
|
header_links: Optional[List[HeaderLink]] = Field(default=None, max_length=MAX_HEADER_LINKS)
|
||||||
|
cta_text: Optional[str] = Field(default=None, max_length=40)
|
||||||
|
cta_url: Optional[str] = Field(default=None, max_length=2048)
|
||||||
|
cta_enabled: Optional[bool] = None
|
||||||
|
auto_generate: Optional[bool] = None
|
||||||
|
agent_id: Optional[UUID] = None
|
||||||
|
ai_search_enabled: Optional[bool] = None
|
||||||
|
chat_widget_enabled: Optional[bool] = None
|
||||||
|
|
||||||
|
@field_validator("brand_color")
|
||||||
|
@classmethod
|
||||||
|
def _hex_color(cls, v: Optional[str]) -> Optional[str]:
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
v = v.strip()
|
||||||
|
if not _HEX_COLOR_RE.match(v):
|
||||||
|
raise ValueError("brand_color must be a hex color like #4338CA")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("cta_url")
|
||||||
|
@classmethod
|
||||||
|
def _cta_url(cls, v: Optional[str]) -> Optional[str]:
|
||||||
|
# Blank clears the CTA link; anything else is normalized.
|
||||||
|
if v is None or not v.strip():
|
||||||
|
return None
|
||||||
|
return normalize_url(v)
|
||||||
|
|
||||||
|
|
||||||
|
class HelpCenterAgentOption(BaseModel):
|
||||||
|
"""Org agents offered in the AI-search agent selector."""
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
has_widget: bool
|
||||||
|
|
||||||
|
|
||||||
|
class DnsRecord(BaseModel):
|
||||||
|
"""One row of the DNS-records table in the admin UI."""
|
||||||
|
type: str # CNAME | TXT
|
||||||
|
host: str
|
||||||
|
value: str
|
||||||
|
verified: bool
|
||||||
|
|
||||||
|
|
||||||
|
class DomainStatusResponse(BaseModel):
|
||||||
|
custom_domain: Optional[str] = None
|
||||||
|
domain_status: DomainStatus
|
||||||
|
ssl_status: SSLStatus
|
||||||
|
records: List[DnsRecord] = []
|
||||||
|
domain_verified_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class HelpCenterSettingsResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
enabled: bool
|
||||||
|
slug: Optional[str] = None
|
||||||
|
title: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
logo_url: Optional[str] = None
|
||||||
|
favicon_url: Optional[str] = None
|
||||||
|
brand_color: str
|
||||||
|
header_links: List[HeaderLink] = []
|
||||||
|
cta_text: Optional[str] = None
|
||||||
|
cta_url: Optional[str] = None
|
||||||
|
cta_enabled: bool
|
||||||
|
auto_generate: bool
|
||||||
|
agent_id: Optional[UUID] = None
|
||||||
|
ai_search_enabled: bool
|
||||||
|
chat_widget_enabled: bool
|
||||||
|
|
||||||
|
# Enriched by the API layer (not ORM columns):
|
||||||
|
live_url: Optional[str] = None
|
||||||
|
published_count: int = 0
|
||||||
|
plan_allowed: bool = True
|
||||||
|
agents: List[HelpCenterAgentOption] = []
|
||||||
|
domain: Optional[DomainStatusResponse] = None
|
||||||
|
|
||||||
|
@field_validator("header_links", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _coerce_null_json(cls, v):
|
||||||
|
return v or []
|
||||||
|
|
||||||
|
|
||||||
|
class DomainRequest(BaseModel):
|
||||||
|
domain: str = Field(min_length=4, max_length=255)
|
||||||
|
|
||||||
|
@field_validator("domain")
|
||||||
|
@classmethod
|
||||||
|
def _hostname(cls, v: str) -> str:
|
||||||
|
v = v.strip().lower().rstrip(".")
|
||||||
|
v = re.sub(r"^https?://", "", v).split("/")[0]
|
||||||
|
try:
|
||||||
|
v = v.encode("idna").decode("ascii")
|
||||||
|
except UnicodeError as exc:
|
||||||
|
raise ValueError("domain is not a valid hostname") from exc
|
||||||
|
if not _HOSTNAME_RE.match(v):
|
||||||
|
raise ValueError("domain is not a valid hostname")
|
||||||
|
return v
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class TriageResult(BaseModel):
|
||||||
|
"""Structured output of the AI triage pass over a new ticket."""
|
||||||
|
# Rewritten, clearer title. The original is preserved on the ticket.
|
||||||
|
title: str = Field(max_length=500)
|
||||||
|
priority: str = Field(description="urgent, high, medium or low")
|
||||||
|
severity: Optional[int] = Field(default=None, ge=1, le=3)
|
||||||
|
# Short intent classification, e.g. "billing_issue", "bug_report",
|
||||||
|
# "feature_request", "account_access", "outage".
|
||||||
|
intent: str = Field(max_length=100)
|
||||||
|
# 1-3 sentence internal summary of the issue.
|
||||||
|
summary: str = Field(max_length=2000)
|
||||||
|
# ["payments", "ledger"] — up to two short lowercase topic tags.
|
||||||
|
tags: List[str] = Field(default_factory=list, max_length=3)
|
||||||
|
confidence: float = Field(ge=0.0, le=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class HypothesisSpec(BaseModel):
|
||||||
|
"""One proposed root-cause hypothesis, before testing."""
|
||||||
|
title: str = Field(max_length=300)
|
||||||
|
rationale: str = Field(max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class HypothesisPlan(BaseModel):
|
||||||
|
"""Output of the hypothesis-generation step (H1..Hn, most likely first)."""
|
||||||
|
hypotheses: List[HypothesisSpec] = Field(min_length=1, max_length=5)
|
||||||
|
|
||||||
|
|
||||||
|
class HypothesisVerdict(BaseModel):
|
||||||
|
"""Outcome of testing a single hypothesis with bounded tool calls."""
|
||||||
|
status: str = Field(description="validated, invalidated or inconclusive")
|
||||||
|
confidence: float = Field(ge=0.0, le=1.0)
|
||||||
|
# What the evidence showed, citing the queries/results that decided it.
|
||||||
|
conclusion: str = Field(max_length=4000)
|
||||||
|
|
||||||
|
|
||||||
|
class RCATimelineEntry(BaseModel):
|
||||||
|
time: str = Field(max_length=100)
|
||||||
|
event: str = Field(max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class RCAResult(BaseModel):
|
||||||
|
"""Structured root-cause analysis synthesized from the tested hypotheses
|
||||||
|
and captured evidence."""
|
||||||
|
summary: str = Field(max_length=4000)
|
||||||
|
impact: Optional[str] = Field(default=None, max_length=4000)
|
||||||
|
timeline: List[RCATimelineEntry] = Field(default_factory=list, max_length=20)
|
||||||
|
investigation_log: Optional[str] = Field(default=None, max_length=6000)
|
||||||
|
contributing_factors: List[str] = Field(default_factory=list, max_length=10)
|
||||||
|
# Must cite hypotheses inline, e.g. "[H1 · 0.92]".
|
||||||
|
conclusion: str = Field(max_length=6000)
|
||||||
|
remediation: Optional[str] = Field(default=None, max_length=4000)
|
||||||
|
prevention: Optional[str] = Field(default=None, max_length=4000)
|
||||||
|
# Plain-language section a human reviews and sends to the customer.
|
||||||
|
customer_summary: str = Field(max_length=4000)
|
||||||
|
confidence: float = Field(ge=0.0, le=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
TRIAGE_JSON_TOOL_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {"type": "string", "description": "Clear, specific rewritten title."},
|
||||||
|
"priority": {"type": "string", "enum": ["urgent", "high", "medium", "low"]},
|
||||||
|
"severity": {"type": "integer", "minimum": 1, "maximum": 3},
|
||||||
|
"intent": {"type": "string", "description": "Short intent slug, e.g. bug_report."},
|
||||||
|
"summary": {"type": "string", "description": "1-3 sentence internal summary."},
|
||||||
|
"tags": {"type": "array", "items": {"type": "string"}, "description": "Up to two short lowercase topic tags."},
|
||||||
|
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
||||||
|
},
|
||||||
|
"required": ["title", "priority", "intent", "summary", "confidence"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
HYPOTHESES_JSON_TOOL_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"hypotheses": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 5,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {"type": "string", "description": "Short testable hypothesis."},
|
||||||
|
"rationale": {"type": "string", "description": "Why this is suspected and what evidence would confirm it."},
|
||||||
|
},
|
||||||
|
"required": ["title", "rationale"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["hypotheses"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
VERDICT_JSON_TOOL_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"status": {"type": "string", "enum": ["validated", "invalidated", "inconclusive"]},
|
||||||
|
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
||||||
|
"conclusion": {"type": "string", "description": "What the evidence showed, citing queries/results."},
|
||||||
|
},
|
||||||
|
"required": ["status", "confidence", "conclusion"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
RCA_JSON_TOOL_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"summary": {"type": "string"},
|
||||||
|
"impact": {"type": "string"},
|
||||||
|
"timeline": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"time": {"type": "string"}, "event": {"type": "string"}},
|
||||||
|
"required": ["time", "event"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"investigation_log": {"type": "string"},
|
||||||
|
"contributing_factors": {"type": "array", "items": {"type": "string"}},
|
||||||
|
"conclusion": {"type": "string", "description": "Cites hypotheses inline, e.g. [H1 · 0.92]."},
|
||||||
|
"remediation": {"type": "string"},
|
||||||
|
"prevention": {"type": "string"},
|
||||||
|
"customer_summary": {"type": "string", "description": "Plain language for the customer."},
|
||||||
|
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
||||||
|
},
|
||||||
|
"required": ["summary", "conclusion", "customer_summary", "confidence"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List, Any
|
||||||
|
from app.models.schemas.agent import AgentResponse
|
||||||
|
|
||||||
|
class AgentWithJiraConfig(AgentResponse):
|
||||||
|
"""Model for agent data with Jira configuration."""
|
||||||
|
tools: Optional[List[Any]] = None
|
||||||
|
is_default: bool = False
|
||||||
|
jira_enabled: bool = False
|
||||||
|
jira_project_key: Optional[str] = None
|
||||||
|
jira_issue_type_id: Optional[str] = None
|
||||||
|
groups: List[Any] = []
|
||||||
|
organization: Optional[Any] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
arbitrary_types_allowed = True
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
# Reuse the ORM enums so the API contract can never drift from the DB.
|
||||||
|
from app.models.lead_capture import LeadAssignmentMode, CrmSyncTarget
|
||||||
|
|
||||||
|
# Guardrails to keep the config small.
|
||||||
|
MAX_FIELDS = 30
|
||||||
|
|
||||||
|
|
||||||
|
class LeadField(BaseModel):
|
||||||
|
"""A detail to collect. Standard fields (email/name/company/phone) carry
|
||||||
|
standard=True; custom fields add a label and optional allowed values — which
|
||||||
|
the agent uses to steer/normalise the answer in conversation (no form)."""
|
||||||
|
key: str
|
||||||
|
standard: bool = False
|
||||||
|
enabled: bool = True
|
||||||
|
required: bool = False
|
||||||
|
label: Optional[str] = Field(default=None, max_length=100)
|
||||||
|
options: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LeadCaptureConfigBase(BaseModel):
|
||||||
|
enabled: bool = False
|
||||||
|
# GDPR: require the visitor's explicit consent before recording a lead.
|
||||||
|
require_consent: bool = True
|
||||||
|
# Optional free-text steering, appended to the agent's prompt.
|
||||||
|
guidance: Optional[str] = Field(default=None, max_length=2000)
|
||||||
|
fields: List[LeadField] = Field(default_factory=list, max_length=MAX_FIELDS)
|
||||||
|
# Routing config — persisted only; no CRM/Slack/assignment side effects in phase 1.
|
||||||
|
assignment_mode: LeadAssignmentMode = LeadAssignmentMode.NONE
|
||||||
|
assignment_target_user_id: Optional[UUID] = None
|
||||||
|
crm_sync_target: CrmSyncTarget = CrmSyncTarget.NONE
|
||||||
|
slack_notify_enabled: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class LeadCaptureConfigUpdate(LeadCaptureConfigBase):
|
||||||
|
"""Full-replace payload for the whole Lead Capture tab (mirrors how the
|
||||||
|
customization tab saves its entire object in one call)."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class LeadCaptureConfigResponse(LeadCaptureConfigBase):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
agent_id: UUID
|
||||||
|
|
||||||
|
@field_validator("fields", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _coerce_null_json(cls, v):
|
||||||
|
# JSON column defaults to NULL in the DB; present it as an empty list.
|
||||||
|
return v or []
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel, Field, field_serializer, validator
|
||||||
|
from typing import List, Optional, Dict, Union
|
||||||
|
from enum import Enum
|
||||||
|
from uuid import UUID
|
||||||
|
from app.models.mcp_tool import MCPTransportType
|
||||||
|
|
||||||
|
|
||||||
|
class MCPTransportTypeEnum(str, Enum):
|
||||||
|
STDIO = "stdio"
|
||||||
|
SSE = "sse"
|
||||||
|
HTTP = "http"
|
||||||
|
|
||||||
|
|
||||||
|
# Stand-in sent to the client in place of stored environment/header values.
|
||||||
|
# An update that sends it back means "keep what is stored", so correcting an
|
||||||
|
# unrelated field never costs a credential rotation — providers typically show
|
||||||
|
# an API key exactly once.
|
||||||
|
SECRET_MASK = "********"
|
||||||
|
|
||||||
|
# Ceiling on a connector's usage guidance. Enforced here so an over-long value
|
||||||
|
# is a clean 422, and again at injection so a row written before this existed
|
||||||
|
# can never blow the prompt.
|
||||||
|
MAX_USAGE_GUIDANCE_CHARS = 2000
|
||||||
|
|
||||||
|
|
||||||
|
def mask_secret_values(values: Optional[Dict[str, str]]) -> Optional[Dict[str, str]]:
|
||||||
|
"""Replace every value with the mask, keeping the keys so the operator can
|
||||||
|
still see which variables are set."""
|
||||||
|
if not values:
|
||||||
|
return values
|
||||||
|
return {key: SECRET_MASK for key in values}
|
||||||
|
|
||||||
|
|
||||||
|
def unmask_secret_values(
|
||||||
|
incoming: Optional[Dict[str, str]], stored: Optional[Dict[str, str]]
|
||||||
|
) -> Optional[Dict[str, str]]:
|
||||||
|
"""Resolve an incoming secret map against what is stored: a masked value
|
||||||
|
keeps the stored one, anything else is a deliberate replacement. A masked
|
||||||
|
key with nothing stored behind it is dropped rather than written empty."""
|
||||||
|
if not incoming:
|
||||||
|
return incoming
|
||||||
|
stored = stored or {}
|
||||||
|
resolved = {}
|
||||||
|
for key, value in incoming.items():
|
||||||
|
if value != SECRET_MASK:
|
||||||
|
resolved[key] = value
|
||||||
|
elif key in stored:
|
||||||
|
resolved[key] = stored[key]
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
class MCPToolBase(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=255, description="Display name for the MCP server")
|
||||||
|
description: Optional[str] = Field(None, description="Optional description of what this MCP server does")
|
||||||
|
usage_guidance: Optional[str] = Field(
|
||||||
|
None, max_length=MAX_USAGE_GUIDANCE_CHARS,
|
||||||
|
description="What this source holds and how to query it — given to the AI as tool guidance",
|
||||||
|
)
|
||||||
|
transport_type: MCPTransportTypeEnum
|
||||||
|
enabled: bool = Field(default=True, description="Whether this MCP tool is enabled")
|
||||||
|
|
||||||
|
# STDIO transport fields
|
||||||
|
command: Optional[str] = Field(None, description="Command to run the MCP server (e.g., 'npx', 'uvx', 'node')")
|
||||||
|
args: Optional[List[str]] = Field(None, description="Arguments to pass to the MCP server")
|
||||||
|
env_vars: Optional[Dict[str, str]] = Field(None, description="Environment variables as key-value pairs")
|
||||||
|
|
||||||
|
# SSE/HTTP transport fields
|
||||||
|
url: Optional[str] = Field(None, description="Server URL for SSE/HTTP transport")
|
||||||
|
headers: Optional[Dict[str, str]] = Field(None, description="HTTP headers as key-value pairs")
|
||||||
|
timeout: Optional[int] = Field(None, ge=1, le=300, description="Connection timeout in seconds (1-300)")
|
||||||
|
sse_read_timeout: Optional[int] = Field(None, ge=1, le=600, description="SSE read timeout in seconds (1-600)")
|
||||||
|
terminate_on_close: Optional[bool] = Field(default=True, description="Whether to terminate connection when client is closed (HTTP only)")
|
||||||
|
|
||||||
|
@validator('command')
|
||||||
|
def validate_stdio_command(cls, v, values):
|
||||||
|
if values.get('transport_type') == MCPTransportTypeEnum.STDIO and not v:
|
||||||
|
raise ValueError('Command is required for STDIO transport')
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator('url')
|
||||||
|
def validate_url(cls, v, values):
|
||||||
|
if values.get('transport_type') in [MCPTransportTypeEnum.SSE, MCPTransportTypeEnum.HTTP] and not v:
|
||||||
|
raise ValueError('URL is required for SSE/HTTP transport')
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class MCPToolCreate(MCPToolBase):
|
||||||
|
organization_id: Optional[UUID] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MCPToolUpdate(BaseModel):
|
||||||
|
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
description: Optional[str] = None
|
||||||
|
usage_guidance: Optional[str] = Field(
|
||||||
|
None, max_length=MAX_USAGE_GUIDANCE_CHARS,
|
||||||
|
description="What this source holds and how to query it — given to the AI as tool guidance",
|
||||||
|
)
|
||||||
|
transport_type: Optional[MCPTransportTypeEnum] = None
|
||||||
|
enabled: Optional[bool] = None
|
||||||
|
|
||||||
|
# STDIO transport fields
|
||||||
|
command: Optional[str] = None
|
||||||
|
args: Optional[List[str]] = None
|
||||||
|
env_vars: Optional[Dict[str, str]] = None
|
||||||
|
|
||||||
|
# SSE/HTTP transport fields
|
||||||
|
url: Optional[str] = None
|
||||||
|
headers: Optional[Dict[str, str]] = None
|
||||||
|
timeout: Optional[int] = Field(None, ge=1, le=300)
|
||||||
|
sse_read_timeout: Optional[int] = Field(None, ge=1, le=600)
|
||||||
|
terminate_on_close: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MCPToolResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: Optional[str]
|
||||||
|
# Deliberately not masked — guidance is prompt text, not a credential.
|
||||||
|
usage_guidance: Optional[str]
|
||||||
|
transport_type: MCPTransportTypeEnum
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
# STDIO transport fields
|
||||||
|
command: Optional[str]
|
||||||
|
args: Optional[List[str]]
|
||||||
|
env_vars: Optional[Dict[str, str]]
|
||||||
|
|
||||||
|
# SSE/HTTP transport fields
|
||||||
|
url: Optional[str]
|
||||||
|
headers: Optional[Dict[str, str]]
|
||||||
|
timeout: Optional[int]
|
||||||
|
sse_read_timeout: Optional[int]
|
||||||
|
terminate_on_close: Optional[bool]
|
||||||
|
|
||||||
|
organization_id: UUID
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
@field_serializer("env_vars", "headers")
|
||||||
|
def _mask_secrets(self, values: Optional[Dict[str, str]], _info):
|
||||||
|
"""Credentials never leave the server. The edit form round-trips the
|
||||||
|
mask, and the update endpoint resolves it back to the stored value."""
|
||||||
|
return mask_secret_values(values)
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class MCPToolReferencesResponse(BaseModel):
|
||||||
|
"""Where a connector is in use, so a delete confirmation can name what it
|
||||||
|
is about to break instead of asking blind."""
|
||||||
|
agents: List[str] = []
|
||||||
|
used_in_investigations: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class MCPToolToAgentCreate(BaseModel):
|
||||||
|
mcp_tool_id: int
|
||||||
|
agent_id: UUID
|
||||||
|
|
||||||
|
|
||||||
|
class MCPToolToAgentResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
mcp_tool_id: int
|
||||||
|
agent_id: UUID
|
||||||
|
created_at: datetime
|
||||||
|
mcp_tool: MCPToolResponse
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class AgentMCPToolsResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
mcp_tools: List[MCPToolResponse] = []
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class MCPToolTestResponse(BaseModel):
|
||||||
|
"""Result of a one-off connection test against a configured tool."""
|
||||||
|
success: bool
|
||||||
|
functions: List[str] = []
|
||||||
|
error: Optional[str] = None
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Dict
|
||||||
|
from app.models.notification import NotificationType
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
user_id: UUID
|
||||||
|
type: NotificationType
|
||||||
|
title: str
|
||||||
|
message: str
|
||||||
|
notification_metadata: Optional[Dict] = None
|
||||||
|
is_read: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationSettingsOut(BaseModel):
|
||||||
|
"""A user's chat push preferences."""
|
||||||
|
notify_new_chat: bool
|
||||||
|
notify_chat_transfer: bool
|
||||||
|
notify_chat_assigned: bool
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationSettingsUpdate(BaseModel):
|
||||||
|
"""Partial update — only the toggles the caller sent are applied."""
|
||||||
|
notify_new_chat: Optional[bool] = None
|
||||||
|
notify_chat_transfer: Optional[bool] = None
|
||||||
|
notify_chat_assigned: Optional[bool] = None
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from typing import Optional, Dict, TypedDict
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from app.models.schemas.user import UserResponse
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessHours(TypedDict):
|
||||||
|
start: str
|
||||||
|
end: str
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessHoursDict(TypedDict):
|
||||||
|
monday: BusinessHours
|
||||||
|
tuesday: BusinessHours
|
||||||
|
wednesday: BusinessHours
|
||||||
|
thursday: BusinessHours
|
||||||
|
friday: BusinessHours
|
||||||
|
saturday: BusinessHours
|
||||||
|
sunday: BusinessHours
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
domain: str
|
||||||
|
timezone: Optional[str] = 'UTC'
|
||||||
|
business_hours: Optional[BusinessHoursDict] = {
|
||||||
|
'monday': {'start': '09:00', 'end': '17:00', 'enabled': True},
|
||||||
|
'tuesday': {'start': '09:00', 'end': '17:00', 'enabled': True},
|
||||||
|
'wednesday': {'start': '09:00', 'end': '17:00', 'enabled': True},
|
||||||
|
'thursday': {'start': '09:00', 'end': '17:00', 'enabled': True},
|
||||||
|
'friday': {'start': '09:00', 'end': '17:00', 'enabled': True},
|
||||||
|
'saturday': {'start': '09:00', 'end': '17:00', 'enabled': False},
|
||||||
|
'sunday': {'start': '09:00', 'end': '17:00', 'enabled': False}
|
||||||
|
}
|
||||||
|
settings: Optional[Dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationCreate(OrganizationBase):
|
||||||
|
admin_email: EmailStr
|
||||||
|
admin_name: str
|
||||||
|
admin_password: str
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
domain: Optional[str] = None
|
||||||
|
timezone: Optional[str] = None
|
||||||
|
business_hours: Optional[BusinessHoursDict] = None
|
||||||
|
settings: Optional[Dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationCreateResponse(OrganizationBase):
|
||||||
|
id: UUID
|
||||||
|
is_active: bool
|
||||||
|
access_token: Optional[str] = None
|
||||||
|
refresh_token: Optional[str] = None
|
||||||
|
token_type: Optional[str] = None
|
||||||
|
user: UserResponse
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationResponse(OrganizationBase):
|
||||||
|
id: UUID
|
||||||
|
is_active: bool
|
||||||
|
access_token: Optional[str] = None
|
||||||
|
refresh_token: Optional[str] = None
|
||||||
|
token_type: Optional[str] = None
|
||||||
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class Pagination(BaseModel):
|
||||||
|
"""Standard list-endpoint pagination block (shape matches the knowledge
|
||||||
|
API's inline dicts: total / page / page_size / total_pages)."""
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
total_pages: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def build(cls, total: int, page: int, page_size: int) -> "Pagination":
|
||||||
|
return cls(
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
total_pages=max(1, math.ceil(total / page_size)) if total else 0,
|
||||||
|
)
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.models.customer import LeadStage
|
||||||
|
|
||||||
|
|
||||||
|
class PersonListItem(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
name: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
is_anonymous: bool = False
|
||||||
|
lead_stage: LeadStage
|
||||||
|
qualified: bool = False
|
||||||
|
source: Optional[Dict[str, Any]] = None
|
||||||
|
captured_at: Optional[datetime] = None
|
||||||
|
last_activity: Optional[datetime] = None
|
||||||
|
synced: bool = False # phase 1: CRM sync not implemented
|
||||||
|
|
||||||
|
|
||||||
|
class PeopleListResponse(BaseModel):
|
||||||
|
items: List[PersonListItem]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class PeopleStats(BaseModel):
|
||||||
|
total_people: int
|
||||||
|
# Engaged-but-unidentified browser sessions — the lead-capture funnel's
|
||||||
|
# raw top, shown as a count beside the tabs, not as directory rows.
|
||||||
|
anonymous: int = 0
|
||||||
|
new_leads_7d: int
|
||||||
|
customers: int
|
||||||
|
synced_to_crm: int = 0 # phase 1: always 0
|
||||||
|
|
||||||
|
|
||||||
|
class TimelineEntry(BaseModel):
|
||||||
|
stage: str
|
||||||
|
at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class PersonConversation(BaseModel):
|
||||||
|
session_id: UUID
|
||||||
|
agent_name: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
last_message: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PersonDetail(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
name: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
# True when something can reach/recognize this person (real email, phone,
|
||||||
|
# or a qualifying capture). Gates Mark-as-customer and outbound actions.
|
||||||
|
identified: bool = False
|
||||||
|
is_anonymous: bool = False
|
||||||
|
lead_stage: LeadStage
|
||||||
|
qualified: bool = False
|
||||||
|
source: Optional[Dict[str, Any]] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
lead_qualified_at: Optional[datetime] = None
|
||||||
|
meta_data: Optional[Dict[str, Any]] = None
|
||||||
|
# AI-written qualification summary from the conversation.
|
||||||
|
summary: Optional[str] = None
|
||||||
|
# Contact/detail fields captured across submissions.
|
||||||
|
captured_attributes: Dict[str, Any] = {}
|
||||||
|
timeline: List[TimelineEntry] = []
|
||||||
|
conversations: List[PersonConversation] = []
|
||||||
|
|
||||||
|
|
||||||
|
class PersonUpdateRequest(BaseModel):
|
||||||
|
"""Drawer edit: both fields optional; phone "" clears the number."""
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class PermissionResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: Optional[str]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class RoleBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
is_default: Optional[bool] = False
|
||||||
|
|
||||||
|
class RoleCreate(RoleBase):
|
||||||
|
permissions: List["PermissionResponse"]
|
||||||
|
|
||||||
|
class RoleUpdate(RoleBase):
|
||||||
|
permissions: Optional[List["PermissionResponse"]] = None
|
||||||
|
|
||||||
|
class SimpleRoleResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class RoleResponse(RoleBase):
|
||||||
|
id: int
|
||||||
|
organization_id: Optional[UUID] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
permissions: Optional[List["PermissionResponse"]] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
RoleResponse.update_forward_refs()
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.models.schemas.shopify.shopify_shop import (
|
||||||
|
ShopifyShopBase,
|
||||||
|
ShopifyShopCreate,
|
||||||
|
ShopifyShopUpdate,
|
||||||
|
ShopifyShopInDB,
|
||||||
|
ShopifyShop,
|
||||||
|
)
|
||||||
|
from .agent_shopify_config import (
|
||||||
|
AgentShopifyConfigBase,
|
||||||
|
AgentShopifyConfigCreate,
|
||||||
|
AgentShopifyConfigUpdate,
|
||||||
|
AgentShopifyConfig
|
||||||
|
)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AgentShopifyConfigBase(BaseModel):
|
||||||
|
"""Base Shopify agent configuration schema."""
|
||||||
|
enabled: bool
|
||||||
|
shop_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentShopifyConfigCreate(AgentShopifyConfigBase):
|
||||||
|
"""Schema for creating a new Shopify agent configuration."""
|
||||||
|
agent_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class AgentShopifyConfigUpdate(AgentShopifyConfigBase):
|
||||||
|
"""Schema for updating a Shopify agent configuration."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ShopifyShopInfo(BaseModel):
|
||||||
|
"""Basic info about a Shopify shop."""
|
||||||
|
id: str
|
||||||
|
shop_domain: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
orm_mode = True
|
||||||
|
|
||||||
|
|
||||||
|
class AgentShopifyConfigInDB(AgentShopifyConfigBase):
|
||||||
|
"""Schema for Shopify agent configuration stored in the database."""
|
||||||
|
id: str
|
||||||
|
agent_id: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
orm_mode = True
|
||||||
|
|
||||||
|
|
||||||
|
class AgentShopifyConfig(AgentShopifyConfigInDB):
|
||||||
|
"""Schema for API response."""
|
||||||
|
shop: Optional[ShopifyShopInfo] = None
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
class ShopifyShopBase(BaseModel):
|
||||||
|
"""Base schema for ShopifyShop"""
|
||||||
|
shop_domain: str = Field(..., description="Shopify shop domain")
|
||||||
|
organization_id: Optional[str] = Field(None, description="ID of the organization this shop belongs to")
|
||||||
|
|
||||||
|
class ShopifyShopCreate(ShopifyShopBase):
|
||||||
|
"""Schema for creating a shopify shop"""
|
||||||
|
access_token: Optional[str] = Field(None, description="Shopify access token")
|
||||||
|
scope: Optional[str] = Field(None, description="Shopify OAuth scopes")
|
||||||
|
is_installed: bool = Field(False, description="Whether the app is installed on this shop")
|
||||||
|
oauth_state: Optional[str] = Field(None, description="CSRF protection state parameter")
|
||||||
|
oauth_state_expiry: Optional[datetime] = Field(None, description="CSRF protection state parameter expiry")
|
||||||
|
|
||||||
|
class ShopifyShopUpdate(BaseModel):
|
||||||
|
"""Schema for updating a shopify shop"""
|
||||||
|
access_token: Optional[str] = None
|
||||||
|
scope: Optional[str] = None
|
||||||
|
is_installed: Optional[bool] = None
|
||||||
|
organization_id: Optional[str] = None
|
||||||
|
oauth_state: Optional[str] = None
|
||||||
|
oauth_state_expiry: Optional[datetime] = None
|
||||||
|
|
||||||
|
class ShopifyShopInDB(ShopifyShopBase):
|
||||||
|
"""Schema for shopify shop from database"""
|
||||||
|
id: str
|
||||||
|
scope: Optional[str] = None
|
||||||
|
is_installed: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
# Add JSON encoders to convert UUID to string
|
||||||
|
json_encoders = {
|
||||||
|
UUID: str # Convert UUID to string
|
||||||
|
}
|
||||||
|
|
||||||
|
class ShopifyShop(ShopifyShopInDB):
|
||||||
|
"""Schema for shopify shop response"""
|
||||||
|
pass
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
|
||||||
|
|
||||||
|
# Reuse the ORM enums so the API contract can never drift from the DB.
|
||||||
|
from app.models.investigation import InvestigationRunStatus, InvestigationRunType
|
||||||
|
from app.models.ticket import ResolutionOutcome, TicketPriority, TicketSource, TicketStatus
|
||||||
|
from app.models.ticket_activity import TicketActivityType, TicketActorType
|
||||||
|
from app.models.schemas.pagination import Pagination
|
||||||
|
|
||||||
|
MAX_TITLE_LENGTH = 500
|
||||||
|
MAX_DESCRIPTION_LENGTH = 20000
|
||||||
|
MAX_COMMENT_LENGTH = 10000
|
||||||
|
MAX_TAGS = 5
|
||||||
|
|
||||||
|
|
||||||
|
class TicketCreate(BaseModel):
|
||||||
|
title: str = Field(min_length=1, max_length=MAX_TITLE_LENGTH)
|
||||||
|
description: Optional[str] = Field(default=None, max_length=MAX_DESCRIPTION_LENGTH)
|
||||||
|
priority: TicketPriority = TicketPriority.MEDIUM
|
||||||
|
severity: Optional[int] = Field(default=None, ge=1, le=3)
|
||||||
|
tags: Optional[List[str]] = Field(default=None, max_length=MAX_TAGS)
|
||||||
|
customer_id: Optional[UUID] = None
|
||||||
|
# Manual tickets: resolve/create the customer by email so direct-email
|
||||||
|
# notifications have somewhere to go. Ignored when customer_id is set.
|
||||||
|
customer_email: Optional[EmailStr] = None
|
||||||
|
customer_name: Optional[str] = Field(default=None, max_length=200)
|
||||||
|
session_id: Optional[UUID] = None
|
||||||
|
assignee_user_id: Optional[UUID] = None
|
||||||
|
group_id: Optional[UUID] = None
|
||||||
|
|
||||||
|
@field_validator("title")
|
||||||
|
@classmethod
|
||||||
|
def _strip_title(cls, v: str) -> str:
|
||||||
|
v = v.strip()
|
||||||
|
if not v:
|
||||||
|
raise ValueError("must not be blank")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class TicketUpdate(BaseModel):
|
||||||
|
"""Partial update — omitted fields keep their current values (apply with
|
||||||
|
model_dump(exclude_unset=True))."""
|
||||||
|
title: Optional[str] = Field(default=None, min_length=1, max_length=MAX_TITLE_LENGTH)
|
||||||
|
description: Optional[str] = Field(default=None, max_length=MAX_DESCRIPTION_LENGTH)
|
||||||
|
# Set/replace the ticket's customer by email (find-or-create in the org).
|
||||||
|
customer_email: Optional[EmailStr] = None
|
||||||
|
customer_name: Optional[str] = Field(default=None, max_length=200)
|
||||||
|
status: Optional[TicketStatus] = None
|
||||||
|
priority: Optional[TicketPriority] = None
|
||||||
|
severity: Optional[int] = Field(default=None, ge=1, le=3)
|
||||||
|
tags: Optional[List[str]] = Field(default=None, max_length=MAX_TAGS)
|
||||||
|
assignee_user_id: Optional[UUID] = None
|
||||||
|
group_id: Optional[UUID] = None
|
||||||
|
resolution_outcome: Optional[ResolutionOutcome] = None
|
||||||
|
resolution_summary: Optional[str] = Field(default=None, max_length=MAX_DESCRIPTION_LENGTH)
|
||||||
|
customer_resolution_message: Optional[str] = Field(default=None, max_length=MAX_DESCRIPTION_LENGTH)
|
||||||
|
|
||||||
|
|
||||||
|
class TicketCommentCreate(BaseModel):
|
||||||
|
body: str = Field(min_length=1, max_length=MAX_COMMENT_LENGTH)
|
||||||
|
is_internal: bool = True
|
||||||
|
|
||||||
|
@field_validator("body")
|
||||||
|
@classmethod
|
||||||
|
def _strip_body(cls, v: str) -> str:
|
||||||
|
v = v.strip()
|
||||||
|
if not v:
|
||||||
|
raise ValueError("must not be blank")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class TicketReopen(BaseModel):
|
||||||
|
reason: Optional[str] = Field(default=None, max_length=MAX_COMMENT_LENGTH)
|
||||||
|
|
||||||
|
|
||||||
|
class TicketResolve(BaseModel):
|
||||||
|
outcome: ResolutionOutcome = ResolutionOutcome.FIXED
|
||||||
|
resolution_summary: Optional[str] = Field(default=None, max_length=MAX_DESCRIPTION_LENGTH)
|
||||||
|
customer_message: Optional[str] = Field(default=None, max_length=MAX_DESCRIPTION_LENGTH)
|
||||||
|
|
||||||
|
|
||||||
|
class TicketUserOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: UUID
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TicketCustomerOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: UUID
|
||||||
|
email: Optional[str] = None
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TicketActivityOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: UUID
|
||||||
|
activity_type: TicketActivityType
|
||||||
|
actor_type: TicketActorType
|
||||||
|
actor_user_id: Optional[UUID] = None
|
||||||
|
actor_name: Optional[str] = None
|
||||||
|
body: Optional[str] = None
|
||||||
|
is_internal: bool = True
|
||||||
|
activity_metadata: Optional[dict] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class InvestigationRunOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: UUID
|
||||||
|
run_type: InvestigationRunType
|
||||||
|
status: InvestigationRunStatus
|
||||||
|
trigger: str
|
||||||
|
error: Optional[str] = None
|
||||||
|
tool_calls_used: int = 0
|
||||||
|
max_tool_calls: int = 25
|
||||||
|
llm_calls: int = 0
|
||||||
|
input_tokens: int = 0
|
||||||
|
output_tokens: int = 0
|
||||||
|
metered: bool = False
|
||||||
|
model_name: Optional[str] = None
|
||||||
|
# {"configured": N, "loaded": M, "failed": [{"name", "error"}]}
|
||||||
|
connector_status: Optional[dict] = None
|
||||||
|
started_at: Optional[datetime] = None
|
||||||
|
finished_at: Optional[datetime] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class HypothesisOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: UUID
|
||||||
|
idx: int
|
||||||
|
title: str
|
||||||
|
rationale: Optional[str] = None
|
||||||
|
status: str
|
||||||
|
confidence: Optional[float] = None
|
||||||
|
conclusion: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class InvestigationEventOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: UUID
|
||||||
|
hypothesis_id: Optional[UUID] = None
|
||||||
|
seq: int
|
||||||
|
event_type: str
|
||||||
|
label: Optional[str] = None
|
||||||
|
tool_name: Optional[str] = None
|
||||||
|
connector_name: Optional[str] = None
|
||||||
|
tool_input: Optional[str] = None
|
||||||
|
tool_result: Optional[str] = None
|
||||||
|
duration_ms: Optional[int] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RCADocumentOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: UUID
|
||||||
|
run_id: Optional[UUID] = None
|
||||||
|
version: int
|
||||||
|
summary: Optional[str] = None
|
||||||
|
impact: Optional[str] = None
|
||||||
|
timeline: Optional[list] = None
|
||||||
|
investigation_log: Optional[str] = None
|
||||||
|
contributing_factors: Optional[list] = None
|
||||||
|
conclusion: Optional[str] = None
|
||||||
|
remediation: Optional[str] = None
|
||||||
|
prevention: Optional[str] = None
|
||||||
|
customer_summary: Optional[str] = None
|
||||||
|
confidence: Optional[float] = None
|
||||||
|
is_partial: bool = False
|
||||||
|
generated_by: str = "ai"
|
||||||
|
reviewed_by_user_id: Optional[UUID] = None
|
||||||
|
reviewed_by_name: Optional[str] = None
|
||||||
|
reviewed_at: Optional[datetime] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProposalOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: UUID
|
||||||
|
run_id: Optional[UUID] = None
|
||||||
|
summary: str
|
||||||
|
customer_message: Optional[str] = None
|
||||||
|
confidence: Optional[float] = None
|
||||||
|
status: str
|
||||||
|
decided_by_user_id: Optional[UUID] = None
|
||||||
|
decided_by_name: Optional[str] = None
|
||||||
|
decided_at: Optional[datetime] = None
|
||||||
|
reject_reason: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProposalRejectRequest(BaseModel):
|
||||||
|
reason: Optional[str] = Field(default=None, max_length=MAX_COMMENT_LENGTH)
|
||||||
|
# Re-enqueue a refined investigation with the reason as context.
|
||||||
|
reinvestigate: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class InvestigationDetailResponse(BaseModel):
|
||||||
|
"""Glass-box payload for the ticket detail page: the (latest or requested)
|
||||||
|
investigation run with its hypotheses, evidence events and RCA document."""
|
||||||
|
run: Optional[InvestigationRunOut] = None
|
||||||
|
hypotheses: List[HypothesisOut] = []
|
||||||
|
events: List[InvestigationEventOut] = []
|
||||||
|
rca: Optional[RCADocumentOut] = None
|
||||||
|
# Latest proposal (any status) — drives the approval banner.
|
||||||
|
proposal: Optional[ProposalOut] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RcaUpdate(BaseModel):
|
||||||
|
customer_summary: Optional[str] = Field(default=None, max_length=MAX_DESCRIPTION_LENGTH)
|
||||||
|
# Stamp the caller as reviewer ("generated by AI · reviewed by …").
|
||||||
|
mark_reviewed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class InvestigateRequest(BaseModel):
|
||||||
|
run_type: InvestigationRunType = InvestigationRunType.INVESTIGATION
|
||||||
|
# Free-text context for the run, e.g. what to focus on this time.
|
||||||
|
context_note: Optional[str] = Field(default=None, max_length=4000)
|
||||||
|
|
||||||
|
|
||||||
|
class TicketOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: UUID
|
||||||
|
ticket_number: int
|
||||||
|
display_number: str
|
||||||
|
organization_id: UUID
|
||||||
|
customer_id: Optional[UUID] = None
|
||||||
|
title: str
|
||||||
|
original_title: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
status: TicketStatus
|
||||||
|
priority: TicketPriority
|
||||||
|
severity: Optional[int] = None
|
||||||
|
source: TicketSource
|
||||||
|
intent: Optional[str] = None
|
||||||
|
triage_confidence: Optional[float] = None
|
||||||
|
ai_summary: Optional[str] = None
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
assignee_user_id: Optional[UUID] = None
|
||||||
|
group_id: Optional[UUID] = None
|
||||||
|
agent_id: Optional[UUID] = None
|
||||||
|
duplicate_of_ticket_id: Optional[UUID] = None
|
||||||
|
resolution_outcome: Optional[ResolutionOutcome] = None
|
||||||
|
resolution_summary: Optional[str] = None
|
||||||
|
customer_resolution_message: Optional[str] = None
|
||||||
|
first_response_at: Optional[datetime] = None
|
||||||
|
resolved_at: Optional[datetime] = None
|
||||||
|
closed_at: Optional[datetime] = None
|
||||||
|
confirmation_requested_at: Optional[datetime] = None
|
||||||
|
reopened_count: int = 0
|
||||||
|
csat_requested_at: Optional[datetime] = None
|
||||||
|
csat_score: Optional[int] = None
|
||||||
|
csat_responded_at: Optional[datetime] = None
|
||||||
|
external_ref_type: Optional[str] = None
|
||||||
|
external_ref_id: Optional[str] = None
|
||||||
|
external_ref_url: Optional[str] = None
|
||||||
|
created_by_user_id: Optional[UUID] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
# Derived UI fields.
|
||||||
|
ai_state: Optional[str] = None
|
||||||
|
sla_due_at: Optional[datetime] = None
|
||||||
|
assignee: Optional[TicketUserOut] = None
|
||||||
|
customer: Optional[TicketCustomerOut] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TicketListItem(BaseModel):
|
||||||
|
"""Slim row for the list view."""
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: UUID
|
||||||
|
ticket_number: int
|
||||||
|
display_number: str
|
||||||
|
title: str
|
||||||
|
status: TicketStatus
|
||||||
|
priority: TicketPriority
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
assignee_user_id: Optional[UUID] = None
|
||||||
|
assignee_name: Optional[str] = None
|
||||||
|
ai_state: Optional[str] = None
|
||||||
|
sla_due_at: Optional[datetime] = None
|
||||||
|
resolved_at: Optional[datetime] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TicketListResponse(BaseModel):
|
||||||
|
tickets: List[TicketListItem]
|
||||||
|
pagination: Pagination
|
||||||
|
|
||||||
|
|
||||||
|
class TicketDetailResponse(BaseModel):
|
||||||
|
ticket: TicketOut
|
||||||
|
activities: List[TicketActivityOut]
|
||||||
|
runs: List[InvestigationRunOut]
|
||||||
|
linked_session_ids: List[UUID]
|
||||||
|
possible_duplicates: List[TicketListItem] = []
|
||||||
|
# Whether any outbound path to the customer exists (linked conversation or
|
||||||
|
# customer email) — gates the "send to customer" affordances in the UI.
|
||||||
|
can_notify_customer: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class TicketStats(BaseModel):
|
||||||
|
open: int
|
||||||
|
awaiting_approval: int
|
||||||
|
sla_breaching: int
|
||||||
|
ai_resolved_pct_7d: Optional[float] = None
|
||||||
|
# CSAT over the trailing csat_window_days, overall and split by resolver.
|
||||||
|
csat_avg: Optional[float] = None
|
||||||
|
csat_ai_avg: Optional[float] = None
|
||||||
|
csat_human_avg: Optional[float] = None
|
||||||
|
csat_responses: int = 0
|
||||||
|
csat_window_days: int = 30
|
||||||
|
|
||||||
|
|
||||||
|
class SlaTarget(BaseModel):
|
||||||
|
first_response_minutes: int = Field(gt=0, le=60 * 24 * 30)
|
||||||
|
resolution_minutes: int = Field(gt=0, le=60 * 24 * 90)
|
||||||
|
|
||||||
|
|
||||||
|
class TicketSettingsOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
autonomy_level: int
|
||||||
|
auto_investigate_on_create: bool
|
||||||
|
min_confidence_to_auto_resolve: float
|
||||||
|
confirmation_timeout_hours: int
|
||||||
|
csat_enabled: bool
|
||||||
|
sla_targets: Optional[dict] = None
|
||||||
|
created_template: Optional[str] = None
|
||||||
|
resolved_template: Optional[str] = None
|
||||||
|
jira_escalation_enabled: bool
|
||||||
|
jira_escalation_priority: Optional[str] = None
|
||||||
|
investigation_mcp_tool_ids: Optional[List[int]] = None
|
||||||
|
alert_webhook_enabled: bool
|
||||||
|
# Only exposed to settings managers; forms the intake URL path.
|
||||||
|
alert_webhook_secret: Optional[str] = None
|
||||||
|
max_tool_calls_per_run: int
|
||||||
|
max_runs_per_ticket: int
|
||||||
|
|
||||||
|
|
||||||
|
class TicketSettingsUpdate(BaseModel):
|
||||||
|
autonomy_level: Optional[int] = Field(default=None, ge=1, le=3)
|
||||||
|
auto_investigate_on_create: Optional[bool] = None
|
||||||
|
min_confidence_to_auto_resolve: Optional[float] = Field(default=None, ge=0.0, le=1.0)
|
||||||
|
confirmation_timeout_hours: Optional[int] = Field(default=None, ge=1, le=24 * 30)
|
||||||
|
csat_enabled: Optional[bool] = None
|
||||||
|
sla_targets: Optional[dict] = None
|
||||||
|
created_template: Optional[str] = Field(default=None, max_length=2000)
|
||||||
|
resolved_template: Optional[str] = Field(default=None, max_length=2000)
|
||||||
|
jira_escalation_enabled: Optional[bool] = None
|
||||||
|
jira_escalation_priority: Optional[TicketPriority] = None
|
||||||
|
investigation_mcp_tool_ids: Optional[List[int]] = None
|
||||||
|
alert_webhook_enabled: Optional[bool] = None
|
||||||
|
max_tool_calls_per_run: Optional[int] = Field(default=None, ge=1, le=100)
|
||||||
|
max_runs_per_ticket: Optional[int] = Field(default=None, ge=1, le=10)
|
||||||
|
|
||||||
|
@field_validator("sla_targets")
|
||||||
|
@classmethod
|
||||||
|
def _validate_sla(cls, v: Optional[dict]) -> Optional[dict]:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
for priority, target in v.items():
|
||||||
|
if priority not in [p.value for p in TicketPriority]:
|
||||||
|
raise ValueError(f"unknown priority '{priority}'")
|
||||||
|
SlaTarget.model_validate(target)
|
||||||
|
return v
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
ENGINES = ("postgresql", "mysql")
|
||||||
|
ROW_SCOPE_KEYS = ("email", "phone")
|
||||||
|
|
||||||
|
|
||||||
|
class DBConnectorColumnOut(BaseModel):
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
|
||||||
|
|
||||||
|
class DBConnectorTableOut(BaseModel):
|
||||||
|
schema_name: str = Field(alias="schema")
|
||||||
|
table: str
|
||||||
|
columns: List[DBConnectorColumnOut]
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SSHTunnelFields(BaseModel):
|
||||||
|
"""Optional SSH bastion/jump-host tunnel. Auth is by private key
|
||||||
|
(preferred) or password; secrets are write-only."""
|
||||||
|
ssh_enabled: bool = False
|
||||||
|
ssh_host: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
ssh_port: int = Field(default=22, ge=1, le=65535)
|
||||||
|
ssh_username: Optional[str] = Field(default=None, max_length=200)
|
||||||
|
ssh_password: Optional[str] = Field(default=None, max_length=1000)
|
||||||
|
ssh_private_key: Optional[str] = Field(default=None, max_length=20000)
|
||||||
|
ssh_private_key_passphrase: Optional[str] = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class DBConnectorDiscoverRequest(SSHTunnelFields):
|
||||||
|
"""Pre-save connection test: credentials go over TLS, are used once for
|
||||||
|
discovery, and are never persisted by this endpoint."""
|
||||||
|
engine: str = Field(pattern=f"^({'|'.join(ENGINES)})$")
|
||||||
|
host: str = Field(min_length=1, max_length=500)
|
||||||
|
port: int = Field(ge=1, le=65535)
|
||||||
|
database: str = Field(min_length=1, max_length=200)
|
||||||
|
username: str = Field(min_length=1, max_length=200)
|
||||||
|
password: str = Field(min_length=1, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class DBConnectorDiscoverResponse(BaseModel):
|
||||||
|
ok: bool
|
||||||
|
error: Optional[str] = None
|
||||||
|
tables: List[DBConnectorTableOut] = []
|
||||||
|
|
||||||
|
|
||||||
|
_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_$]*$")
|
||||||
|
_TABLE_REF = re.compile(r"^[A-Za-z_][A-Za-z0-9_$]*(\.[A-Za-z_][A-Za-z0-9_$]*)?$")
|
||||||
|
|
||||||
|
ROW_SCOPE_DESCRIPTION = (
|
||||||
|
'Tables restricted to the ticket customer\'s own rows, as '
|
||||||
|
'{"schema.table": "customer_column"}. Listed tables are rewritten into a '
|
||||||
|
"pre-filtered subquery; tables left out are readable in full."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_row_scope(value: Optional[Dict[str, str]]) -> Optional[Dict[str, str]]:
|
||||||
|
"""Both halves land in generated SQL as identifiers. sqlglot quotes them,
|
||||||
|
but a bad value should be refused at the edge rather than quoted and
|
||||||
|
executed — a typo'd column silently scopes nothing useful."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if len(value) > 500:
|
||||||
|
raise ValueError("Too many row-scope entries")
|
||||||
|
cleaned: Dict[str, str] = {}
|
||||||
|
for table, column in value.items():
|
||||||
|
table = (table or "").strip()
|
||||||
|
column = (column or "").strip()
|
||||||
|
if not table or not column:
|
||||||
|
continue
|
||||||
|
if not _TABLE_REF.match(table):
|
||||||
|
raise ValueError(f"Invalid table reference in row scope: {table!r}")
|
||||||
|
if not _IDENTIFIER.match(column):
|
||||||
|
raise ValueError(f"Invalid column name in row scope: {column!r}")
|
||||||
|
cleaned[table.lower()] = column
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
class DBConnectorCreate(SSHTunnelFields):
|
||||||
|
name: str = Field(min_length=1, max_length=200)
|
||||||
|
engine: str = Field(pattern=f"^({'|'.join(ENGINES)})$")
|
||||||
|
host: str = Field(min_length=1, max_length=500)
|
||||||
|
port: int = Field(ge=1, le=65535)
|
||||||
|
database: str = Field(min_length=1, max_length=200)
|
||||||
|
username: str = Field(min_length=1, max_length=200)
|
||||||
|
password: str = Field(min_length=1, max_length=500)
|
||||||
|
enabled: bool = True
|
||||||
|
# Nothing is queryable unless explicitly listed ("schema.table").
|
||||||
|
allowed_tables: List[str] = Field(default_factory=list, max_length=500)
|
||||||
|
masked_columns: List[str] = Field(default_factory=list, max_length=500)
|
||||||
|
row_scope: Dict[str, str] = Field(
|
||||||
|
default_factory=dict, description=ROW_SCOPE_DESCRIPTION
|
||||||
|
)
|
||||||
|
row_scope_key: str = Field(
|
||||||
|
default="email", pattern=f"^({'|'.join(ROW_SCOPE_KEYS)})$"
|
||||||
|
)
|
||||||
|
max_rows: int = Field(default=100, ge=1, le=1000)
|
||||||
|
statement_timeout_ms: int = Field(default=5000, ge=100, le=30000)
|
||||||
|
|
||||||
|
@field_validator("row_scope")
|
||||||
|
@classmethod
|
||||||
|
def _check_row_scope(cls, value):
|
||||||
|
return validate_row_scope(value) or {}
|
||||||
|
|
||||||
|
|
||||||
|
class DBConnectorUpdate(BaseModel):
|
||||||
|
name: Optional[str] = Field(default=None, min_length=1, max_length=200)
|
||||||
|
host: Optional[str] = Field(default=None, min_length=1, max_length=500)
|
||||||
|
port: Optional[int] = Field(default=None, ge=1, le=65535)
|
||||||
|
database: Optional[str] = Field(default=None, min_length=1, max_length=200)
|
||||||
|
username: Optional[str] = Field(default=None, min_length=1, max_length=200)
|
||||||
|
# Omit to keep the stored password.
|
||||||
|
password: Optional[str] = Field(default=None, min_length=1, max_length=500)
|
||||||
|
enabled: Optional[bool] = None
|
||||||
|
allowed_tables: Optional[List[str]] = Field(default=None, max_length=500)
|
||||||
|
masked_columns: Optional[List[str]] = Field(default=None, max_length=500)
|
||||||
|
row_scope: Optional[Dict[str, str]] = Field(
|
||||||
|
default=None, description=ROW_SCOPE_DESCRIPTION
|
||||||
|
)
|
||||||
|
row_scope_key: Optional[str] = Field(
|
||||||
|
default=None, pattern=f"^({'|'.join(ROW_SCOPE_KEYS)})$"
|
||||||
|
)
|
||||||
|
max_rows: Optional[int] = Field(default=None, ge=1, le=1000)
|
||||||
|
statement_timeout_ms: Optional[int] = Field(default=None, ge=100, le=30000)
|
||||||
|
# SSH tunnel — omit a secret to keep the stored one.
|
||||||
|
ssh_enabled: Optional[bool] = None
|
||||||
|
ssh_host: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
ssh_port: Optional[int] = Field(default=None, ge=1, le=65535)
|
||||||
|
ssh_username: Optional[str] = Field(default=None, max_length=200)
|
||||||
|
ssh_password: Optional[str] = Field(default=None, max_length=1000)
|
||||||
|
ssh_private_key: Optional[str] = Field(default=None, max_length=20000)
|
||||||
|
ssh_private_key_passphrase: Optional[str] = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
|
@field_validator("row_scope")
|
||||||
|
@classmethod
|
||||||
|
def _check_row_scope(cls, value):
|
||||||
|
return validate_row_scope(value)
|
||||||
|
|
||||||
|
|
||||||
|
class DBConnectorOut(BaseModel):
|
||||||
|
"""Never includes the password in any form."""
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
engine: str
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
database: str
|
||||||
|
username: str
|
||||||
|
enabled: bool
|
||||||
|
allowed_tables: Optional[List[str]] = None
|
||||||
|
masked_columns: Optional[List[str]] = None
|
||||||
|
row_scope: Optional[Dict[str, str]] = None
|
||||||
|
row_scope_key: str = "email"
|
||||||
|
max_rows: int
|
||||||
|
statement_timeout_ms: int
|
||||||
|
# SSH tunnel — connection details only, never the key/password.
|
||||||
|
ssh_enabled: bool = False
|
||||||
|
ssh_host: Optional[str] = None
|
||||||
|
ssh_port: int = 22
|
||||||
|
ssh_username: Optional[str] = None
|
||||||
|
last_test_at: Optional[datetime] = None
|
||||||
|
last_test_ok: Optional[bool] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr, field_serializer, field_validator
|
||||||
|
from typing import Optional, List
|
||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.core.s3 import sign_s3_url
|
||||||
|
from app.core.security import validate_password_strength
|
||||||
|
|
||||||
|
from app.models.schemas.role import RoleResponse
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class UserBase(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
full_name: str
|
||||||
|
is_active: bool = True
|
||||||
|
profile_pic: Optional[str] = None
|
||||||
|
is_online: bool = False
|
||||||
|
last_seen: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChatScopeFields(BaseModel):
|
||||||
|
"""The two chat-scope toggles on the user form.
|
||||||
|
|
||||||
|
Both default to None, meaning "whatever the chosen role already grants".
|
||||||
|
A client that predates these fields keeps its old behaviour; the form
|
||||||
|
sends explicit values because it renders the role's current scope.
|
||||||
|
"""
|
||||||
|
see_all_ai_chats: Optional[bool] = None
|
||||||
|
see_all_org_chats: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(UserBase, ChatScopeFields):
|
||||||
|
password: str
|
||||||
|
role_id: int
|
||||||
|
|
||||||
|
@field_validator('password')
|
||||||
|
@classmethod
|
||||||
|
def _check_strength(cls, value: str) -> str:
|
||||||
|
# The password an admin picks for a new teammate clears the same bar as
|
||||||
|
# one set by a reset — otherwise the policy is one invite away from
|
||||||
|
# being bypassed.
|
||||||
|
return validate_password_strength(value)
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdate(ChatScopeFields):
|
||||||
|
email: Optional[EmailStr] = None
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
password: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
current_password: Optional[str] = None
|
||||||
|
role_id: Optional[int] = None
|
||||||
|
profile_pic: Optional[str] = None
|
||||||
|
is_online: Optional[bool] = None
|
||||||
|
|
||||||
|
class TeammateResponse(BaseModel):
|
||||||
|
"""A colleague as the inbox needs them: enough to pick one and render them.
|
||||||
|
|
||||||
|
No role, no permissions, no timestamps — an agent listing who they can hand
|
||||||
|
a chat to has no business reading the org's permission matrix, which is
|
||||||
|
what UserResponse would give them.
|
||||||
|
"""
|
||||||
|
id: UUID
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
email: EmailStr
|
||||||
|
profile_pic: Optional[str] = None
|
||||||
|
is_online: Optional[bool] = False
|
||||||
|
|
||||||
|
@field_serializer('profile_pic')
|
||||||
|
def _sign_profile_pic(self, v: Optional[str]) -> Optional[str]:
|
||||||
|
return sign_s3_url(v) if v else v
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class AdminPasswordReset(BaseModel):
|
||||||
|
"""An admin setting a new password for someone else in their organization.
|
||||||
|
|
||||||
|
No current_password: the admin does not know it — that is the point of a
|
||||||
|
reset. The policy check lives here because nobody is around to see the
|
||||||
|
form's strength meter when the call arrives from a script.
|
||||||
|
"""
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
@field_validator('new_password')
|
||||||
|
@classmethod
|
||||||
|
def _check_strength(cls, value: str) -> str:
|
||||||
|
return validate_password_strength(value)
|
||||||
|
|
||||||
|
|
||||||
|
class UserStatusUpdate(BaseModel):
|
||||||
|
is_online: bool
|
||||||
|
|
||||||
|
class UserGroupResponse(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
class UserResponse(UserBase):
|
||||||
|
id: UUID
|
||||||
|
organization_id: Optional[UUID] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
is_online: Optional[bool] = None
|
||||||
|
last_seen: Optional[datetime] = None
|
||||||
|
profile_pic: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
groups: Optional[List[UserGroupResponse]] = None
|
||||||
|
role: Optional[RoleResponse] = None
|
||||||
|
|
||||||
|
@field_serializer('profile_pic')
|
||||||
|
def _sign_profile_pic(self, v: Optional[str]) -> Optional[str]:
|
||||||
|
"""Sign on the way out, every response — never stored signed."""
|
||||||
|
return sign_s3_url(v) if v else v
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str
|
||||||
|
user: UserResponse
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from uuid import UUID
|
||||||
|
from app.models.schemas.user import UserResponse
|
||||||
|
|
||||||
|
class UserGroupBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
class UserGroupCreate(UserGroupBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class UserGroupUpdate(UserGroupBase):
|
||||||
|
name: Optional[str] = None
|
||||||
|
|
||||||
|
class UserGroupResponse(UserGroupBase):
|
||||||
|
id: UUID
|
||||||
|
organization_id: UUID
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class UserGroupWithUsers(UserGroupResponse):
|
||||||
|
users: List[UserResponse]
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, field_serializer
|
||||||
|
from typing import List, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from app.core.s3 import sign_s3_url
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
# Optional agent ID for widget configuration
|
||||||
|
agent_id: Optional[UUID] = None
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetCreate(WidgetBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCustomizationResponse(BaseModel):
|
||||||
|
chat_background_color: Optional[str] = None
|
||||||
|
chat_text_color: Optional[str] = None
|
||||||
|
chat_bubble_color: Optional[str] = None
|
||||||
|
accent_color: Optional[str] = None
|
||||||
|
font_family: Optional[str] = None
|
||||||
|
photo_url: Optional[str] = None
|
||||||
|
chat_style: Optional[str] = "CHATBOT"
|
||||||
|
widget_position: Optional[str] = "FLOATING"
|
||||||
|
welcome_title: Optional[str] = None
|
||||||
|
welcome_subtitle: Optional[str] = None
|
||||||
|
welcome_message: Optional[str] = None
|
||||||
|
chat_initiation_messages: Optional[List[str]] = None
|
||||||
|
quick_actions: Optional[List[str]] = None
|
||||||
|
show_citations: Optional[bool] = None
|
||||||
|
collect_email: Optional[bool] = None
|
||||||
|
show_ai_disclaimer: Optional[bool] = None
|
||||||
|
allow_new_chat: Optional[bool] = None
|
||||||
|
# Free-form extras (avatar_style, widget_display placement defaults, ...)
|
||||||
|
customization_metadata: Optional[dict] = None
|
||||||
|
|
||||||
|
@field_serializer('photo_url')
|
||||||
|
def _sign_photo_url(self, v: Optional[str]) -> Optional[str]:
|
||||||
|
"""Sign on the way out, every response — never stored signed."""
|
||||||
|
return sign_s3_url(v) if v else v
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class AgentResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
customization: Optional[AgentCustomizationResponse] = None
|
||||||
|
workflow: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class HumanAgentResponse(BaseModel):
|
||||||
|
human_agent_name: Optional[str] = None
|
||||||
|
human_agent_profile_pic: Optional[str] = None
|
||||||
|
|
||||||
|
@field_serializer('human_agent_profile_pic')
|
||||||
|
def _sign_profile_pic(self, v: Optional[str]) -> Optional[str]:
|
||||||
|
"""Sign on the way out, every response — never stored signed."""
|
||||||
|
return sign_s3_url(v) if v else v
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
organization_id: UUID
|
||||||
|
agent: AgentResponse
|
||||||
|
human_agent: Optional[HumanAgentResponse] = None
|
||||||
|
# Include agent ID in response if set
|
||||||
|
agent_id: Optional[UUID] = None
|
||||||
|
token: Optional[str] = None
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetAppBase(BaseModel):
|
||||||
|
"""Base schema with common fields"""
|
||||||
|
name: str = Field(..., min_length=1, max_length=100, description="App name (e.g., 'Production Widget')")
|
||||||
|
description: Optional[str] = Field(None, max_length=500, description="Optional description")
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetAppCreate(WidgetAppBase):
|
||||||
|
"""Schema for creating a new widget app"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetAppUpdate(BaseModel):
|
||||||
|
"""Schema for updating widget app"""
|
||||||
|
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||||
|
description: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetAppResponse(WidgetAppBase):
|
||||||
|
"""Schema for widget app response (without API key)"""
|
||||||
|
id: UUID
|
||||||
|
organization_id: UUID
|
||||||
|
created_by: UUID
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetAppWithKeyResponse(WidgetAppResponse):
|
||||||
|
"""
|
||||||
|
Schema for newly created app (includes API key ONCE).
|
||||||
|
|
||||||
|
IMPORTANT: Only use this for create/regenerate responses.
|
||||||
|
Never log or persist the api_key field.
|
||||||
|
"""
|
||||||
|
api_key: str = Field(..., description="API key - SAVE THIS! It won't be shown again.")
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetAppListResponse(BaseModel):
|
||||||
|
"""Schema for list of widget apps"""
|
||||||
|
total: int
|
||||||
|
apps: list[WidgetAppResponse]
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, validator
|
||||||
|
from typing import Optional, List, Dict, Any, Union, Literal
|
||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
from app.models.workflow import WorkflowStatus
|
||||||
|
from app.models.workflow_node import NodeType, ActionType
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Workflow schemas
|
||||||
|
# ==============================
|
||||||
|
|
||||||
|
class WorkflowBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
status: Optional[WorkflowStatus] = WorkflowStatus.DRAFT
|
||||||
|
is_template: bool = False
|
||||||
|
default_language: str = "en"
|
||||||
|
canvas_data: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
settings: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowCreate(WorkflowBase):
|
||||||
|
agent_id: UUID
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Operation schemas for batch updates
|
||||||
|
# ==============================
|
||||||
|
|
||||||
|
class WorkflowNodeOperation(BaseModel):
|
||||||
|
operation: Literal["create", "update", "delete"]
|
||||||
|
id: Optional[UUID] = None # Required for update/delete, optional for create
|
||||||
|
node_type: Optional[NodeType] = None
|
||||||
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
position_x: Optional[float] = None
|
||||||
|
position_y: Optional[float] = None
|
||||||
|
config: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
# Landing Page node fields
|
||||||
|
landing_page_heading: Optional[str] = None
|
||||||
|
landing_page_content: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowConnectionOperation(BaseModel):
|
||||||
|
operation: Literal["create", "update", "delete"]
|
||||||
|
id: Optional[UUID] = None # Required for update/delete, optional for create
|
||||||
|
source_node_id: Optional[UUID] = None
|
||||||
|
target_node_id: Optional[UUID] = None
|
||||||
|
label: Optional[str] = None
|
||||||
|
condition: Optional[str] = None
|
||||||
|
priority: Optional[int] = None
|
||||||
|
connection_metadata: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
status: Optional[WorkflowStatus] = None
|
||||||
|
is_template: Optional[bool] = None
|
||||||
|
default_language: Optional[str] = None
|
||||||
|
canvas_data: Optional[Dict[str, Any]] = None
|
||||||
|
settings: Optional[Dict[str, Any]] = None
|
||||||
|
agent_id: Optional[UUID] = None
|
||||||
|
nodes: Optional[List[WorkflowNodeOperation]] = None
|
||||||
|
connections: Optional[List[WorkflowConnectionOperation]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowResponse(WorkflowBase):
|
||||||
|
id: UUID
|
||||||
|
organization_id: UUID
|
||||||
|
agent_id: UUID
|
||||||
|
created_by: UUID
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
orm_mode = True
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# WorkflowNode schemas
|
||||||
|
# ==============================
|
||||||
|
|
||||||
|
class WorkflowNodeBase(BaseModel):
|
||||||
|
workflow_id: UUID
|
||||||
|
node_type: NodeType
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
position_x: float = 0
|
||||||
|
position_y: float = 0
|
||||||
|
config: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
# Message node fields
|
||||||
|
message_text: Optional[str] = None
|
||||||
|
|
||||||
|
# Condition node fields
|
||||||
|
condition_expression: Optional[str] = None
|
||||||
|
|
||||||
|
# Action node fields
|
||||||
|
action_type: Optional[ActionType] = None
|
||||||
|
action_config: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
# Transfer node fields
|
||||||
|
transfer_rules: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
# Wait node fields
|
||||||
|
wait_duration: Optional[int] = None # Duration in seconds
|
||||||
|
wait_until_condition: Optional[str] = None
|
||||||
|
|
||||||
|
# Landing Page node fields
|
||||||
|
landing_page_heading: Optional[str] = None
|
||||||
|
landing_page_content: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowNodeCreate(WorkflowNodeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowNodeUpdate(BaseModel):
|
||||||
|
node_type: Optional[NodeType] = None
|
||||||
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
position_x: Optional[float] = None
|
||||||
|
position_y: Optional[float] = None
|
||||||
|
config: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
# Landing Page node fields
|
||||||
|
landing_page_heading: Optional[str] = None
|
||||||
|
landing_page_content: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowNodeResponse(WorkflowNodeBase):
|
||||||
|
id: UUID
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
orm_mode = True
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# WorkflowConnection schemas
|
||||||
|
# ==============================
|
||||||
|
|
||||||
|
class WorkflowConnectionBase(BaseModel):
|
||||||
|
workflow_id: UUID
|
||||||
|
source_node_id: UUID
|
||||||
|
target_node_id: UUID
|
||||||
|
label: Optional[str] = None
|
||||||
|
condition: Optional[str] = None
|
||||||
|
priority: int = 0
|
||||||
|
connection_metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowConnectionCreate(WorkflowConnectionBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowConnectionUpdate(BaseModel):
|
||||||
|
label: Optional[str] = None
|
||||||
|
condition: Optional[str] = None
|
||||||
|
priority: Optional[int] = None
|
||||||
|
connection_metadata: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowConnectionResponse(WorkflowConnectionBase):
|
||||||
|
id: UUID
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
orm_mode = True
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Additional composite schemas
|
||||||
|
# ==============================
|
||||||
|
|
||||||
|
class WorkflowDetailResponse(WorkflowResponse):
|
||||||
|
nodes: List[WorkflowNodeResponse]
|
||||||
|
connections: List[WorkflowConnectionResponse]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
orm_mode = True
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, String, DateTime, ForeignKey, Enum as SQLEnum, JSON, Float
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from app.core.encryption import EncryptedText
|
||||||
|
from app.database import Base
|
||||||
|
import enum
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.models.user import UserGroup
|
||||||
|
|
||||||
|
|
||||||
|
class SessionStatus(str, enum.Enum):
|
||||||
|
OPEN = "open"
|
||||||
|
TRANSFERRED = "transferred"
|
||||||
|
CLOSED = "closed"
|
||||||
|
|
||||||
|
|
||||||
|
class EndChatReasonType(str, enum.Enum):
|
||||||
|
ISSUE_RESOLVED = "ISSUE_RESOLVED"
|
||||||
|
CUSTOMER_REQUEST = "CUSTOMER_REQUEST"
|
||||||
|
CONFIRMATION_RECEIVED = "CONFIRMATION_RECEIVED"
|
||||||
|
FAREWELL = "FAREWELL"
|
||||||
|
THANK_YOU = "THANK_YOU"
|
||||||
|
NATURAL_CONCLUSION = "NATURAL_CONCLUSION"
|
||||||
|
TASK_COMPLETED = "TASK_COMPLETED"
|
||||||
|
|
||||||
|
|
||||||
|
# A conversation the visitor can still talk in.
|
||||||
|
ACTIVE_STATUSES = (SessionStatus.OPEN, SessionStatus.TRANSFERRED)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionToAgent(Base):
|
||||||
|
__tablename__ = "session_to_agents"
|
||||||
|
|
||||||
|
session_id = Column(UUID(as_uuid=True), primary_key=True)
|
||||||
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
customer_id = Column(UUID(as_uuid=True), ForeignKey("customers.id", ondelete="SET NULL"), nullable=False, index=True)
|
||||||
|
group_id = Column(UUID(as_uuid=True), ForeignKey("groups.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
status = Column(SQLEnum(SessionStatus), nullable=False, default=SessionStatus.OPEN)
|
||||||
|
# Messaging channel the session belongs to ('web' = widget, 'telegram',
|
||||||
|
# 'whatsapp', ...). Routing key for outbound delivery of replies.
|
||||||
|
channel = Column(String, nullable=False, server_default='web', index=True)
|
||||||
|
# Which widget conversation token may resume this session (see
|
||||||
|
# conversation_token.conversation_scope). NULL: only a token the embedding app
|
||||||
|
# vouched for - and every non-widget channel, which never resumes by token.
|
||||||
|
conversation_key = Column(String(64), nullable=True)
|
||||||
|
assigned_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
transfer_reason = Column(String, nullable=True)
|
||||||
|
# The free-text fields below are AI-written summaries of the conversation, so
|
||||||
|
# they are encrypted at rest alongside the messages themselves.
|
||||||
|
transfer_description = Column(EncryptedText, nullable=True)
|
||||||
|
end_chat_reason = Column(SQLEnum(EndChatReasonType), nullable=True)
|
||||||
|
end_chat_description = Column(EncryptedText, nullable=True)
|
||||||
|
|
||||||
|
# Ticket-related fields
|
||||||
|
ticket_id = Column(String, nullable=True)
|
||||||
|
ticket_status = Column(String, nullable=True)
|
||||||
|
ticket_summary = Column(EncryptedText, nullable=True)
|
||||||
|
ticket_description = Column(EncryptedText, nullable=True)
|
||||||
|
integration_type = Column(String, nullable=True)
|
||||||
|
ticket_priority = Column(String, nullable=True)
|
||||||
|
|
||||||
|
# Sentiment fields
|
||||||
|
sentiment_label = Column(String, nullable=True) # Overall session sentiment: 'positive', 'neutral', 'negative'
|
||||||
|
sentiment_score = Column(Float, nullable=True) # Overall average sentiment score: -1.0 to 1.0
|
||||||
|
|
||||||
|
# Workflow-related fields
|
||||||
|
workflow_id = Column(UUID(as_uuid=True), ForeignKey("workflows.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
current_node_id = Column(UUID(as_uuid=True), ForeignKey("workflow_nodes.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
|
||||||
|
workflow_state = Column(JSON, default={}) # Store workflow execution state
|
||||||
|
workflow_history = Column(JSON, default=[]) # Store form submissions and workflow interactions
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = relationship("User", back_populates="session_assignments")
|
||||||
|
agent = relationship("Agent", back_populates="session_assignments")
|
||||||
|
customer = relationship("Customer", back_populates="session_assignments")
|
||||||
|
group = relationship("UserGroup", back_populates="session_assignments")
|
||||||
|
ratings = relationship("Rating", back_populates="session_assignments")
|
||||||
|
workflow = relationship("Workflow", back_populates="sessions")
|
||||||
|
current_node = relationship("WorkflowNode")
|
||||||
|
|
||||||
|
|
||||||
|
# Add back-reference in UserGroup model if not already present
|
||||||
|
if not hasattr(UserGroup, 'session_assignments'):
|
||||||
|
UserGroup.session_assignments = relationship(
|
||||||
|
"SessionToAgent",
|
||||||
|
back_populates="group",
|
||||||
|
uselist=True
|
||||||
|
)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Import models
|
||||||
|
from .shopify_shop import ShopifyShop
|
||||||
|
from .agent_shopify_config import AgentShopifyConfig
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AgentShopifyConfig(Base):
|
||||||
|
"""Model for storing agent-to-Shopify configuration."""
|
||||||
|
__tablename__ = "agent_shopify_configs"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True)
|
||||||
|
agent_id = Column(String, nullable=False, index=True)
|
||||||
|
|
||||||
|
# Link to ShopifyShop
|
||||||
|
shop_id = Column(String, ForeignKey("shopify_shops.id"), nullable=True)
|
||||||
|
shop = relationship("ShopifyShop", backref="agent_configs")
|
||||||
|
|
||||||
|
enabled = Column(Boolean, default=False, nullable=False)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, String, Boolean, DateTime, Integer, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
import uuid
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ShopifyShop(Base):
|
||||||
|
"""Shopify shop model for storing shop information and credentials"""
|
||||||
|
__tablename__ = "shopify_shops"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
shop_domain = Column(String, nullable=False, unique=True, index=True)
|
||||||
|
access_token = Column(String, nullable=True)
|
||||||
|
scope = Column(String, nullable=True)
|
||||||
|
is_installed = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
# OAuth state for CSRF protection
|
||||||
|
oauth_state = Column(String, nullable=True)
|
||||||
|
oauth_state_expiry = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
# Use UUID type to match the database schema from Alembic migration
|
||||||
|
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=True)
|
||||||
|
organization = relationship("Organization", back_populates="shopify_shops")
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from pgvector.sqlalchemy import Vector
|
||||||
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
# Display prefix for ticket numbers (TKT-1024). Also mirrored into
|
||||||
|
# session_to_agents.ticket_id so existing session/widget consumers keep working.
|
||||||
|
TICKET_NUMBER_PREFIX = "TKT"
|
||||||
|
|
||||||
|
# FastEmbed model used for dedup/similarity embeddings. The dimension is baked
|
||||||
|
# into the tickets.embedding column — changing models requires a re-embed
|
||||||
|
# migration.
|
||||||
|
TICKET_EMBEDDING_MODEL = "BAAI/bge-small-en-v1.5"
|
||||||
|
TICKET_EMBEDDING_DIM = 384
|
||||||
|
|
||||||
|
|
||||||
|
class _ValueStrEnum(str, enum.Enum):
|
||||||
|
"""str-enum whose str() is the value — so in-memory enum instances and
|
||||||
|
refreshed-from-DB plain strings render identically (session mirroring,
|
||||||
|
ai_state comparisons, API payloads)."""
|
||||||
|
|
||||||
|
def __str__(self) -> str: # pragma: no cover - trivial
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
|
||||||
|
class TicketStatus(_ValueStrEnum):
|
||||||
|
OPEN = "open"
|
||||||
|
TRIAGING = "triaging" # AI triage running
|
||||||
|
INVESTIGATING = "investigating" # investigation run active
|
||||||
|
AWAITING_APPROVAL = "awaiting_approval" # L2: resolution proposed
|
||||||
|
IN_PROGRESS = "in_progress" # human working it
|
||||||
|
RESOLVED_PENDING_CONFIRMATION = "resolved_pending_confirmation"
|
||||||
|
RESOLVED = "resolved"
|
||||||
|
CLOSED = "closed"
|
||||||
|
REOPENED = "reopened"
|
||||||
|
|
||||||
|
|
||||||
|
class TicketPriority(_ValueStrEnum):
|
||||||
|
URGENT = "urgent"
|
||||||
|
HIGH = "high"
|
||||||
|
MEDIUM = "medium"
|
||||||
|
LOW = "low"
|
||||||
|
|
||||||
|
|
||||||
|
class TicketSource(_ValueStrEnum):
|
||||||
|
CHAT_AI = "chat_ai"
|
||||||
|
HUMAN_AGENT = "human_agent"
|
||||||
|
MANUAL = "manual"
|
||||||
|
API = "api"
|
||||||
|
|
||||||
|
|
||||||
|
class ResolutionOutcome(_ValueStrEnum):
|
||||||
|
FIXED = "fixed"
|
||||||
|
WORKAROUND = "workaround"
|
||||||
|
NOT_A_BUG = "not_a_bug"
|
||||||
|
DUPLICATE = "duplicate"
|
||||||
|
CANNOT_REPRODUCE = "cannot_reproduce"
|
||||||
|
CUSTOMER_UNRESPONSIVE = "customer_unresponsive"
|
||||||
|
ESCALATED_EXTERNAL = "escalated_external"
|
||||||
|
|
||||||
|
|
||||||
|
# Statuses that count as "still open" — derived so a future status defaults to
|
||||||
|
# open rather than silently escaping open-ticket queries and dedup checks.
|
||||||
|
TERMINAL_TICKET_STATUSES = {TicketStatus.RESOLVED, TicketStatus.CLOSED}
|
||||||
|
OPEN_TICKET_STATUSES = [s.value for s in TicketStatus if s not in TERMINAL_TICKET_STATUSES]
|
||||||
|
|
||||||
|
# Legal status transitions, enforced centrally in TicketService.transition_status.
|
||||||
|
TICKET_STATUS_TRANSITIONS = {
|
||||||
|
TicketStatus.OPEN: {
|
||||||
|
TicketStatus.TRIAGING, TicketStatus.INVESTIGATING, TicketStatus.AWAITING_APPROVAL,
|
||||||
|
TicketStatus.IN_PROGRESS, TicketStatus.RESOLVED_PENDING_CONFIRMATION,
|
||||||
|
TicketStatus.RESOLVED, TicketStatus.CLOSED,
|
||||||
|
},
|
||||||
|
TicketStatus.TRIAGING: {
|
||||||
|
TicketStatus.OPEN, TicketStatus.INVESTIGATING, TicketStatus.IN_PROGRESS,
|
||||||
|
TicketStatus.AWAITING_APPROVAL, TicketStatus.RESOLVED, TicketStatus.CLOSED,
|
||||||
|
},
|
||||||
|
TicketStatus.INVESTIGATING: {
|
||||||
|
TicketStatus.OPEN, TicketStatus.AWAITING_APPROVAL, TicketStatus.IN_PROGRESS,
|
||||||
|
TicketStatus.RESOLVED_PENDING_CONFIRMATION, TicketStatus.RESOLVED, TicketStatus.CLOSED,
|
||||||
|
},
|
||||||
|
TicketStatus.AWAITING_APPROVAL: {
|
||||||
|
TicketStatus.OPEN, TicketStatus.INVESTIGATING, TicketStatus.IN_PROGRESS,
|
||||||
|
TicketStatus.RESOLVED_PENDING_CONFIRMATION, TicketStatus.RESOLVED, TicketStatus.CLOSED,
|
||||||
|
},
|
||||||
|
TicketStatus.IN_PROGRESS: {
|
||||||
|
TicketStatus.OPEN, TicketStatus.INVESTIGATING, TicketStatus.AWAITING_APPROVAL,
|
||||||
|
TicketStatus.RESOLVED_PENDING_CONFIRMATION, TicketStatus.RESOLVED, TicketStatus.CLOSED,
|
||||||
|
},
|
||||||
|
TicketStatus.RESOLVED_PENDING_CONFIRMATION: {
|
||||||
|
TicketStatus.RESOLVED, TicketStatus.CLOSED, TicketStatus.REOPENED,
|
||||||
|
},
|
||||||
|
TicketStatus.RESOLVED: {TicketStatus.CLOSED, TicketStatus.REOPENED},
|
||||||
|
TicketStatus.CLOSED: {TicketStatus.REOPENED},
|
||||||
|
TicketStatus.REOPENED: {
|
||||||
|
TicketStatus.TRIAGING, TicketStatus.INVESTIGATING, TicketStatus.AWAITING_APPROVAL,
|
||||||
|
TicketStatus.IN_PROGRESS, TicketStatus.RESOLVED_PENDING_CONFIRMATION,
|
||||||
|
TicketStatus.RESOLVED, TicketStatus.CLOSED,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Ticket(Base):
|
||||||
|
"""A native support ticket — the aggregate root of the AI-first ticketing
|
||||||
|
feature. Decoupled from chat sessions: a ticket may aggregate 0..n linked
|
||||||
|
sessions via TicketSession; a session spawns at most one native ticket.
|
||||||
|
|
||||||
|
Status/priority/source/outcome columns are plain strings (values from the
|
||||||
|
str enums above, mirroring faq_generation_jobs) so new values never need an
|
||||||
|
ALTER TYPE migration.
|
||||||
|
"""
|
||||||
|
__tablename__ = "tickets"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
# Per-org monotonically increasing display number (TKT-{n}), allocated from
|
||||||
|
# ticket_sequences with SELECT ... FOR UPDATE in the same transaction that
|
||||||
|
# commits the ticket.
|
||||||
|
ticket_number = Column(Integer, nullable=False)
|
||||||
|
organization_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
# Nullable: manual/internal tickets may have no customer.
|
||||||
|
customer_id = Column(UUID(as_uuid=True), ForeignKey("customers.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
|
||||||
|
title = Column(String(500), nullable=False)
|
||||||
|
# Preserved when AI triage rewrites the title.
|
||||||
|
original_title = Column(String(500), nullable=True)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
status = Column(String, nullable=False, default=TicketStatus.OPEN, index=True)
|
||||||
|
priority = Column(String, nullable=False, default=TicketPriority.MEDIUM)
|
||||||
|
# SEV-1..3 (1 = worst). Nullable — set by triage or humans, not required.
|
||||||
|
severity = Column(Integer, nullable=True)
|
||||||
|
source = Column(String, nullable=False, default=TicketSource.MANUAL)
|
||||||
|
|
||||||
|
# AI triage output.
|
||||||
|
intent = Column(String, nullable=True)
|
||||||
|
triage_confidence = Column(Float, nullable=True)
|
||||||
|
ai_summary = Column(Text, nullable=True)
|
||||||
|
# ["payments", "ledger"] — rendered as the "payments · ledger" sub-line.
|
||||||
|
tags = Column(JSON, nullable=True)
|
||||||
|
|
||||||
|
# Assignment. All nullable: an unassigned open ticket with an active
|
||||||
|
# investigation run is "AI-owned" in the UI.
|
||||||
|
assignee_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
group_id = Column(UUID(as_uuid=True), ForeignKey("groups.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
# Which chat agent created it (source=chat_ai).
|
||||||
|
agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
|
||||||
|
# Dedup: cosine similarity over FastEmbed embeddings (pgvector).
|
||||||
|
duplicate_of_ticket_id = Column(UUID(as_uuid=True), ForeignKey("tickets.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
embedding = Column(Vector(TICKET_EMBEDDING_DIM), nullable=True)
|
||||||
|
|
||||||
|
# Resolution. resolution_summary is internal; customer_resolution_message
|
||||||
|
# is the separate plain-language text sent to the customer.
|
||||||
|
resolution_outcome = Column(String, nullable=True)
|
||||||
|
resolution_summary = Column(Text, nullable=True)
|
||||||
|
customer_resolution_message = Column(Text, nullable=True)
|
||||||
|
# Who resolved it ('ai' | 'user') — powers the AI-resolved stat and the
|
||||||
|
# AI-state list column without scanning activities.
|
||||||
|
resolved_by_actor = Column(String, nullable=True)
|
||||||
|
|
||||||
|
# SLA timestamps.
|
||||||
|
first_response_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
resolved_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
closed_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
confirmation_requested_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
reopened_count = Column(Integer, nullable=False, default=0, server_default="0")
|
||||||
|
|
||||||
|
# CSAT. The ask goes out on close (csat_requested_at); the score arrives
|
||||||
|
# later, when the customer rates the linked conversation — see
|
||||||
|
# app/services/ticket_csat.py for the capture path and why it is
|
||||||
|
# conversation-only.
|
||||||
|
csat_requested_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
csat_score = Column(Integer, nullable=True)
|
||||||
|
csat_rating_id = Column(UUID(as_uuid=True), ForeignKey("ratings.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
csat_responded_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
# Optional one-way escalation reference (e.g. 'JIRA' + issue key + URL).
|
||||||
|
external_ref_type = Column(String, nullable=True)
|
||||||
|
external_ref_id = Column(String, nullable=True)
|
||||||
|
external_ref_url = Column(String, nullable=True)
|
||||||
|
|
||||||
|
created_by_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
organization = relationship("Organization")
|
||||||
|
customer = relationship("Customer")
|
||||||
|
assignee = relationship("User", foreign_keys=[assignee_user_id])
|
||||||
|
created_by = relationship("User", foreign_keys=[created_by_user_id])
|
||||||
|
group = relationship("UserGroup")
|
||||||
|
agent = relationship("Agent")
|
||||||
|
duplicate_of = relationship("Ticket", remote_side=[id])
|
||||||
|
sessions = relationship("TicketSession", back_populates="ticket", cascade="all, delete-orphan")
|
||||||
|
activities = relationship(
|
||||||
|
"TicketActivity",
|
||||||
|
back_populates="ticket",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="TicketActivity.created_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("organization_id", "ticket_number", name="uq_tickets_org_number"),
|
||||||
|
Index("ix_tickets_org_status", "organization_id", "status"),
|
||||||
|
Index("ix_tickets_org_assignee", "organization_id", "assignee_user_id"),
|
||||||
|
Index("ix_tickets_org_created", "organization_id", "created_at"),
|
||||||
|
# Drives the CSAT stat (AI- vs human-resolved averages over a window).
|
||||||
|
Index("ix_tickets_org_csat", "organization_id", "csat_responded_at"),
|
||||||
|
# NOTE: production also has an HNSW cosine index on embedding — it
|
||||||
|
# lives only in the migration (sqlite tests can't compile it).
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_number(self) -> str:
|
||||||
|
return f"{TICKET_NUMBER_PREFIX}-{self.ticket_number}"
|
||||||
|
|
||||||
|
|
||||||
|
class TicketSequence(Base):
|
||||||
|
"""Per-org allocator for ticket display numbers. Read with
|
||||||
|
SELECT ... FOR UPDATE and committed together with the ticket row.
|
||||||
|
"""
|
||||||
|
__tablename__ = "ticket_sequences"
|
||||||
|
|
||||||
|
organization_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
next_number = Column(Integer, nullable=False, default=1)
|
||||||
|
|
||||||
|
|
||||||
|
class TicketSession(Base):
|
||||||
|
"""Link table between tickets and chat sessions. A link table (rather than
|
||||||
|
an FK on session_to_agents) so merged/duplicate tickets can aggregate
|
||||||
|
sessions. The legacy session_to_agents.ticket_* columns are kept as a
|
||||||
|
denormalized mirror for existing consumers.
|
||||||
|
"""
|
||||||
|
__tablename__ = "ticket_sessions"
|
||||||
|
|
||||||
|
ticket_id = Column(UUID(as_uuid=True), ForeignKey("tickets.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
session_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("session_to_agents.session_id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
linked_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
ticket = relationship("Ticket", back_populates="sessions")
|
||||||
|
session = relationship("SessionToAgent")
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, Boolean, Column, DateTime, ForeignKey, Index, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import expression, func
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
from app.models.ticket import _ValueStrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class TicketActivityType(_ValueStrEnum):
|
||||||
|
COMMENT = "comment"
|
||||||
|
STATUS_CHANGE = "status_change"
|
||||||
|
ASSIGNMENT = "assignment"
|
||||||
|
PRIORITY_CHANGE = "priority_change"
|
||||||
|
AI_TRIAGE = "ai_triage"
|
||||||
|
AI_INVESTIGATION_STARTED = "ai_investigation_started"
|
||||||
|
AI_INVESTIGATION_COMPLETED = "ai_investigation_completed"
|
||||||
|
AI_RESOLUTION_PROPOSED = "ai_resolution_proposed"
|
||||||
|
AI_RESOLUTION_APPROVED = "ai_resolution_approved"
|
||||||
|
AI_RESOLUTION_REJECTED = "ai_resolution_rejected"
|
||||||
|
CUSTOMER_NOTIFIED = "customer_notified"
|
||||||
|
CUSTOMER_REPLIED = "customer_replied"
|
||||||
|
CUSTOMER_LINKED = "customer_linked"
|
||||||
|
CSAT_REQUESTED = "csat_requested"
|
||||||
|
CSAT_RECEIVED = "csat_received"
|
||||||
|
REOPENED = "reopened"
|
||||||
|
JIRA_ESCALATED = "jira_escalated"
|
||||||
|
|
||||||
|
|
||||||
|
class TicketActorType(_ValueStrEnum):
|
||||||
|
USER = "user"
|
||||||
|
AI = "ai"
|
||||||
|
CUSTOMER = "customer"
|
||||||
|
SYSTEM = "system"
|
||||||
|
|
||||||
|
|
||||||
|
class TicketActivity(Base):
|
||||||
|
"""Append-only ticket feed — doubles as comments and the audit trail of
|
||||||
|
every AI/system/human action. Never updated or deleted in place.
|
||||||
|
"""
|
||||||
|
__tablename__ = "ticket_activities"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
ticket_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("tickets.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
activity_type = Column(String, nullable=False)
|
||||||
|
actor_type = Column(String, nullable=False, default=TicketActorType.SYSTEM)
|
||||||
|
actor_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
# Comment text or the human-readable line for audit events.
|
||||||
|
body = Column(Text, nullable=True)
|
||||||
|
# Internal note vs customer-visible comment.
|
||||||
|
is_internal = Column(Boolean, nullable=False, default=True, server_default=expression.true())
|
||||||
|
# Structured context: {"from": "open", "to": "investigating"},
|
||||||
|
# {"run_id": ...}, {"channel": "web"}, ...
|
||||||
|
activity_metadata = Column(JSON, nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
ticket = relationship("Ticket", back_populates="activities")
|
||||||
|
actor_user = relationship("User")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_ticket_activities_ticket_created", "ticket_id", "created_at"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT 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 ReplyPier; phone suits channel-first businesses.
|
||||||
|
ROW_SCOPE_KEYS = ("email", "phone")
|
||||||
|
|
||||||
|
|
||||||
|
class TicketDBConnector(Base):
|
||||||
|
"""A guardrailed, read-only database connection the ticket investigation
|
||||||
|
agent may query. Guardrails are structural, enforced outside the model:
|
||||||
|
SELECT-only AST validation against allowed_tables, forced row limits,
|
||||||
|
column masking, read-only transaction + statement timeout, full audit.
|
||||||
|
"""
|
||||||
|
__tablename__ = "ticket_db_connectors"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
organization_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
name = Column(String(200), nullable=False)
|
||||||
|
engine = Column(String, nullable=False, default=DBConnectorEngine.POSTGRESQL)
|
||||||
|
host = Column(String(500), nullable=False)
|
||||||
|
port = Column(Integer, nullable=False, default=5432)
|
||||||
|
database = Column(String(200), nullable=False)
|
||||||
|
username = Column(String(200), nullable=False)
|
||||||
|
# Encrypted with app.core.security.encrypt_api_key — never stored plain.
|
||||||
|
encrypted_password = Column(Text, nullable=False)
|
||||||
|
|
||||||
|
enabled = Column(Boolean, nullable=False, default=True, server_default=expression.true())
|
||||||
|
|
||||||
|
# SSH tunnel (bastion / jump host). Production databases are rarely
|
||||||
|
# directly reachable — the connection is forwarded through an SSH host.
|
||||||
|
# host/port above are then resolved from the SSH host's perspective.
|
||||||
|
ssh_enabled = Column(Boolean, nullable=False, default=False, server_default=expression.false())
|
||||||
|
ssh_host = Column(String(500), nullable=True)
|
||||||
|
ssh_port = Column(Integer, nullable=False, default=22, server_default="22")
|
||||||
|
ssh_username = Column(String(200), nullable=True)
|
||||||
|
# Exactly one auth method is used: private key preferred, else password.
|
||||||
|
# All encrypted with app.core.security.encrypt_api_key.
|
||||||
|
encrypted_ssh_password = Column(Text, nullable=True)
|
||||||
|
encrypted_ssh_private_key = Column(Text, nullable=True)
|
||||||
|
encrypted_ssh_key_passphrase = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# ["schema.table", ...] — nothing is queryable unless listed here.
|
||||||
|
allowed_tables = Column(JSON, nullable=True)
|
||||||
|
# ["email", "phone", ...] — masked before the AI ever sees them; the AST
|
||||||
|
# validator also rejects any query referencing them.
|
||||||
|
masked_columns = Column(JSON, nullable=True)
|
||||||
|
# {"schema.table": "customer_email", ...} — row-level scoping. A listed
|
||||||
|
# table is rewritten into a subquery pre-filtered to the ticket customer's
|
||||||
|
# own rows, so the agent cannot read across customers even with a query
|
||||||
|
# that tries to. Tables left out are unscoped: reference data (products,
|
||||||
|
# error codes) that belongs to nobody in particular.
|
||||||
|
row_scope = Column(JSON, nullable=True)
|
||||||
|
# Which customer value fills the predicate — see ROW_SCOPE_KEYS.
|
||||||
|
row_scope_key = Column(
|
||||||
|
String(20), nullable=False, default="email", server_default="email"
|
||||||
|
)
|
||||||
|
max_rows = Column(Integer, nullable=False, default=100, server_default="100")
|
||||||
|
statement_timeout_ms = Column(Integer, nullable=False, default=5000, server_default="5000")
|
||||||
|
|
||||||
|
last_test_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
last_test_ok = Column(Boolean, nullable=True)
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
organization = relationship("Organization")
|
||||||
|
|
||||||
|
|
||||||
|
class DBConnectorAuditOutcome(_ValueStrEnum):
|
||||||
|
ALLOWED = "allowed"
|
||||||
|
BLOCKED = "blocked"
|
||||||
|
ERROR = "error"
|
||||||
|
|
||||||
|
|
||||||
|
class DBConnectorAuditLog(Base):
|
||||||
|
"""Append-only audit of every query the AI attempted against a
|
||||||
|
connector: the raw model SQL, what actually executed (canonical form),
|
||||||
|
and the outcome. Result rows are never stored."""
|
||||||
|
__tablename__ = "db_connector_audit_logs"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
connector_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("ticket_db_connectors.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
organization_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
ticket_id = Column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("tickets.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
run_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("investigation_runs.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
raw_sql = Column(Text, nullable=False)
|
||||||
|
validated_sql = Column(Text, nullable=True)
|
||||||
|
outcome = Column(String, nullable=False)
|
||||||
|
block_reason = Column(Text, nullable=True)
|
||||||
|
row_count = Column(Integer, nullable=True)
|
||||||
|
duration_ms = Column(Integer, nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_db_connector_audit_logs_created", "organization_id", "created_at"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, ForeignKey, Integer, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import expression, func
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
# Staged autonomy levels — enforced in code (TicketService/worker), never just
|
||||||
|
# in the prompt.
|
||||||
|
AUTONOMY_INVESTIGATE_ONLY = 1 # L1: investigate + summarize
|
||||||
|
AUTONOMY_PROPOSE_APPROVE = 2 # L2: propose resolution, human approves
|
||||||
|
AUTONOMY_AUTO_RESOLVE = 3 # L3: auto-resolve + notify customer
|
||||||
|
|
||||||
|
# Default customer message templates. [customer]/[ticket] placeholders are
|
||||||
|
# substituted at send time.
|
||||||
|
DEFAULT_CREATED_TEMPLATE = (
|
||||||
|
"Hi [customer] — we've opened ticket [ticket] about your issue and our "
|
||||||
|
"team is on it. We'll keep you posted."
|
||||||
|
)
|
||||||
|
DEFAULT_RESOLVED_TEMPLATE = (
|
||||||
|
"Good news [customer] — ticket [ticket] is resolved. Here's what happened "
|
||||||
|
"and what we did to fix it. Reply if anything's still off."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Default SLA targets in minutes, keyed by priority value.
|
||||||
|
DEFAULT_SLA_TARGETS = {
|
||||||
|
"urgent": {"first_response_minutes": 15, "resolution_minutes": 120},
|
||||||
|
"high": {"first_response_minutes": 30, "resolution_minutes": 240},
|
||||||
|
"medium": {"first_response_minutes": 120, "resolution_minutes": 1440},
|
||||||
|
"low": {"first_response_minutes": 480, "resolution_minutes": 4320},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationTicketSettings(Base):
|
||||||
|
"""Per-org AI ticketing policy: autonomy level, SLA targets, customer
|
||||||
|
comms templates and investigation budgets. One row per organization,
|
||||||
|
created lazily with defaults on first read.
|
||||||
|
"""
|
||||||
|
__tablename__ = "organization_ticket_settings"
|
||||||
|
|
||||||
|
organization_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
autonomy_level = Column(Integer, nullable=False, default=AUTONOMY_INVESTIGATE_ONLY, server_default="1")
|
||||||
|
auto_investigate_on_create = Column(Boolean, nullable=False, default=True, server_default=expression.true())
|
||||||
|
min_confidence_to_auto_resolve = Column(Float, nullable=False, default=0.85)
|
||||||
|
# resolved_pending_confirmation -> closed after this many hours without a
|
||||||
|
# customer reply.
|
||||||
|
confirmation_timeout_hours = Column(Integer, nullable=False, default=72, server_default="72")
|
||||||
|
csat_enabled = Column(Boolean, nullable=False, default=True, server_default=expression.true())
|
||||||
|
|
||||||
|
# {"urgent": {"first_response_minutes": 15, "resolution_minutes": 120}, ...}
|
||||||
|
sla_targets = Column(JSON, nullable=True)
|
||||||
|
# Customer message templates with [customer]/[ticket] placeholders.
|
||||||
|
created_template = Column(Text, nullable=True)
|
||||||
|
resolved_template = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Jira escalation (one-way sync). jira_escalation_priority is the minimum
|
||||||
|
# priority value that also syncs to Jira.
|
||||||
|
jira_escalation_enabled = Column(Boolean, nullable=False, default=False, server_default=expression.false())
|
||||||
|
jira_escalation_priority = Column(String, nullable=True)
|
||||||
|
|
||||||
|
# Ticket-scoped MCP connector selection (list of mcp_tools.id) —
|
||||||
|
# deliberately separate from the chat-facing MCPToolToAgent m2m so
|
||||||
|
# observability connectors are never handed to the customer-facing agent.
|
||||||
|
investigation_mcp_tool_ids = Column(JSON, nullable=True)
|
||||||
|
# Alert-webhook intake: Grafana/Datadog/CloudWatch alerts auto-open
|
||||||
|
# tickets via POST /tickets/webhooks/alerts/{org_id}/{secret}.
|
||||||
|
alert_webhook_enabled = Column(Boolean, nullable=False, default=False, server_default=expression.false())
|
||||||
|
# URL-path token authenticating alert posts; rotated by re-enabling.
|
||||||
|
alert_webhook_secret = Column(String(64), nullable=True)
|
||||||
|
|
||||||
|
# Investigation budgets.
|
||||||
|
max_tool_calls_per_run = Column(Integer, nullable=False, default=25, server_default="25")
|
||||||
|
max_runs_per_ticket = Column(Integer, nullable=False, default=3, server_default="3")
|
||||||
|
monthly_investigation_cap = Column(Integer, nullable=True)
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
organization = relationship("Organization")
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, func, Table, Text
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
import uuid
|
||||||
|
from app.database import Base
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
# Association table for many-to-many relationship between users and groups
|
||||||
|
user_groups = Table(
|
||||||
|
'user_groups',
|
||||||
|
Base.metadata,
|
||||||
|
Column('user_id', UUID(as_uuid=True), ForeignKey('users.id'), primary_key=True),
|
||||||
|
Column('group_id', UUID(as_uuid=True), ForeignKey('groups.id'), primary_key=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
class UserGroup(Base):
|
||||||
|
__tablename__ = "groups"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
description = Column(String)
|
||||||
|
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
organization = relationship("Organization", back_populates="groups")
|
||||||
|
users = relationship("User", secondary=user_groups, back_populates="groups")
|
||||||
|
session_assignments = relationship("SessionToAgent", back_populates="group")
|
||||||
|
|
||||||
|
# agents relationship will be created by backref from Agent model
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convert user group object to dictionary"""
|
||||||
|
return {
|
||||||
|
"id": str(self.id),
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"agents": [{"id": str(agent.id), "name": agent.name} for agent in self.agents]
|
||||||
|
}
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
email = Column(String, unique=True, index=True)
|
||||||
|
full_name = Column(String)
|
||||||
|
hashed_password = Column(String)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
profile_pic = Column(String, nullable=True)
|
||||||
|
is_online = Column(Boolean, default=False)
|
||||||
|
last_seen = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=True)
|
||||||
|
role_id = Column(Integer, ForeignKey("roles.id"), nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
# Define relationships
|
||||||
|
organization = relationship("Organization", back_populates="users")
|
||||||
|
role = relationship("Role", back_populates="users")
|
||||||
|
knowledge_queue_items = relationship(
|
||||||
|
"KnowledgeQueue",
|
||||||
|
back_populates="user",
|
||||||
|
cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
notifications = relationship(
|
||||||
|
"Notification", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
# One row per signed-in browser; see app/models/fcm_token.py
|
||||||
|
fcm_tokens = relationship(
|
||||||
|
"FCMToken", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
chat_histories = relationship("ChatHistory", back_populates="user")
|
||||||
|
|
||||||
|
# Add groups relationship
|
||||||
|
groups = relationship("UserGroup", secondary=user_groups, back_populates="users")
|
||||||
|
|
||||||
|
# Add this new relationship
|
||||||
|
session_assignments = relationship("SessionToAgent", back_populates="user")
|
||||||
|
ratings = relationship("Rating", back_populates="user")
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convert user object to dictionary"""
|
||||||
|
return {
|
||||||
|
"id": str(self.id),
|
||||||
|
"email": self.email,
|
||||||
|
"full_name": self.full_name,
|
||||||
|
"is_active": self.is_active,
|
||||||
|
"organization_id": str(self.organization_id),
|
||||||
|
"created_at": self.created_at,
|
||||||
|
"updated_at": self.updated_at,
|
||||||
|
"role": self.role.to_dict() if self.role else None
|
||||||
|
}
|
||||||
|
|
||||||
|
def verify_password(self, password: str) -> bool:
|
||||||
|
return pwd_context.verify(password, self.hashed_password)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
return pwd_context.hash(password)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, String, ForeignKey, Integer
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from uuid import uuid4
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Widget(Base):
|
||||||
|
__tablename__ = "widgets"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
organization_id = Column(UUID(as_uuid=True), ForeignKey(
|
||||||
|
"organizations.id"), nullable=False)
|
||||||
|
|
||||||
|
# Agent relationship
|
||||||
|
agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id"))
|
||||||
|
agent = relationship("Agent", back_populates="widgets")
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
organization = relationship("Organization", back_populates="widgets")
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Column, String, Text, ForeignKey, DateTime, func
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
import uuid
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetApp(Base):
|
||||||
|
"""
|
||||||
|
Widget App model for managing API keys for widget authentication.
|
||||||
|
|
||||||
|
Each organization can create multiple widget apps, each with a unique API key
|
||||||
|
for generating conversation tokens. API keys are stored as bcrypt hashes for security.
|
||||||
|
"""
|
||||||
|
__tablename__ = "widget_apps"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||||
|
name = Column(String, nullable=False) # User-friendly name like "Production Widget"
|
||||||
|
description = Column(Text, nullable=True) # Optional description
|
||||||
|
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True)
|
||||||
|
api_key_hash = Column(String, nullable=False) # bcrypt hash of the API key (one-way)
|
||||||
|
api_key_lookup = Column(String(64), nullable=True, unique=True, index=True) # SHA-256 of the key; NULL for keys issued before it
|
||||||
|
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) # Audit trail
|
||||||
|
is_active = Column(Boolean, default=True, index=True) # Soft delete support
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
organization = relationship("Organization", back_populates="widget_apps")
|
||||||
|
creator = relationship("User", foreign_keys=[created_by])
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, String, Text, Boolean, ForeignKey, JSON, DateTime, Enum
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from app.database import Base
|
||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowStatus(str, enum.Enum):
|
||||||
|
DRAFT = "draft"
|
||||||
|
PUBLISHED = "published"
|
||||||
|
ARCHIVED = "archived"
|
||||||
|
|
||||||
|
|
||||||
|
class Workflow(Base):
|
||||||
|
__tablename__ = "workflows"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
description = Column(Text)
|
||||||
|
status = Column(Enum(WorkflowStatus), default=WorkflowStatus.DRAFT)
|
||||||
|
version = Column(Integer, default=1)
|
||||||
|
is_template = Column(Boolean, default=False)
|
||||||
|
default_language = Column(String(10), default="en")
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"))
|
||||||
|
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"))
|
||||||
|
agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id"))
|
||||||
|
canvas_data = Column(JSON, default={}) # Stores canvas view state (zoom, position)
|
||||||
|
settings = Column(JSON, default={}) # General workflow settings
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
organization = relationship("Organization", back_populates="workflows")
|
||||||
|
agent = relationship("Agent", foreign_keys=[agent_id], back_populates="workflows")
|
||||||
|
user = relationship("User")
|
||||||
|
nodes = relationship("WorkflowNode", back_populates="workflow", cascade="all, delete-orphan")
|
||||||
|
connections = relationship("WorkflowConnection", back_populates="workflow", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
sessions = relationship("SessionToAgent", back_populates="workflow")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
orm_mode = True
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, String, Text, ForeignKey, JSON, DateTime
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.database import Base
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowConnection(Base):
|
||||||
|
__tablename__ = "workflow_connections"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
workflow_id = Column(UUID(as_uuid=True), ForeignKey("workflows.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
source_node_id = Column(UUID(as_uuid=True), ForeignKey("workflow_nodes.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
target_node_id = Column(UUID(as_uuid=True), ForeignKey("workflow_nodes.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
label = Column(String(100)) # Optional label for the connection
|
||||||
|
condition = Column(Text) # Optional condition for conditional routing
|
||||||
|
priority = Column(Integer, default=0) # Priority for multiple outgoing connections
|
||||||
|
connection_metadata = Column(JSON, default={}) # Additional connection properties
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
workflow = relationship("Workflow", back_populates="connections")
|
||||||
|
source_node = relationship("WorkflowNode", foreign_keys=[source_node_id], back_populates="outgoing_connections")
|
||||||
|
target_node = relationship("WorkflowNode", foreign_keys=[target_node_id], back_populates="incoming_connections")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
orm_mode = True
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES 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
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES 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
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List, Optional
|
||||||
|
from app.models.agent import Agent, AgentType
|
||||||
|
import json
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
from uuid import UUID
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
class AgentRepository:
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def create_agent(self, **kwargs) -> Agent:
|
||||||
|
"""Create a new agent"""
|
||||||
|
# Handle instructions
|
||||||
|
# We don't need to do any conversion here, as the Agent model will handle it
|
||||||
|
# through its property setter
|
||||||
|
|
||||||
|
# Handle tools if present
|
||||||
|
if 'tools' in kwargs and kwargs['tools']:
|
||||||
|
if not isinstance(kwargs['tools'], str):
|
||||||
|
kwargs['tools'] = json.dumps(kwargs['tools'])
|
||||||
|
|
||||||
|
# Handle org_id to organization_id conversion for backward compatibility
|
||||||
|
if 'org_id' in kwargs:
|
||||||
|
kwargs['organization_id'] = kwargs.pop('org_id')
|
||||||
|
|
||||||
|
# Create new agent
|
||||||
|
agent = Agent(**kwargs)
|
||||||
|
self.db.add(agent)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(agent)
|
||||||
|
return agent
|
||||||
|
|
||||||
|
def get_by_name(self, name: str, organization_id: UUID) -> Optional[Agent]:
|
||||||
|
"""Get agent by name within an organization"""
|
||||||
|
return self.db.query(Agent).filter(
|
||||||
|
Agent.name == name,
|
||||||
|
Agent.organization_id == organization_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
def get_agent(self, agent_id: UUID) -> Optional[Agent]:
|
||||||
|
"""Get template by ID"""
|
||||||
|
return self.db.query(Agent).filter(
|
||||||
|
Agent.id == agent_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
def get_agent_in_org(self, agent_id, org_id) -> Optional[Agent]:
|
||||||
|
"""Get an agent only if it belongs to org_id.
|
||||||
|
|
||||||
|
The single choke point for "is this agent mine?" — endpoints that take
|
||||||
|
an agent_id from the caller use this so an agent from another tenant
|
||||||
|
can never be read or reconfigured.
|
||||||
|
"""
|
||||||
|
return self.db.query(Agent).filter(
|
||||||
|
Agent.id == agent_id,
|
||||||
|
Agent.organization_id == org_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
def get_org_agents(self, org_id: UUID, active_only: bool = True) -> List[Agent]:
|
||||||
|
"""Get all templates for an organization"""
|
||||||
|
query = self.db.query(Agent).filter(
|
||||||
|
Agent.organization_id == org_id
|
||||||
|
)
|
||||||
|
if active_only:
|
||||||
|
query = query.filter(Agent.is_active == True)
|
||||||
|
return query.all()
|
||||||
|
|
||||||
|
def get_default_agents(self, org_id: UUID) -> Optional[Agent]:
|
||||||
|
"""Get default template for an organization"""
|
||||||
|
return self.db.query(Agent).filter(
|
||||||
|
Agent.organization_id == org_id,
|
||||||
|
Agent.is_default == True,
|
||||||
|
Agent.is_active == True
|
||||||
|
).first()
|
||||||
|
|
||||||
|
def update_agent(self, agent_id: UUID, **kwargs) -> Optional[Agent]:
|
||||||
|
"""Update an existing template"""
|
||||||
|
agent = self.get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Handle special fields
|
||||||
|
if 'instructions' in kwargs:
|
||||||
|
instructions = kwargs['instructions']
|
||||||
|
if isinstance(instructions, str):
|
||||||
|
try:
|
||||||
|
# Try to parse if it's a JSON string
|
||||||
|
parsed = json.loads(instructions)
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
kwargs['instructions'] = parsed
|
||||||
|
else:
|
||||||
|
# If it's a single instruction, wrap in list
|
||||||
|
kwargs['instructions'] = [instructions]
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# If not JSON, treat as single instruction
|
||||||
|
kwargs['instructions'] = [instructions]
|
||||||
|
if 'tools' in kwargs:
|
||||||
|
kwargs['tools'] = json.dumps(kwargs['tools'])
|
||||||
|
|
||||||
|
# Handle default flag
|
||||||
|
if kwargs.get('is_default'):
|
||||||
|
existing_defaults = self.db.query(Agent).filter(
|
||||||
|
Agent.organization_id == agent.organization_id,
|
||||||
|
Agent.is_default == True,
|
||||||
|
Agent.id != agent_id
|
||||||
|
).all()
|
||||||
|
for existing in existing_defaults:
|
||||||
|
existing.is_default = False
|
||||||
|
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
setattr(agent, key, value)
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(agent)
|
||||||
|
return agent
|
||||||
|
|
||||||
|
def delete_agent(self, agent_id: str) -> bool:
|
||||||
|
"""Soft delete a template by setting is_active to False"""
|
||||||
|
template = self.get_agent(agent_id)
|
||||||
|
if not template:
|
||||||
|
return False
|
||||||
|
|
||||||
|
template.is_active = False
|
||||||
|
self.db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_active_agents(self, org_id: UUID) -> List[Agent]:
|
||||||
|
"""Get the active agent template for an organization"""
|
||||||
|
return self.db.query(Agent)\
|
||||||
|
.filter(Agent.organization_id == org_id)\
|
||||||
|
.filter(Agent.is_active == True)\
|
||||||
|
.all()
|
||||||
|
|
||||||
|
def get_by_agent_id(self, agent_id: str):
|
||||||
|
"""Get agent by ID with relationships loaded"""
|
||||||
|
if isinstance(agent_id, str):
|
||||||
|
agent_id = UUID(agent_id)
|
||||||
|
return self.db.query(Agent)\
|
||||||
|
.options(
|
||||||
|
joinedload(Agent.groups),
|
||||||
|
joinedload(Agent.organization)
|
||||||
|
)\
|
||||||
|
.filter(Agent.id == agent_id)\
|
||||||
|
.first()
|
||||||
|
|
||||||
|
def get_all(self) -> list[Agent]:
|
||||||
|
return self.db.query(Agent).all()
|
||||||
|
|
||||||
|
def get_by_id(self, agent_id: str) -> Agent | None:
|
||||||
|
return self.db.query(Agent).filter(Agent.id == agent_id).first()
|
||||||
|
|
||||||
|
def get_all_agents(self, org_id: UUID) -> List[Agent]:
|
||||||
|
"""Get all agents for organization"""
|
||||||
|
return self.db.query(Agent)\
|
||||||
|
.filter(Agent.organization_id == org_id)\
|
||||||
|
.all()
|
||||||
|
|
||||||
|
def count_by_organization(self, org_id: UUID) -> int:
|
||||||
|
"""Count agents for an organization"""
|
||||||
|
from sqlalchemy import func
|
||||||
|
return self.db.query(func.count(Agent.id))\
|
||||||
|
.filter(Agent.organization_id == org_id)\
|
||||||
|
.scalar() or 0
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT 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
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.models.ai_config import AIConfig, AIModelType, DEFAULT_AI_SETTINGS
|
||||||
|
from app.core.security import encrypt_api_key, decrypt_api_key
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class AIConfigRepository:
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def create_config(self, org_id: str, model_type: str, model_name: str, api_key: str, base_url: Optional[str] = None) -> AIConfig:
|
||||||
|
"""Create a new AI configuration"""
|
||||||
|
try:
|
||||||
|
# Encrypt API key before storing
|
||||||
|
encrypted_key = encrypt_api_key(api_key)
|
||||||
|
|
||||||
|
# Create new config
|
||||||
|
config = AIConfig(
|
||||||
|
organization_id=org_id,
|
||||||
|
model_type=AIModelType(model_type.upper()),
|
||||||
|
model_name=model_name,
|
||||||
|
encrypted_api_key=encrypted_key,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
if base_url:
|
||||||
|
# No dedicated column: base_url lives in the JSON settings blob.
|
||||||
|
# The column default is only applied by SQLAlchemy at flush time,
|
||||||
|
# so start from the same default explicitly rather than {}.
|
||||||
|
config.settings = {**DEFAULT_AI_SETTINGS, "base_url": base_url}
|
||||||
|
|
||||||
|
# Deactivate any existing configs
|
||||||
|
existing_configs = self.db.query(AIConfig).filter(
|
||||||
|
AIConfig.organization_id == org_id,
|
||||||
|
AIConfig.is_active == True
|
||||||
|
).all()
|
||||||
|
for existing in existing_configs:
|
||||||
|
existing.is_active = False
|
||||||
|
|
||||||
|
self.db.add(config)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(config)
|
||||||
|
return config
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.db.rollback()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def get_active_config(self, org_id: str) -> Optional[AIConfig]:
|
||||||
|
"""Get active AI configuration for an organization"""
|
||||||
|
config = self.db.query(AIConfig).filter(
|
||||||
|
AIConfig.organization_id == org_id,
|
||||||
|
AIConfig.is_active == True
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not config:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Return dict with decrypted API key
|
||||||
|
return config
|
||||||
|
|
||||||
|
def update_config(self, config_id: int, **kwargs) -> Optional[AIConfig]:
|
||||||
|
"""Update an existing AI configuration"""
|
||||||
|
config = self.db.query(AIConfig).filter(
|
||||||
|
AIConfig.id == config_id).first()
|
||||||
|
if not config:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if key == 'api_key':
|
||||||
|
if value is not None:
|
||||||
|
config.encrypted_api_key = encrypt_api_key(value)
|
||||||
|
elif key == 'base_url':
|
||||||
|
# None means "no change" (mirrors api_key above). An explicit
|
||||||
|
# empty string clears it. Merged into settings JSON — no
|
||||||
|
# dedicated column — so the rest of settings survives.
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
current_settings = dict(config.settings or DEFAULT_AI_SETTINGS)
|
||||||
|
if value:
|
||||||
|
current_settings['base_url'] = value
|
||||||
|
else:
|
||||||
|
current_settings.pop('base_url', None)
|
||||||
|
config.settings = current_settings
|
||||||
|
else:
|
||||||
|
setattr(config, key, value)
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(config)
|
||||||
|
return config
|
||||||
|
|
||||||
|
def deactivate_config(self, config_id: int) -> bool:
|
||||||
|
"""Deactivate an AI configuration"""
|
||||||
|
config = self.db.query(AIConfig).filter(
|
||||||
|
AIConfig.id == config_id).first()
|
||||||
|
if not config:
|
||||||
|
return False
|
||||||
|
|
||||||
|
config.is_active = False
|
||||||
|
self.db.commit()
|
||||||
|
return True
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.repositories.channels.accounts import ChannelAccountRepository
|
||||||
|
from app.repositories.channels.conversations import ChannelConversationRepository
|
||||||
|
from app.repositories.channels.agent_config import AgentChannelConfigRepository
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ChannelAccountRepository",
|
||||||
|
"ChannelConversationRepository",
|
||||||
|
"AgentChannelConfigRepository",
|
||||||
|
]
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Iterable, Optional, List
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.channels.base import SendResult
|
||||||
|
from app.models.channels import ChannelAccount, ChannelConversation
|
||||||
|
from app.models.session_to_agent import SessionToAgent, SessionStatus
|
||||||
|
from app.core.security import decrypt_credentials, encrypt_api_key
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# settings key set while the platform rejects the account's credentials:
|
||||||
|
# {"error": <platform message>, "at": <ISO time>}. Absent means healthy.
|
||||||
|
AUTH_FAILURE_SETTING = "auth_failure"
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelAccountRepository:
|
||||||
|
"""CRUD for connected channel accounts. Credentials are always stored
|
||||||
|
Fernet-encrypted; use get_credentials()/create_account() so plaintext
|
||||||
|
secrets never touch the table."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def create_account(
|
||||||
|
self,
|
||||||
|
organization_id: UUID,
|
||||||
|
channel_type: str,
|
||||||
|
external_account_id: str,
|
||||||
|
credentials: dict,
|
||||||
|
display_name: Optional[str] = None,
|
||||||
|
settings: Optional[dict] = None,
|
||||||
|
) -> ChannelAccount:
|
||||||
|
try:
|
||||||
|
account = ChannelAccount(
|
||||||
|
organization_id=organization_id,
|
||||||
|
channel_type=channel_type,
|
||||||
|
external_account_id=external_account_id,
|
||||||
|
display_name=display_name,
|
||||||
|
encrypted_credentials=encrypt_api_key(json.dumps(credentials)),
|
||||||
|
webhook_secret=secrets.token_urlsafe(32),
|
||||||
|
settings=settings or {},
|
||||||
|
)
|
||||||
|
self.db.add(account)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(account)
|
||||||
|
logger.info(f"Created {channel_type} channel account for org {organization_id}")
|
||||||
|
return account
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating channel account: {str(e)}")
|
||||||
|
self.db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_by_id(self, account_id: UUID) -> Optional[ChannelAccount]:
|
||||||
|
try:
|
||||||
|
if isinstance(account_id, str):
|
||||||
|
account_id = UUID(account_id)
|
||||||
|
return self.db.query(ChannelAccount).filter(
|
||||||
|
ChannelAccount.id == account_id
|
||||||
|
).first()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting channel account: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_by_external_id(self, channel_type: str, external_account_id: str) -> Optional[ChannelAccount]:
|
||||||
|
"""Resolve the account a webhook payload belongs to."""
|
||||||
|
try:
|
||||||
|
return self.db.query(ChannelAccount).filter(
|
||||||
|
ChannelAccount.channel_type == channel_type,
|
||||||
|
ChannelAccount.external_account_id == external_account_id,
|
||||||
|
).first()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting channel account by external id: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def list_by_org(self, organization_id: UUID, channel_type: Optional[str] = None) -> List[ChannelAccount]:
|
||||||
|
try:
|
||||||
|
query = self.db.query(ChannelAccount).filter(
|
||||||
|
ChannelAccount.organization_id == organization_id
|
||||||
|
)
|
||||||
|
if channel_type:
|
||||||
|
query = query.filter(ChannelAccount.channel_type == channel_type)
|
||||||
|
return query.order_by(ChannelAccount.created_at).all()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing channel accounts: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_credentials(self, account: ChannelAccount) -> dict:
|
||||||
|
"""Decrypt an account's credential blob."""
|
||||||
|
return decrypt_credentials(account.encrypted_credentials)
|
||||||
|
|
||||||
|
def update_settings(self, account: ChannelAccount, updates: dict,
|
||||||
|
remove: Iterable[str] = ()) -> ChannelAccount:
|
||||||
|
"""Merge non-secret settings into the account (and drop `remove`)."""
|
||||||
|
merged = {**(account.settings or {}), **updates}
|
||||||
|
for key in remove:
|
||||||
|
merged.pop(key, None)
|
||||||
|
try:
|
||||||
|
account.settings = merged
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(account)
|
||||||
|
return account
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating channel account settings: {str(e)}")
|
||||||
|
self.db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def update_credentials(self, account: ChannelAccount, credentials: dict) -> ChannelAccount:
|
||||||
|
try:
|
||||||
|
account.encrypted_credentials = encrypt_api_key(json.dumps(credentials))
|
||||||
|
# New credentials are a fresh chance; the next send proves them
|
||||||
|
account.settings = _without_auth_failure(account.settings)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(account)
|
||||||
|
return account
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating channel account credentials: {str(e)}")
|
||||||
|
self.db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def track_send_result(self, account: ChannelAccount, result: SendResult) -> None:
|
||||||
|
"""Keep the account's needs-reconnect flag in step with what the
|
||||||
|
platform just said about its credentials."""
|
||||||
|
if result.auth_failed:
|
||||||
|
self.record_auth_failure(account, result.error)
|
||||||
|
elif result.ok:
|
||||||
|
self.clear_auth_failure(account)
|
||||||
|
|
||||||
|
def record_auth_failure(self, account: ChannelAccount, error: Optional[str]) -> None:
|
||||||
|
"""Mark the account as needing reconnection after the platform refused
|
||||||
|
its credentials. Keeps the first failure's time so the UI can say since
|
||||||
|
when."""
|
||||||
|
if needs_reconnect(account):
|
||||||
|
return
|
||||||
|
logger.warning(f"{account.channel_type} account {account.id} needs reconnecting: {error}")
|
||||||
|
self._update_auth_failure(account, {AUTH_FAILURE_SETTING: {
|
||||||
|
"error": error, "at": datetime.now(timezone.utc).isoformat()}})
|
||||||
|
|
||||||
|
def clear_auth_failure(self, account: ChannelAccount) -> None:
|
||||||
|
"""A send went through, so the credentials work again."""
|
||||||
|
if needs_reconnect(account):
|
||||||
|
self._update_auth_failure(account, {}, remove=(AUTH_FAILURE_SETTING,))
|
||||||
|
|
||||||
|
def _update_auth_failure(self, account: ChannelAccount, updates: dict,
|
||||||
|
remove: Iterable[str] = ()) -> None:
|
||||||
|
# Bookkeeping beside a send: failing to record it must not fail the send
|
||||||
|
try:
|
||||||
|
self.update_settings(account, updates, remove=remove)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def set_active(self, account: ChannelAccount, is_active: bool) -> ChannelAccount:
|
||||||
|
try:
|
||||||
|
account.is_active = is_active
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(account)
|
||||||
|
return account
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating channel account state: {str(e)}")
|
||||||
|
self.db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _close_open_sessions(self, account: ChannelAccount) -> None:
|
||||||
|
"""Close the sessions belonging to this account's conversations.
|
||||||
|
|
||||||
|
Deleting the account cascade-deletes its channel_conversations, but the
|
||||||
|
sessions they point at are not owned by the account and would survive as
|
||||||
|
open-but-unreachable: an agent could take one over while the customer's
|
||||||
|
next message — finding no conversation — starts a fresh thread that the
|
||||||
|
AI answers. Closing them first keeps the two from diverging.
|
||||||
|
"""
|
||||||
|
session_ids = [
|
||||||
|
row.session_id
|
||||||
|
for row in self.db.query(ChannelConversation.session_id)
|
||||||
|
.filter(ChannelConversation.channel_account_id == account.id)
|
||||||
|
.all()
|
||||||
|
]
|
||||||
|
if not session_ids:
|
||||||
|
return
|
||||||
|
closed = (
|
||||||
|
self.db.query(SessionToAgent)
|
||||||
|
.filter(
|
||||||
|
SessionToAgent.session_id.in_(session_ids),
|
||||||
|
SessionToAgent.status != SessionStatus.CLOSED,
|
||||||
|
)
|
||||||
|
.update({SessionToAgent.status: SessionStatus.CLOSED},
|
||||||
|
synchronize_session=False)
|
||||||
|
)
|
||||||
|
if closed:
|
||||||
|
logger.info(f"Closed {closed} open session(s) for disconnected "
|
||||||
|
f"{account.channel_type} account {account.id}")
|
||||||
|
|
||||||
|
def delete(self, account: ChannelAccount) -> bool:
|
||||||
|
try:
|
||||||
|
self._close_open_sessions(account)
|
||||||
|
self.db.delete(account)
|
||||||
|
self.db.commit()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting channel account: {str(e)}")
|
||||||
|
self.db.rollback()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def needs_reconnect(account: ChannelAccount) -> bool:
|
||||||
|
return bool((account.settings or {}).get(AUTH_FAILURE_SETTING))
|
||||||
|
|
||||||
|
|
||||||
|
def _without_auth_failure(settings: Optional[dict]) -> dict:
|
||||||
|
# A new dict, not an in-place pop: the JSON column only notices reassignment
|
||||||
|
return {k: v for k, v in (settings or {}).items() if k != AUTH_FAILURE_SETTING}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional, List
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.channels import AgentChannelConfig
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentChannelConfigRepository:
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def get_by_account(self, channel_account_id: UUID) -> Optional[AgentChannelConfig]:
|
||||||
|
try:
|
||||||
|
return self.db.query(AgentChannelConfig).filter(
|
||||||
|
AgentChannelConfig.channel_account_id == channel_account_id
|
||||||
|
).first()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting agent channel config: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_active_agent_id(self, channel_account_id: UUID) -> Optional[UUID]:
|
||||||
|
"""Agent that answers inbound messages on this account, if enabled."""
|
||||||
|
config = self.get_by_account(channel_account_id)
|
||||||
|
if config and config.is_active:
|
||||||
|
return config.agent_id
|
||||||
|
return None
|
||||||
|
|
||||||
|
def map_by_accounts(self, channel_account_ids: List[UUID]) -> dict:
|
||||||
|
"""Configs for many accounts in one query, keyed by channel_account_id."""
|
||||||
|
if not channel_account_ids:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
configs = self.db.query(AgentChannelConfig).filter(
|
||||||
|
AgentChannelConfig.channel_account_id.in_(channel_account_ids)
|
||||||
|
).all()
|
||||||
|
return {config.channel_account_id: config for config in configs}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error mapping agent channel configs: {str(e)}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def list_by_agent(self, agent_id: UUID) -> List[AgentChannelConfig]:
|
||||||
|
try:
|
||||||
|
return self.db.query(AgentChannelConfig).filter(
|
||||||
|
AgentChannelConfig.agent_id == agent_id
|
||||||
|
).all()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing agent channel configs: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def set_agent(self, channel_account_id: UUID, agent_id: UUID, is_active: bool = True) -> AgentChannelConfig:
|
||||||
|
"""Upsert the single agent mapping for a channel account."""
|
||||||
|
try:
|
||||||
|
config = self.get_by_account(channel_account_id)
|
||||||
|
if config:
|
||||||
|
config.agent_id = agent_id
|
||||||
|
config.is_active = is_active
|
||||||
|
else:
|
||||||
|
config = AgentChannelConfig(
|
||||||
|
channel_account_id=channel_account_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
is_active=is_active,
|
||||||
|
)
|
||||||
|
self.db.add(config)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(config)
|
||||||
|
return config
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error setting agent channel config: {str(e)}")
|
||||||
|
self.db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def delete_by_account(self, channel_account_id: UUID) -> bool:
|
||||||
|
try:
|
||||||
|
config = self.get_by_account(channel_account_id)
|
||||||
|
if not config:
|
||||||
|
return False
|
||||||
|
self.db.delete(config)
|
||||||
|
self.db.commit()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting agent channel config: {str(e)}")
|
||||||
|
self.db.rollback()
|
||||||
|
return False
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES 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()
|
||||||
@@ -0,0 +1,714 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT 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
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.utils.uuids import as_uuid
|
||||||
|
from app.models.customer import Customer
|
||||||
|
from app.utils.phone import normalize_phone
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Channels that declare the customer's phone on EVERY inbound message — i.e.
|
||||||
|
# where the platform id IS the number, so identity resolves by phone and the
|
||||||
|
# synthesized `{platform_id}@{channel}.channel` address is redundant. Keep in
|
||||||
|
# step with the adapters that set profile['phone']: app/channels/whatsapp.py
|
||||||
|
# and app/channels/sms/base.py.
|
||||||
|
#
|
||||||
|
# Telegram is deliberately absent. It can learn a phone (share-contact button)
|
||||||
|
# but never sends it with a message, so it is still found by its address.
|
||||||
|
PHONE_KEYED_CHANNELS = ('whatsapp', 'sms')
|
||||||
|
|
||||||
|
# The same fact expressed as address suffixes. Derived, not spelled out twice —
|
||||||
|
# the two must never disagree about which channels these are.
|
||||||
|
PHONE_KEYED_CHANNEL_SUFFIXES = tuple(f'@{c}.channel' for c in PHONE_KEYED_CHANNELS)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerRepository:
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def get_customer_by_email(
|
||||||
|
self,
|
||||||
|
email: str,
|
||||||
|
organization_id: UUID
|
||||||
|
) -> Customer | None:
|
||||||
|
"""Get existing customer by email and organization ID"""
|
||||||
|
try:
|
||||||
|
customer = self.db.query(Customer).filter(
|
||||||
|
Customer.email == email,
|
||||||
|
Customer.organization_id == organization_id
|
||||||
|
).first()
|
||||||
|
return customer
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting customer by email: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_customer_by_phone(
|
||||||
|
self,
|
||||||
|
phone: str,
|
||||||
|
organization_id: UUID
|
||||||
|
) -> Customer | None:
|
||||||
|
"""Get existing customer by normalized E.164 phone and organization ID.
|
||||||
|
|
||||||
|
Callers must pass the output of normalize_phone — the column stores
|
||||||
|
only that shape, so an unnormalized value would silently never match.
|
||||||
|
|
||||||
|
Merged-away rows are excluded, as everywhere else a person is resolved
|
||||||
|
(lead_capture, People). It matters more here than anywhere: this is the
|
||||||
|
FIRST lookup on every inbound WhatsApp/SMS message, so a tombstone
|
||||||
|
still holding a number would capture that conversation onto a row
|
||||||
|
People hides and no drawer can edit.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return self.db.query(Customer).filter(
|
||||||
|
Customer.phone == phone,
|
||||||
|
Customer.organization_id == organization_id,
|
||||||
|
Customer.merged_into_customer_id.is_(None)
|
||||||
|
).first()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting customer by phone: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def create_customer(
|
||||||
|
self,
|
||||||
|
email: str,
|
||||||
|
organization_id: UUID,
|
||||||
|
full_name: str = None,
|
||||||
|
meta_data: dict = None,
|
||||||
|
phone: str = None,
|
||||||
|
conversation_key: str = None
|
||||||
|
) -> Customer:
|
||||||
|
"""Create a new customer"""
|
||||||
|
try:
|
||||||
|
customer = Customer(
|
||||||
|
email=email,
|
||||||
|
full_name=full_name,
|
||||||
|
meta_data=meta_data,
|
||||||
|
conversation_key=conversation_key,
|
||||||
|
organization_id=organization_id,
|
||||||
|
# Defensive normalize: the identity column only ever holds the
|
||||||
|
# canonical E.164 shape, whoever the caller is.
|
||||||
|
phone=normalize_phone(phone)
|
||||||
|
)
|
||||||
|
self.db.add(customer)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(customer)
|
||||||
|
return customer
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating customer: {str(e)}")
|
||||||
|
self.db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def identify(
|
||||||
|
self,
|
||||||
|
email: str,
|
||||||
|
organization_id: UUID,
|
||||||
|
full_name: str = None,
|
||||||
|
meta_data: dict = None
|
||||||
|
) -> Customer:
|
||||||
|
"""Find or create the customer an embedding app has just named, and record
|
||||||
|
that the identity came from a system that already knew who they were.
|
||||||
|
|
||||||
|
`is_authenticated` is what separates these people from leads the agent
|
||||||
|
captured: they are the business's own signed-in users, so the People and
|
||||||
|
lead views filter them out. It is set on creation and backfilled on an
|
||||||
|
existing row, because the same person may first have arrived anonymously.
|
||||||
|
|
||||||
|
A supplied name replaces a stale one, and supplied meta_data is merged
|
||||||
|
(existing keys not named in this call are kept).
|
||||||
|
"""
|
||||||
|
email = (email or "").strip()
|
||||||
|
if not email:
|
||||||
|
raise ValueError("identify() needs an email")
|
||||||
|
try:
|
||||||
|
customer = self.get_customer_by_email(email, organization_id)
|
||||||
|
if not customer:
|
||||||
|
customer = self._create_identified(email, organization_id, full_name, meta_data)
|
||||||
|
if customer is not None:
|
||||||
|
return customer
|
||||||
|
# Someone else created them between our read and our insert —
|
||||||
|
# two dashboard tabs opening the chat at once is enough. Their
|
||||||
|
# row is the one that exists now, so carry on updating it.
|
||||||
|
customer = self.get_customer_by_email(email, organization_id)
|
||||||
|
if customer is None:
|
||||||
|
raise RuntimeError(f"Customer {email} vanished after a create conflict")
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
if not customer.is_authenticated:
|
||||||
|
customer.is_authenticated = True
|
||||||
|
changed = True
|
||||||
|
if full_name and customer.full_name != full_name:
|
||||||
|
customer.full_name = full_name
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
self.db.commit()
|
||||||
|
# Only when it would actually change something: this runs on every
|
||||||
|
# token request, and an unconditional merge writes a row per request
|
||||||
|
# to store what is already there.
|
||||||
|
if meta_data and {**(customer.meta_data or {}), **meta_data} != (customer.meta_data or {}):
|
||||||
|
customer = self.update_meta_data(customer.id, meta_data) or customer
|
||||||
|
return customer
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error identifying customer: {str(e)}")
|
||||||
|
self.db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _create_identified(
|
||||||
|
self,
|
||||||
|
email: str,
|
||||||
|
organization_id: UUID,
|
||||||
|
full_name: str = None,
|
||||||
|
meta_data: dict = None
|
||||||
|
) -> Customer | None:
|
||||||
|
"""Insert the customer, or None when the (email, organization_id) unique
|
||||||
|
constraint says a concurrent caller got there first."""
|
||||||
|
customer = Customer(
|
||||||
|
email=email,
|
||||||
|
full_name=full_name,
|
||||||
|
meta_data=meta_data,
|
||||||
|
is_authenticated=True,
|
||||||
|
organization_id=organization_id,
|
||||||
|
)
|
||||||
|
self.db.add(customer)
|
||||||
|
try:
|
||||||
|
self.db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
self.db.rollback()
|
||||||
|
return None
|
||||||
|
self.db.refresh(customer)
|
||||||
|
logger.info(f"Identified new customer {customer.id} ({email})")
|
||||||
|
return customer
|
||||||
|
|
||||||
|
def update_meta_data(
|
||||||
|
self,
|
||||||
|
customer_id: UUID,
|
||||||
|
meta_data: dict
|
||||||
|
) -> Customer | None:
|
||||||
|
"""Merge integrator-supplied fields (e.g. student_name, center_name) into a
|
||||||
|
customer's existing meta_data, so agents see the latest values in the inbox.
|
||||||
|
New keys are added and existing keys are overwritten; unrelated keys are kept.
|
||||||
|
"""
|
||||||
|
if not meta_data:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
customer = self.get_by_id(customer_id)
|
||||||
|
if not customer:
|
||||||
|
return None
|
||||||
|
customer.meta_data = {**(customer.meta_data or {}), **meta_data}
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(customer)
|
||||||
|
return customer
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating customer meta_data: {str(e)}")
|
||||||
|
self.db.rollback()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_phone_if_absent(self, customer: Customer, phone: str) -> bool:
|
||||||
|
"""Stage `phone` on a customer that has none, unless another customer
|
||||||
|
in the org already owns it (then skip-and-log — never reassign or
|
||||||
|
merge on a phone collision; that judgement belongs to a human).
|
||||||
|
|
||||||
|
The single owner of the set-phone policy: get_or_create_customer's
|
||||||
|
backfill and update_contact both go through here, so the conflict rule
|
||||||
|
cannot drift between them. Stages only — the caller owns the commit.
|
||||||
|
"""
|
||||||
|
if customer.phone:
|
||||||
|
return False
|
||||||
|
existing = self.get_customer_by_phone(phone, customer.organization_id)
|
||||||
|
if existing and existing.id != customer.id:
|
||||||
|
logger.warning(
|
||||||
|
f"Phone {phone} already belongs to customer {existing.id} in org "
|
||||||
|
f"{customer.organization_id}; not setting it on {customer.id}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
customer.phone = phone
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_or_create_customer(
|
||||||
|
self,
|
||||||
|
email: str,
|
||||||
|
organization_id: UUID,
|
||||||
|
full_name: str = None,
|
||||||
|
phone: str = None
|
||||||
|
) -> Customer:
|
||||||
|
"""Resolve a person by phone first, then email; create if neither hits.
|
||||||
|
|
||||||
|
Phone outranks email because where both exist the phone came verified
|
||||||
|
from the channel (Meta/the SMS provider), while the email may be a
|
||||||
|
synthesized per-channel placeholder. When the two match *different*
|
||||||
|
customers, no merging happens here — auto-merge on a soft signal is how
|
||||||
|
Chatwoot corrupted contacts (their #2811); the phone match wins the
|
||||||
|
conversation and both records stay intact for a human.
|
||||||
|
|
||||||
|
A customer found by email with no stored phone gets it backfilled, so
|
||||||
|
pre-existing channel contacts become phone-addressable on their next
|
||||||
|
message without any data migration.
|
||||||
|
"""
|
||||||
|
# Defensive: the column must only ever hold the canonical shape, so an
|
||||||
|
# unnormalized value from a future caller degrades to no-phone rather
|
||||||
|
# than splitting one person into two spellings. Idempotent for the
|
||||||
|
# already-normalized values today's callers pass.
|
||||||
|
phone = normalize_phone(phone)
|
||||||
|
customer = self._resolve_existing(email, organization_id, phone)
|
||||||
|
if customer is not None:
|
||||||
|
return customer
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self.create_customer(email, organization_id, full_name, phone=phone)
|
||||||
|
except IntegrityError:
|
||||||
|
# Lost a race: a concurrent request created this person between our
|
||||||
|
# lookup and our insert. Either unique key can be the one that
|
||||||
|
# fired — (organization_id, email) or the partial index on
|
||||||
|
# (organization_id, phone) — and the phone one is new, so a
|
||||||
|
# WhatsApp customer messaging while an agent starts an outbound
|
||||||
|
# send to them now collides on DIFFERENT emails, which the email
|
||||||
|
# constraint never caught. Re-resolve: the winner's row is the
|
||||||
|
# right answer. Without this the exception reaches
|
||||||
|
# process_channel_message's blanket except and the customer's
|
||||||
|
# message is silently never answered.
|
||||||
|
self.db.rollback()
|
||||||
|
customer = self._resolve_existing(email, organization_id, phone)
|
||||||
|
if customer is None:
|
||||||
|
raise
|
||||||
|
return customer
|
||||||
|
|
||||||
|
def _resolve_existing(
|
||||||
|
self,
|
||||||
|
email: str,
|
||||||
|
organization_id: UUID,
|
||||||
|
phone: str | None
|
||||||
|
) -> Customer | None:
|
||||||
|
"""Phone first, then email — the resolution order, without the create.
|
||||||
|
|
||||||
|
Split out so the post-IntegrityError retry re-runs exactly the lookup
|
||||||
|
that just lost the race, rather than a second, drifting copy of it.
|
||||||
|
"""
|
||||||
|
by_phone = self.get_customer_by_phone(phone, organization_id) if phone else None
|
||||||
|
|
||||||
|
if by_phone is not None:
|
||||||
|
# The email lookup is diagnostic-only here, so skip it on the hot
|
||||||
|
# path (every inbound message) when the emails trivially agree.
|
||||||
|
if by_phone.email != email:
|
||||||
|
by_email = self.get_customer_by_email(email, organization_id)
|
||||||
|
if by_email is not None and by_email.id != by_phone.id:
|
||||||
|
logger.warning(
|
||||||
|
f"Identity conflict in org {organization_id}: phone matches "
|
||||||
|
f"customer {by_phone.id} but email {email} matches "
|
||||||
|
f"{by_email.id}; using the phone match and leaving both "
|
||||||
|
f"records for manual review"
|
||||||
|
)
|
||||||
|
return by_phone
|
||||||
|
|
||||||
|
customer = self.get_customer_by_email(email, organization_id)
|
||||||
|
if not customer:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if phone and self.set_phone_if_absent(customer, phone):
|
||||||
|
try:
|
||||||
|
self.db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
# The partial unique index can race a concurrent insert; the
|
||||||
|
# customer is still the right one, just without the backfill.
|
||||||
|
self.db.rollback()
|
||||||
|
logger.warning(f"Could not backfill phone for customer {customer.id}: {e}")
|
||||||
|
return customer
|
||||||
|
|
||||||
|
def get_by_id(self, customer_id: UUID | str) -> Customer | None:
|
||||||
|
"""Get customer by ID"""
|
||||||
|
try:
|
||||||
|
customer = self.db.query(Customer).filter(Customer.id == as_uuid(customer_id)).first()
|
||||||
|
return customer
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting customer by ID: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def resolve_merged(self, customer_id: UUID | str) -> Customer | None:
|
||||||
|
"""The customer `customer_id` names now, following lead-capture merges.
|
||||||
|
|
||||||
|
A device's token can still carry an anonymous customer that was since
|
||||||
|
merged into an identified one; everything acting for that device must
|
||||||
|
see the survivor.
|
||||||
|
"""
|
||||||
|
customer = self.get_by_id(customer_id)
|
||||||
|
seen = set()
|
||||||
|
while customer is not None and customer.merged_into_customer_id and customer.id not in seen:
|
||||||
|
seen.add(customer.id)
|
||||||
|
logger.info(f"Customer {customer.id} was merged; continuing as {customer.merged_into_customer_id}")
|
||||||
|
customer = self.get_by_id(customer.merged_into_customer_id)
|
||||||
|
return customer
|
||||||
|
|
||||||
|
def get_customer_email(self, customer_id: UUID) -> str | None:
|
||||||
|
"""Get customer email by ID"""
|
||||||
|
try:
|
||||||
|
customer = self.get_by_id(customer_id)
|
||||||
|
return customer.email if customer else None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting customer email: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def anonymous_email() -> str:
|
||||||
|
"""A fresh stand-in email for an anonymous widget visitor.
|
||||||
|
|
||||||
|
Random, not time-based: two visitors arriving in the same millisecond
|
||||||
|
would otherwise collide on the (email, organization) unique key and one
|
||||||
|
of them would get a widget that never loads.
|
||||||
|
"""
|
||||||
|
return f"{uuid4().hex}@noemail.com"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_placeholder_email(email: str | None) -> bool:
|
||||||
|
"""True if the email is missing or an auto-generated stand-in.
|
||||||
|
|
||||||
|
Two kinds exist, and neither can receive mail:
|
||||||
|
- ``…@noemail.com`` — an anonymous widget visitor.
|
||||||
|
- ``…@<channel>.channel`` — synthesized from a platform id (a wa_id, a
|
||||||
|
Telegram user id) so channel people have an identity key at all. It
|
||||||
|
was never an address, and treating it as one is why a channel
|
||||||
|
customer's captured email was silently dropped and why the handoff
|
||||||
|
form never asked them for one.
|
||||||
|
"""
|
||||||
|
return (not email) or ('@noemail.com' in email) or email.endswith('.channel')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def display_email(email: str | None) -> str | None:
|
||||||
|
"""The email to show a human — None when we only ever had a stand-in.
|
||||||
|
Showing `916…@whatsapp.channel` in an Email field states something
|
||||||
|
untrue about the person."""
|
||||||
|
return None if CustomerRepository.is_placeholder_email(email) else email
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _email_is_identity_key(customer) -> bool:
|
||||||
|
"""True when this customer is *found* by their synthesized address.
|
||||||
|
|
||||||
|
`_get_or_create_customer` resolves by phone first, but only for channels
|
||||||
|
whose adapter declares one on EVERY inbound message — WhatsApp and SMS,
|
||||||
|
where the platform id is the number. For those, the phone is the key and
|
||||||
|
the address is free to become the person's real one.
|
||||||
|
|
||||||
|
Every other channel is found by `{platform_id}@{channel}.channel` alone.
|
||||||
|
Telegram is the trap: it can learn a phone via the share-contact button,
|
||||||
|
so the customer HAS one — but its inbound messages never carry it, so
|
||||||
|
the lookup is still by address. Overwrite it and their next message
|
||||||
|
finds nobody and mints a duplicate. Storing a phone must not be mistaken
|
||||||
|
for being found by it.
|
||||||
|
"""
|
||||||
|
email = customer.email or ''
|
||||||
|
if not email.endswith('.channel'):
|
||||||
|
return False
|
||||||
|
return not (email.endswith(PHONE_KEYED_CHANNEL_SUFFIXES) and customer.phone)
|
||||||
|
|
||||||
|
def update_contact(
|
||||||
|
self,
|
||||||
|
customer_id: UUID,
|
||||||
|
email: str = None,
|
||||||
|
full_name: str = None,
|
||||||
|
phone: str = None
|
||||||
|
) -> dict:
|
||||||
|
"""Update an existing customer's contact details (used by human-handoff capture).
|
||||||
|
|
||||||
|
- Replaces a placeholder ``…@noemail.com`` (or empty) email with the supplied one,
|
||||||
|
respecting the ``(email, organization_id)`` unique constraint — if the email already
|
||||||
|
belongs to a different customer, the email update is skipped (logged) but the name
|
||||||
|
can still be updated. A real existing email is never overwritten.
|
||||||
|
- Sets ``full_name`` when a non-empty value is supplied.
|
||||||
|
- Sets ``phone`` (normalized E.164) only when the customer has none — automatic
|
||||||
|
capture paths must not overwrite a channel-verified number — and skips it when the
|
||||||
|
phone belongs to another customer in the org (same rule as email). Correcting an
|
||||||
|
existing phone is an explicit human act, not this method's job.
|
||||||
|
|
||||||
|
Returns ``{'email_updated': bool, 'name_updated': bool, 'phone_updated': bool,
|
||||||
|
'email': str|None}``.
|
||||||
|
"""
|
||||||
|
result = {'email_updated': False, 'name_updated': False,
|
||||||
|
'phone_updated': False, 'email': None}
|
||||||
|
try:
|
||||||
|
customer = self.get_by_id(customer_id)
|
||||||
|
if not customer:
|
||||||
|
return result
|
||||||
|
|
||||||
|
if email:
|
||||||
|
email = email.strip()
|
||||||
|
# Only replace a placeholder/empty email — don't clobber a real one,
|
||||||
|
# and never one that is still doing duty as the identity key.
|
||||||
|
if email and self.is_placeholder_email(customer.email) \
|
||||||
|
and not self._email_is_identity_key(customer) \
|
||||||
|
and email.lower() != (customer.email or '').lower():
|
||||||
|
existing = self.get_customer_by_email(email, customer.organization_id)
|
||||||
|
if existing and existing.id != customer.id:
|
||||||
|
logger.warning(
|
||||||
|
f"Email {email} already belongs to another customer in org "
|
||||||
|
f"{customer.organization_id}; skipping email update for {customer_id}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
customer.email = email
|
||||||
|
result['email_updated'] = True
|
||||||
|
|
||||||
|
if full_name is not None:
|
||||||
|
full_name = full_name.strip()
|
||||||
|
if full_name:
|
||||||
|
customer.full_name = full_name
|
||||||
|
result['name_updated'] = True
|
||||||
|
|
||||||
|
if phone and not customer.phone:
|
||||||
|
normalized = normalize_phone(phone)
|
||||||
|
if not normalized:
|
||||||
|
logger.warning(
|
||||||
|
f"Captured phone {phone!r} for customer {customer_id} is not a "
|
||||||
|
f"resolvable E.164 number; skipping"
|
||||||
|
)
|
||||||
|
elif self.set_phone_if_absent(customer, normalized):
|
||||||
|
result['phone_updated'] = True
|
||||||
|
|
||||||
|
if result['email_updated'] or result['name_updated'] or result['phone_updated']:
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(customer)
|
||||||
|
|
||||||
|
result['email'] = customer.email
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating customer contact: {str(e)}")
|
||||||
|
self.db.rollback()
|
||||||
|
return result
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.faq import FAQ, FAQStatus
|
||||||
|
|
||||||
|
|
||||||
|
class FAQRepository:
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def _org_query(self, organization_id: UUID):
|
||||||
|
return self.db.query(FAQ).filter(FAQ.organization_id == organization_id)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _apply_search(query, search: str):
|
||||||
|
"""Tokenized search: every whitespace-separated term must appear in the
|
||||||
|
question, answer or category (any of them). Matching the whole query as
|
||||||
|
one substring made 'close my' miss 'How to close my account'."""
|
||||||
|
for term in search.split():
|
||||||
|
pattern = f"%{term}%"
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
FAQ.question.ilike(pattern),
|
||||||
|
FAQ.answer.ilike(pattern),
|
||||||
|
FAQ.category.ilike(pattern),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return query
|
||||||
|
|
||||||
|
def get_by_id(self, faq_id: UUID, organization_id: UUID) -> Optional[FAQ]:
|
||||||
|
"""Org-scoped fetch: cross-org ids resolve to None (callers 404)."""
|
||||||
|
return self._org_query(organization_id).filter(FAQ.id == faq_id).first()
|
||||||
|
|
||||||
|
def list_for_org(
|
||||||
|
self,
|
||||||
|
organization_id: UUID,
|
||||||
|
status: Optional[FAQStatus] = None,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> tuple[List[FAQ], int]:
|
||||||
|
"""Filtered, paginated list plus the total matching count."""
|
||||||
|
query = self._org_query(organization_id)
|
||||||
|
if status is not None:
|
||||||
|
query = query.filter(FAQ.status == status)
|
||||||
|
if category:
|
||||||
|
# Exact match: filter values come from get_categories(), and the
|
||||||
|
# (organization_id, category) index only serves equality.
|
||||||
|
query = query.filter(FAQ.category == category)
|
||||||
|
if search:
|
||||||
|
query = self._apply_search(query, search)
|
||||||
|
total = query.count()
|
||||||
|
items = (
|
||||||
|
query.order_by(FAQ.category.asc(), FAQ.sort_order.asc(), FAQ.created_at.asc())
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return items, total
|
||||||
|
|
||||||
|
def get_published_for_org(self, organization_id: UUID, search: Optional[str] = None) -> List[FAQ]:
|
||||||
|
"""Published FAQs for the public help center, in display order."""
|
||||||
|
query = self._org_query(organization_id).filter(FAQ.status == FAQStatus.PUBLISHED)
|
||||||
|
if search:
|
||||||
|
query = self._apply_search(query, search)
|
||||||
|
return query.order_by(FAQ.category.asc(), FAQ.sort_order.asc(), FAQ.created_at.asc()).all()
|
||||||
|
|
||||||
|
def slug_exists(
|
||||||
|
self, organization_id: UUID, slug: str, exclude_id: Optional[UUID] = None
|
||||||
|
) -> bool:
|
||||||
|
"""Whether another FAQ in the org already owns this slug. `exclude_id`
|
||||||
|
skips one row so re-saving an article doesn't collide with itself."""
|
||||||
|
query = self._org_query(organization_id).filter(FAQ.slug == slug)
|
||||||
|
if exclude_id is not None:
|
||||||
|
query = query.filter(FAQ.id != exclude_id)
|
||||||
|
return self.db.query(query.exists()).scalar()
|
||||||
|
|
||||||
|
def get_published_by_slug(self, organization_id: UUID, slug: str) -> Optional[FAQ]:
|
||||||
|
"""A single published article by its slug (the public /a/{slug} lookup)."""
|
||||||
|
return (
|
||||||
|
self._org_query(organization_id)
|
||||||
|
.filter(FAQ.slug == slug, FAQ.status == FAQStatus.PUBLISHED)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
def url_path_exists(
|
||||||
|
self, organization_id: UUID, url_path: str, exclude_id: Optional[UUID] = None
|
||||||
|
) -> bool:
|
||||||
|
"""Whether another FAQ in the org is already served at this preserved
|
||||||
|
path. `exclude_id` skips one row so re-saving an article doesn't collide
|
||||||
|
with itself."""
|
||||||
|
query = self._org_query(organization_id).filter(FAQ.url_path == url_path)
|
||||||
|
if exclude_id is not None:
|
||||||
|
query = query.filter(FAQ.id != exclude_id)
|
||||||
|
return self.db.query(query.exists()).scalar()
|
||||||
|
|
||||||
|
def get_published_by_url_path(self, organization_id: UUID, url_path: str) -> Optional[FAQ]:
|
||||||
|
"""A single published article by its preserved original path — the
|
||||||
|
lookup for any public URL no other route claimed. Case-sensitive on
|
||||||
|
purpose: the point of the feature is to serve the exact URL the org
|
||||||
|
already ranks for."""
|
||||||
|
return (
|
||||||
|
self._org_query(organization_id)
|
||||||
|
.filter(FAQ.url_path == url_path, FAQ.status == FAQStatus.PUBLISHED)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_published_related(
|
||||||
|
self, organization_id: UUID, category: str, exclude_id: UUID, limit: int = 4
|
||||||
|
) -> List[FAQ]:
|
||||||
|
"""Other published FAQs in the same category — the article's related list."""
|
||||||
|
return (
|
||||||
|
self._org_query(organization_id)
|
||||||
|
.filter(
|
||||||
|
FAQ.status == FAQStatus.PUBLISHED,
|
||||||
|
FAQ.category == category,
|
||||||
|
FAQ.id != exclude_id,
|
||||||
|
)
|
||||||
|
.order_by(FAQ.sort_order.asc(), FAQ.created_at.asc())
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_categories(self, organization_id: UUID) -> List[str]:
|
||||||
|
rows = (
|
||||||
|
self._org_query(organization_id)
|
||||||
|
.with_entities(FAQ.category)
|
||||||
|
.distinct()
|
||||||
|
.order_by(FAQ.category.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [row[0] for row in rows]
|
||||||
|
|
||||||
|
def knowledge_ids_with_faqs(self, organization_id: UUID) -> set:
|
||||||
|
"""Knowledge sources that already produced FAQs — regenerate skips
|
||||||
|
these unless the caller explicitly targets them. Self-healing: deleting
|
||||||
|
a source's FAQs makes it eligible for generation again."""
|
||||||
|
rows = (
|
||||||
|
self._org_query(organization_id)
|
||||||
|
.with_entities(FAQ.knowledge_id)
|
||||||
|
.filter(FAQ.knowledge_id.isnot(None))
|
||||||
|
.distinct()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return {row[0] for row in rows}
|
||||||
|
|
||||||
|
def get_existing_questions(self, organization_id: UUID) -> List[str]:
|
||||||
|
"""All question strings for the org — the generation dedup baseline."""
|
||||||
|
rows = self._org_query(organization_id).with_entities(FAQ.question).all()
|
||||||
|
return [row[0] for row in rows]
|
||||||
|
|
||||||
|
def exists_for_org(self, organization_id: UUID) -> bool:
|
||||||
|
return self.db.query(
|
||||||
|
self._org_query(organization_id).exists()
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
def create(self, faq: FAQ) -> FAQ:
|
||||||
|
self.db.add(faq)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(faq)
|
||||||
|
return faq
|
||||||
|
|
||||||
|
def bulk_create(self, faqs: List[FAQ]) -> List[FAQ]:
|
||||||
|
# Guarantee every generated/imported FAQ gets a unique article slug (the
|
||||||
|
# single choke point for batch inserts), so publishing one never yields a
|
||||||
|
# broken /a/ link.
|
||||||
|
from app.services.help_center_settings import assign_faq_slugs
|
||||||
|
|
||||||
|
assign_faq_slugs(self.db, faqs)
|
||||||
|
self.db.add_all(faqs)
|
||||||
|
self.db.commit()
|
||||||
|
return faqs
|
||||||
|
|
||||||
|
def update(self, faq: FAQ) -> FAQ:
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(faq)
|
||||||
|
return faq
|
||||||
|
|
||||||
|
def delete(self, faq: FAQ) -> None:
|
||||||
|
self.db.delete(faq)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
def record_feedback(self, faq_id: UUID, helpful: bool) -> None:
|
||||||
|
"""Atomically bump the 'Was this helpful?' tally for an article."""
|
||||||
|
column = FAQ.helpful_yes if helpful else FAQ.helpful_no
|
||||||
|
self.db.query(FAQ).filter(FAQ.id == faq_id).update(
|
||||||
|
{column: column + 1}, synchronize_session=False
|
||||||
|
)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
def bulk_set_status(
|
||||||
|
self, organization_id: UUID, faq_ids: List[UUID], status: FAQStatus
|
||||||
|
) -> int:
|
||||||
|
"""Set status on the org's FAQs among faq_ids; returns rows changed."""
|
||||||
|
updated = (
|
||||||
|
self._org_query(organization_id)
|
||||||
|
.filter(FAQ.id.in_(faq_ids))
|
||||||
|
.update({FAQ.status: status}, synchronize_session=False)
|
||||||
|
)
|
||||||
|
self.db.commit()
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def bulk_delete(self, organization_id: UUID, faq_ids: List[UUID]) -> int:
|
||||||
|
"""Delete the org's FAQs among faq_ids; returns rows deleted.
|
||||||
|
Org-scoped, so cross-org ids are silently ignored."""
|
||||||
|
deleted = (
|
||||||
|
self._org_query(organization_id)
|
||||||
|
.filter(FAQ.id.in_(faq_ids))
|
||||||
|
.delete(synchronize_session=False)
|
||||||
|
)
|
||||||
|
self.db.commit()
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
def count_published(self, organization_id: UUID) -> int:
|
||||||
|
return (
|
||||||
|
self._org_query(organization_id)
|
||||||
|
.filter(FAQ.status == FAQStatus.PUBLISHED)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES 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
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from uuid import UUID
|
||||||
|
from typing import List, Sequence
|
||||||
|
|
||||||
|
from app.models.fcm_token import FCMToken
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FCMTokenRepository:
|
||||||
|
"""Web-push tokens, one row per signed-in browser."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def register(self, user_id: UUID, token: str) -> bool:
|
||||||
|
"""Store `token` for `user_id`, creating or reassigning its row.
|
||||||
|
|
||||||
|
FCM hands the same token back to a given browser install no matter who
|
||||||
|
is signed in, so an existing row is moved to the current user rather
|
||||||
|
than duplicated — otherwise a shared browser would keep receiving the
|
||||||
|
previous account's notifications.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if self._claim(user_id, token):
|
||||||
|
return True
|
||||||
|
# Lost the insert race: the client posts on every app load, so two
|
||||||
|
# tabs opening together both see no row and both insert. Re-run
|
||||||
|
# against the row the winner committed instead of failing the
|
||||||
|
# request and leaving this browser unregistered for the session.
|
||||||
|
self.db.rollback()
|
||||||
|
return self._claim(user_id, token)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error registering FCM token: {str(e)}")
|
||||||
|
self.db.rollback()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _claim(self, user_id: UUID, token: str) -> bool:
|
||||||
|
"""Point `token` at `user_id`. False if a concurrent insert won."""
|
||||||
|
existing = self.db.query(FCMToken).filter(
|
||||||
|
FCMToken.token == token).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
if existing.user_id != user_id:
|
||||||
|
logger.info(
|
||||||
|
f"Reassigning FCM token from user {existing.user_id} to {user_id}")
|
||||||
|
existing.user_id = user_id
|
||||||
|
else:
|
||||||
|
self.db.add(FCMToken(user_id=user_id, token=token))
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.db.commit()
|
||||||
|
return True
|
||||||
|
except IntegrityError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_tokens(self, user_id: UUID) -> List[str]:
|
||||||
|
"""Every device token registered for a user."""
|
||||||
|
try:
|
||||||
|
rows = self.db.query(FCMToken.token).filter(
|
||||||
|
FCMToken.user_id == user_id).all()
|
||||||
|
return [row.token for row in rows]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting FCM tokens: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def remove(self, user_id: UUID, token: str) -> bool:
|
||||||
|
"""Drop a single device's token — used on logout.
|
||||||
|
|
||||||
|
Scoped to the owner so a stolen token can't be used to silence someone
|
||||||
|
else's notifications.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
deleted = self.db.query(FCMToken).filter(
|
||||||
|
FCMToken.user_id == user_id,
|
||||||
|
FCMToken.token == token,
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
self.db.commit()
|
||||||
|
return deleted > 0
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error removing FCM token: {str(e)}")
|
||||||
|
self.db.rollback()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def remove_tokens(self, tokens: Sequence[str]) -> int:
|
||||||
|
"""Drop tokens FCM has told us are dead. Returns the number removed."""
|
||||||
|
if not tokens:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
deleted = self.db.query(FCMToken).filter(
|
||||||
|
FCMToken.token.in_(list(tokens))).delete(synchronize_session=False)
|
||||||
|
self.db.commit()
|
||||||
|
return deleted
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error pruning FCM tokens: {str(e)}")
|
||||||
|
self.db.rollback()
|
||||||
|
return 0
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES 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
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES 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()
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES 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
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES 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()
|
||||||
|
)
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES 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
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES 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
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.models.knowledge_queue import KnowledgeQueue, QueueStatus
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeQueueRepository:
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def create(self, queue_item: KnowledgeQueue) -> KnowledgeQueue:
|
||||||
|
self.db.add(queue_item)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(queue_item)
|
||||||
|
return queue_item
|
||||||
|
|
||||||
|
def get_by_id(self, queue_id: int) -> Optional[KnowledgeQueue]:
|
||||||
|
"""Get queue item by ID"""
|
||||||
|
return self.db.query(KnowledgeQueue).filter(
|
||||||
|
KnowledgeQueue.id == queue_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
def get_pending(self) -> List[KnowledgeQueue]:
|
||||||
|
"""Get all pending queue items ordered by priority (highest first), then by creation time"""
|
||||||
|
return self.db.query(KnowledgeQueue)\
|
||||||
|
.filter(KnowledgeQueue.status == QueueStatus.PENDING)\
|
||||||
|
.order_by(KnowledgeQueue.priority.desc(), KnowledgeQueue.created_at.asc())\
|
||||||
|
.all()
|
||||||
|
|
||||||
|
def get_abandoned(self, last_progress_before: datetime) -> List[KnowledgeQueue]:
|
||||||
|
"""Items still 'processing' with no progress since `last_progress_before`,
|
||||||
|
locked so two workers never handle the same one."""
|
||||||
|
return self.db.query(KnowledgeQueue)\
|
||||||
|
.filter(KnowledgeQueue.status == QueueStatus.PROCESSING,
|
||||||
|
func.coalesce(KnowledgeQueue.updated_at, KnowledgeQueue.created_at) < last_progress_before)\
|
||||||
|
.with_for_update(skip_locked=True)\
|
||||||
|
.all()
|
||||||
|
|
||||||
|
def update_status(self, queue_id: int, status: QueueStatus, error: Optional[str] = None) -> bool:
|
||||||
|
item = self.db.query(KnowledgeQueue).filter(
|
||||||
|
KnowledgeQueue.id == queue_id).first()
|
||||||
|
if item:
|
||||||
|
item.status = status
|
||||||
|
item.error = error
|
||||||
|
# Update processing stage to COMPLETED when status is COMPLETED
|
||||||
|
if status == QueueStatus.COMPLETED:
|
||||||
|
item.processing_stage = "COMPLETED"
|
||||||
|
item.progress_percentage = 100.0
|
||||||
|
self.db.commit()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def update_progress(
|
||||||
|
self,
|
||||||
|
queue_id: int,
|
||||||
|
processing_stage: str = None,
|
||||||
|
progress_percentage: float = None,
|
||||||
|
total_items: int = None,
|
||||||
|
processed_items: int = None,
|
||||||
|
crawled_url: str = None,
|
||||||
|
force_stage_update: bool = False
|
||||||
|
) -> bool:
|
||||||
|
"""Update the progress of a queue item"""
|
||||||
|
item = self.db.query(KnowledgeQueue).filter(
|
||||||
|
KnowledgeQueue.id == queue_id).first()
|
||||||
|
if item:
|
||||||
|
# Define stage priority to prevent regression
|
||||||
|
stage_priority = {
|
||||||
|
"not_started": 0,
|
||||||
|
"crawling": 1,
|
||||||
|
"embedding": 2,
|
||||||
|
"completed": 3
|
||||||
|
}
|
||||||
|
|
||||||
|
# Only update processing stage if it's an advancement or forced
|
||||||
|
if processing_stage and (force_stage_update or not item.processing_stage or
|
||||||
|
stage_priority.get(processing_stage.lower(), 0) >= stage_priority.get(item.processing_stage.lower(), 0)):
|
||||||
|
item.processing_stage = processing_stage
|
||||||
|
|
||||||
|
# Update progress percentage using main columns
|
||||||
|
if progress_percentage is not None:
|
||||||
|
item.progress_percentage = progress_percentage
|
||||||
|
elif processed_items is not None and total_items is not None and total_items > 0:
|
||||||
|
# Auto-calculate percentage based on processed/total items
|
||||||
|
item.progress_percentage = (processed_items / total_items) * 100
|
||||||
|
|
||||||
|
if total_items is not None:
|
||||||
|
item.total_items = total_items
|
||||||
|
|
||||||
|
if processed_items is not None:
|
||||||
|
item.processed_items = processed_items
|
||||||
|
|
||||||
|
# Add crawled URL to the list if provided (store only URL strings)
|
||||||
|
if crawled_url:
|
||||||
|
if not item.crawled_urls:
|
||||||
|
item.crawled_urls = []
|
||||||
|
# Store only the URL string, no timestamp or status
|
||||||
|
if crawled_url not in item.crawled_urls:
|
||||||
|
# Create a new list to ensure SQLAlchemy detects the change
|
||||||
|
new_crawled_urls = list(item.crawled_urls) if item.crawled_urls else []
|
||||||
|
new_crawled_urls.append(crawled_url)
|
||||||
|
item.crawled_urls = new_crawled_urls
|
||||||
|
|
||||||
|
# Mark the attribute as modified to ensure SQLAlchemy commits the change
|
||||||
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
flag_modified(item, 'crawled_urls')
|
||||||
|
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
logger.debug(f"📝 Added crawled URL to queue {queue_id}: {crawled_url} (total: {len(item.crawled_urls)})")
|
||||||
|
|
||||||
|
else:
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
logger.debug(f"🔄 URL already in list for queue {queue_id}: {crawled_url}")
|
||||||
|
|
||||||
|
# Commit changes and verify
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
# If we added a crawled URL, verify it was saved
|
||||||
|
if crawled_url:
|
||||||
|
self.db.refresh(item)
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
logger.debug(f"🔍 After commit - queue {queue_id} crawled_urls count: {len(item.crawled_urls) if item.crawled_urls else 0}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
return False
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES 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)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES 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
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
from typing import List, Optional
|
||||||
|
from app.models.agent import Agent
|
||||||
|
from app.models.mcp_tool import MCPTool, MCPToolToAgent, MCPTransportType
|
||||||
|
from app.models.ticket_settings import OrganizationTicketSettings
|
||||||
|
from uuid import UUID
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MCPToolRepository:
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def create_mcp_tool(self, **kwargs) -> MCPTool:
|
||||||
|
"""Create a new MCP tool"""
|
||||||
|
# Handle org_id to organization_id conversion for backward compatibility
|
||||||
|
if 'org_id' in kwargs:
|
||||||
|
kwargs['organization_id'] = kwargs.pop('org_id')
|
||||||
|
|
||||||
|
# Create new MCP tool
|
||||||
|
mcp_tool = MCPTool(**kwargs)
|
||||||
|
self.db.add(mcp_tool)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(mcp_tool)
|
||||||
|
return mcp_tool
|
||||||
|
|
||||||
|
def get_by_name(self, name: str, organization_id: UUID) -> Optional[MCPTool]:
|
||||||
|
"""Get MCP tool by name within an organization"""
|
||||||
|
return self.db.query(MCPTool).filter(
|
||||||
|
MCPTool.name == name,
|
||||||
|
MCPTool.organization_id == organization_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
def get_mcp_tool(self, mcp_tool_id: int) -> Optional[MCPTool]:
|
||||||
|
"""Get MCP tool by ID"""
|
||||||
|
return self.db.query(MCPTool).filter(
|
||||||
|
MCPTool.id == mcp_tool_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
def get_by_ids(self, org_id: UUID, tool_ids: List[int], enabled_only: bool = True) -> List[MCPTool]:
|
||||||
|
"""Get specific MCP tools by id, scoped to the organization — ids
|
||||||
|
from another org are silently dropped."""
|
||||||
|
if not tool_ids:
|
||||||
|
return []
|
||||||
|
query = self.db.query(MCPTool).filter(
|
||||||
|
MCPTool.organization_id == org_id,
|
||||||
|
MCPTool.id.in_(tool_ids),
|
||||||
|
)
|
||||||
|
if enabled_only:
|
||||||
|
query = query.filter(MCPTool.enabled == True)
|
||||||
|
return query.all()
|
||||||
|
|
||||||
|
def get_org_mcp_tools(self, org_id: UUID, enabled_only: bool = True) -> List[MCPTool]:
|
||||||
|
"""Get all MCP tools for an organization"""
|
||||||
|
query = self.db.query(MCPTool).filter(
|
||||||
|
MCPTool.organization_id == org_id
|
||||||
|
)
|
||||||
|
if enabled_only:
|
||||||
|
query = query.filter(MCPTool.enabled == True)
|
||||||
|
return query.all()
|
||||||
|
|
||||||
|
def update_mcp_tool(self, mcp_tool_id: int, **kwargs) -> Optional[MCPTool]:
|
||||||
|
"""Update an existing MCP tool"""
|
||||||
|
mcp_tool = self.get_mcp_tool(mcp_tool_id)
|
||||||
|
if not mcp_tool:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
setattr(mcp_tool, key, value)
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(mcp_tool)
|
||||||
|
return mcp_tool
|
||||||
|
|
||||||
|
def _get_ticket_settings(self, organization_id: UUID) -> Optional[OrganizationTicketSettings]:
|
||||||
|
return self.db.query(OrganizationTicketSettings).filter(
|
||||||
|
OrganizationTicketSettings.organization_id == organization_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
def is_used_in_investigations(self, mcp_tool_id: int, organization_id: UUID) -> bool:
|
||||||
|
"""Whether the org's investigation connector selection points at this tool."""
|
||||||
|
settings = self._get_ticket_settings(organization_id)
|
||||||
|
return bool(settings and mcp_tool_id in (settings.investigation_mcp_tool_ids or []))
|
||||||
|
|
||||||
|
def get_mcp_tool_agent_names(self, mcp_tool_id: int) -> List[str]:
|
||||||
|
"""Names of the agents this tool is linked to."""
|
||||||
|
rows = self.db.query(Agent.name)\
|
||||||
|
.join(MCPToolToAgent, MCPToolToAgent.agent_id == Agent.id)\
|
||||||
|
.filter(MCPToolToAgent.mcp_tool_id == mcp_tool_id)\
|
||||||
|
.order_by(Agent.name)\
|
||||||
|
.all()
|
||||||
|
return [name for (name,) in rows]
|
||||||
|
|
||||||
|
def delete_mcp_tool(self, mcp_tool_id: int) -> bool:
|
||||||
|
"""Delete an MCP tool, along with every reference to it."""
|
||||||
|
mcp_tool = self.get_mcp_tool(mcp_tool_id)
|
||||||
|
if not mcp_tool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# investigation_mcp_tool_ids is a plain JSON id list with no foreign
|
||||||
|
# key, so a deleted connector would otherwise stay "configured"
|
||||||
|
# forever and every later run would report fewer connectors loaded
|
||||||
|
# than configured — the exact signal #271 added to catch real
|
||||||
|
# failures. Agent links go with the tool via cascade.
|
||||||
|
settings = self._get_ticket_settings(mcp_tool.organization_id)
|
||||||
|
if settings and mcp_tool_id in (settings.investigation_mcp_tool_ids or []):
|
||||||
|
settings.investigation_mcp_tool_ids = [
|
||||||
|
tool_id for tool_id in settings.investigation_mcp_tool_ids
|
||||||
|
if tool_id != mcp_tool_id
|
||||||
|
]
|
||||||
|
|
||||||
|
self.db.delete(mcp_tool)
|
||||||
|
self.db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_enabled_mcp_tools(self, org_id: UUID) -> List[MCPTool]:
|
||||||
|
"""Get enabled MCP tools for an organization"""
|
||||||
|
return self.db.query(MCPTool)\
|
||||||
|
.filter(MCPTool.organization_id == org_id)\
|
||||||
|
.filter(MCPTool.enabled == True)\
|
||||||
|
.all()
|
||||||
|
|
||||||
|
def get_all_mcp_tools(self, org_id: UUID) -> List[MCPTool]:
|
||||||
|
"""Get all MCP tools for organization"""
|
||||||
|
return self.db.query(MCPTool)\
|
||||||
|
.filter(MCPTool.organization_id == org_id)\
|
||||||
|
.all()
|
||||||
|
|
||||||
|
# Agent-MCPTool association methods
|
||||||
|
def add_mcp_tool_to_agent(self, mcp_tool_id: int, agent_id: UUID) -> Optional[MCPToolToAgent]:
|
||||||
|
"""Add MCP tool to agent"""
|
||||||
|
# Check if association already exists
|
||||||
|
existing = self.db.query(MCPToolToAgent).filter(
|
||||||
|
MCPToolToAgent.mcp_tool_id == mcp_tool_id,
|
||||||
|
MCPToolToAgent.agent_id == agent_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
# Create new association
|
||||||
|
association = MCPToolToAgent(
|
||||||
|
mcp_tool_id=mcp_tool_id,
|
||||||
|
agent_id=agent_id
|
||||||
|
)
|
||||||
|
self.db.add(association)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(association)
|
||||||
|
return association
|
||||||
|
|
||||||
|
def remove_mcp_tool_from_agent(self, mcp_tool_id: int, agent_id: UUID) -> bool:
|
||||||
|
"""Remove MCP tool from agent"""
|
||||||
|
association = self.db.query(MCPToolToAgent).filter(
|
||||||
|
MCPToolToAgent.mcp_tool_id == mcp_tool_id,
|
||||||
|
MCPToolToAgent.agent_id == agent_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not association:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.db.delete(association)
|
||||||
|
self.db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_agent_mcp_tools(self, agent_id: UUID) -> List[MCPTool]:
|
||||||
|
"""Get all MCP tools associated with an agent"""
|
||||||
|
return self.db.query(MCPTool)\
|
||||||
|
.join(MCPToolToAgent)\
|
||||||
|
.filter(MCPToolToAgent.agent_id == agent_id)\
|
||||||
|
.filter(MCPTool.enabled == True)\
|
||||||
|
.all()
|
||||||
|
|
||||||
|
def get_mcp_tool_agents(self, mcp_tool_id: int) -> List[UUID]:
|
||||||
|
"""Get all agent IDs associated with an MCP tool"""
|
||||||
|
associations = self.db.query(MCPToolToAgent).filter(
|
||||||
|
MCPToolToAgent.mcp_tool_id == mcp_tool_id
|
||||||
|
).all()
|
||||||
|
return [assoc.agent_id for assoc in associations]
|
||||||
|
|
||||||
|
def get_mcp_tool_with_agents(self, mcp_tool_id: int) -> Optional[MCPTool]:
|
||||||
|
"""Get MCP tool with associated agents loaded"""
|
||||||
|
return self.db.query(MCPTool)\
|
||||||
|
.options(joinedload(MCPTool.agent_links))\
|
||||||
|
.filter(MCPTool.id == mcp_tool_id)\
|
||||||
|
.first()
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES 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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES 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
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES 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
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.models.rating import Rating
|
||||||
|
from typing import Optional, List
|
||||||
|
from uuid import UUID
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
|
||||||
|
class RatingRepository:
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def create_rating(self, session_id: UUID, customer_id: UUID, user_id: UUID, agent_id: UUID,
|
||||||
|
organization_id: UUID, rating: int, feedback: Optional[str] = None) -> Rating:
|
||||||
|
"""Create a new rating"""
|
||||||
|
db_rating = Rating(
|
||||||
|
session_id=session_id,
|
||||||
|
customer_id=customer_id,
|
||||||
|
user_id=user_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
organization_id=organization_id,
|
||||||
|
rating=rating,
|
||||||
|
feedback=feedback
|
||||||
|
)
|
||||||
|
self.db.add(db_rating)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(db_rating)
|
||||||
|
return db_rating
|
||||||
|
|
||||||
|
def upsert_rating(self, session_id: UUID, customer_id: UUID, user_id: UUID, agent_id: UUID,
|
||||||
|
organization_id: UUID, rating: int, feedback: Optional[str] = None) -> Rating:
|
||||||
|
"""Record the customer's rating for a session, replacing any earlier one.
|
||||||
|
|
||||||
|
One row per session: the widget can re-show the rating prompt, and
|
||||||
|
without this a customer could pile up rows that skew agent/org averages
|
||||||
|
and, since the score now flows onto the ticket, spam its activity feed.
|
||||||
|
"""
|
||||||
|
existing = self.get_rating_by_session(session_id)
|
||||||
|
if existing is None:
|
||||||
|
return self.create_rating(
|
||||||
|
session_id=session_id, customer_id=customer_id, user_id=user_id,
|
||||||
|
agent_id=agent_id, organization_id=organization_id,
|
||||||
|
rating=rating, feedback=feedback,
|
||||||
|
)
|
||||||
|
existing.rating = rating
|
||||||
|
existing.feedback = feedback
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(existing)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
def get_rating_by_session(self, session_id: UUID) -> Optional[Rating]:
|
||||||
|
"""Get rating by session ID"""
|
||||||
|
return self.db.query(Rating).filter(Rating.session_id == session_id).first()
|
||||||
|
|
||||||
|
def get_ratings_by_agent(self, agent_id: UUID) -> List[Rating]:
|
||||||
|
"""Get all ratings for an agent"""
|
||||||
|
return self.db.query(Rating).filter(Rating.agent_id == agent_id).all()
|
||||||
|
|
||||||
|
def get_ratings_by_customer(self, customer_id: UUID) -> List[Rating]:
|
||||||
|
"""Get all ratings from a customer"""
|
||||||
|
return self.db.query(Rating).filter(Rating.customer_id == customer_id).all()
|
||||||
|
|
||||||
|
def get_average_rating_by_agent(self, agent_id: UUID) -> float:
|
||||||
|
"""Get average rating for an agent"""
|
||||||
|
result = self.db.query(func.avg(Rating.rating)).filter(
|
||||||
|
Rating.agent_id == agent_id
|
||||||
|
).scalar()
|
||||||
|
return float(result) if result else 0.0
|
||||||
|
|
||||||
|
def get_organization_ratings(self, organization_id: UUID,
|
||||||
|
limit: int = 100, offset: int = 0) -> List[Rating]:
|
||||||
|
"""Get all ratings for an organization with pagination"""
|
||||||
|
return self.db.query(Rating).filter(
|
||||||
|
Rating.organization_id == organization_id
|
||||||
|
).order_by(Rating.created_at.desc()).offset(offset).limit(limit).all()
|
||||||
|
|
||||||
|
def get_organization_average_rating(self, organization_id: UUID) -> float:
|
||||||
|
"""Get average rating for an organization"""
|
||||||
|
result = self.db.query(func.avg(Rating.rating)).filter(
|
||||||
|
Rating.organization_id == organization_id
|
||||||
|
).scalar()
|
||||||
|
return float(result) if result else 0.0
|
||||||
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user