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