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