feat(models): 组织、用户、角色、客户、会话与消息模型

按组织边界定义数据关系,补齐仓储层与通用工具,迁移脚本可重复执行。
This commit is contained in:
WangShen007 committed 2026-09-23 22:05:00 +08:00
1 parent 977783c126
commit 9eb8613c05
126 files changed
+17436

No files matched your search

+18
View File
@@ -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
+182
View File
@@ -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
+116
View File
@@ -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()
+714
View File
@@ -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
}
+428
View File
@@ -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
+488
View File
@@ -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
+239
View File
@@ -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
+115
View File
@@ -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
+28
View File
@@ -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()
+111
View File
@@ -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
+129
View File
@@ -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()
)
+116
View File
@@ -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
+214
View File
@@ -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
+149
View File
@@ -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)
+60
View File
@@ -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
+203
View File
@@ -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)
}
+165
View File
@@ -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
+422
View File
@@ -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
+97
View File
@@ -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
+184
View File
@@ -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
+556
View File
@@ -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)
+119
View File
@@ -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
+116
View File
@@ -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 []
+60
View File
@@ -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()
+215
View File
@@ -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
+127
View File
@@ -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()
+133
View File
@@ -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