2026-06-29 02:44:13 +02:00
|
|
|
"""AI Copilot service — NL query processing, action execution, RBAC, audit logging."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import uuid
|
2026-06-29 17:43:56 +02:00
|
|
|
from datetime import UTC, datetime
|
2026-06-29 02:44:13 +02:00
|
|
|
from typing import Any
|
|
|
|
|
|
2026-06-29 17:43:56 +02:00
|
|
|
from sqlalchemy import desc, func, select
|
2026-06-29 02:44:13 +02:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
from app.ai.llm_client import get_llm_client
|
|
|
|
|
from app.core.audit import log_audit
|
2026-06-29 17:43:56 +02:00
|
|
|
from app.core.auth import check_permission
|
2026-06-29 02:44:13 +02:00
|
|
|
from app.models.ai_conversation import AIConversation, AIMessage
|
2026-07-19 21:12:49 +02:00
|
|
|
from app.models.contact import Contact as Company
|
2026-06-29 02:44:13 +02:00
|
|
|
from app.models.contact import Contact
|
|
|
|
|
from app.models.workflow import Workflow
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _safe_iso(dt) -> str | None:
|
|
|
|
|
if dt is None:
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
return dt.isoformat() if hasattr(dt, "isoformat") else None
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_attr(obj, name, default=None):
|
|
|
|
|
try:
|
|
|
|
|
val = getattr(obj, name)
|
|
|
|
|
return val if val is not None else default
|
|
|
|
|
except Exception:
|
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _conversation_to_dict(c: AIConversation) -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"id": str(c.id),
|
|
|
|
|
"title": c.title,
|
|
|
|
|
"context": c.context,
|
|
|
|
|
"created_at": _safe_iso(_get_attr(c, "created_at")),
|
|
|
|
|
"updated_at": _safe_iso(_get_attr(c, "updated_at")),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _message_to_dict(m: AIMessage) -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"id": str(m.id),
|
|
|
|
|
"role": m.role,
|
|
|
|
|
"content": m.content,
|
|
|
|
|
"proposed_actions": m.proposed_actions,
|
|
|
|
|
"executed_action": m.executed_action,
|
|
|
|
|
"execution_result": m.execution_result,
|
|
|
|
|
"created_at": _safe_iso(_get_attr(m, "created_at")),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def process_query(
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
tenant_id: uuid.UUID,
|
|
|
|
|
user_id: uuid.UUID,
|
|
|
|
|
query: str,
|
|
|
|
|
conversation_id: str | None = None,
|
|
|
|
|
context: dict[str, Any] | None = None,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
"""Process a natural language query and return proposed actions.
|
|
|
|
|
|
|
|
|
|
1. Get or create conversation
|
|
|
|
|
2. Store user message
|
|
|
|
|
3. Call LLM client (mock or real) to get proposed actions
|
|
|
|
|
4. Store assistant message with proposed actions
|
|
|
|
|
5. Return response with conversation_id and proposed_actions
|
|
|
|
|
"""
|
|
|
|
|
context = context or {}
|
|
|
|
|
|
|
|
|
|
# Get or create conversation
|
|
|
|
|
if conversation_id:
|
|
|
|
|
conv_uuid = uuid.UUID(conversation_id)
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(AIConversation).where(
|
|
|
|
|
AIConversation.id == conv_uuid,
|
|
|
|
|
AIConversation.tenant_id == tenant_id,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
conversation = result.scalar_one_or_none()
|
|
|
|
|
if conversation is None:
|
|
|
|
|
return {"error": "Conversation not found", "status_code": 404}
|
|
|
|
|
else:
|
|
|
|
|
conversation = AIConversation(
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
title=query[:100] if query else "Untitled",
|
|
|
|
|
context=context,
|
|
|
|
|
)
|
|
|
|
|
db.add(conversation)
|
|
|
|
|
await db.flush()
|
|
|
|
|
await db.refresh(conversation)
|
|
|
|
|
|
|
|
|
|
# Get next message index
|
|
|
|
|
count_q = select(func.count()).select_from(
|
|
|
|
|
select(AIMessage).where(AIMessage.conversation_id == conversation.id).subquery()
|
|
|
|
|
)
|
|
|
|
|
count_result = await db.execute(count_q)
|
|
|
|
|
msg_index = count_result.scalar_one()
|
|
|
|
|
|
|
|
|
|
# Store user message
|
|
|
|
|
user_msg = AIMessage(
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
conversation_id=conversation.id,
|
|
|
|
|
role="user",
|
|
|
|
|
content=query,
|
|
|
|
|
message_index=msg_index,
|
|
|
|
|
)
|
|
|
|
|
db.add(user_msg)
|
|
|
|
|
await db.flush()
|
|
|
|
|
await db.refresh(user_msg)
|
|
|
|
|
|
|
|
|
|
# Call LLM client
|
|
|
|
|
llm = get_llm_client()
|
|
|
|
|
llm_response = await llm.generate(query, context)
|
|
|
|
|
|
|
|
|
|
# Store assistant message
|
|
|
|
|
assistant_msg = AIMessage(
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
conversation_id=conversation.id,
|
|
|
|
|
role="assistant",
|
|
|
|
|
content=llm_response.message,
|
|
|
|
|
proposed_actions=llm_response.proposed_actions,
|
|
|
|
|
message_index=msg_index + 1,
|
|
|
|
|
)
|
|
|
|
|
db.add(assistant_msg)
|
|
|
|
|
await db.flush()
|
|
|
|
|
await db.refresh(assistant_msg)
|
|
|
|
|
|
|
|
|
|
# Log to audit
|
|
|
|
|
await log_audit(
|
2026-06-29 17:43:56 +02:00
|
|
|
db,
|
|
|
|
|
tenant_id,
|
|
|
|
|
user_id,
|
2026-06-29 02:44:13 +02:00
|
|
|
action="query",
|
|
|
|
|
entity_type="ai_copilot",
|
|
|
|
|
entity_id=conversation.id,
|
|
|
|
|
changes={"query": query, "proposed_action_count": len(llm_response.proposed_actions)},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"conversation_id": str(conversation.id),
|
|
|
|
|
"message": llm_response.message,
|
|
|
|
|
"proposed_actions": llm_response.proposed_actions,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def execute_action(
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
tenant_id: uuid.UUID,
|
|
|
|
|
user_id: uuid.UUID,
|
|
|
|
|
role: str,
|
|
|
|
|
conversation_id: str,
|
|
|
|
|
action: dict[str, Any],
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
"""Execute a proposed action with RBAC enforcement.
|
|
|
|
|
|
|
|
|
|
1. Validate conversation belongs to tenant
|
|
|
|
|
2. Check RBAC permissions for the action
|
|
|
|
|
3. Execute the action (direct DB or API call)
|
|
|
|
|
4. Store execution result in message
|
|
|
|
|
5. Log to audit
|
|
|
|
|
"""
|
|
|
|
|
conv_uuid = uuid.UUID(conversation_id)
|
|
|
|
|
|
|
|
|
|
# Validate conversation ownership
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(AIConversation).where(
|
|
|
|
|
AIConversation.id == conv_uuid,
|
|
|
|
|
AIConversation.tenant_id == tenant_id,
|
2026-06-29 17:43:56 +02:00
|
|
|
)
|
2026-06-29 02:44:13 +02:00
|
|
|
)
|
|
|
|
|
conversation = result.scalar_one_or_none()
|
|
|
|
|
if conversation is None:
|
|
|
|
|
return {"error": "Conversation not found", "status_code": 404}
|
|
|
|
|
|
|
|
|
|
method = action.get("method", "GET").upper()
|
|
|
|
|
path = action.get("path", "")
|
|
|
|
|
body = action.get("body") or {}
|
|
|
|
|
|
|
|
|
|
# Determine module and action_type from path for RBAC
|
|
|
|
|
module, action_type = _derive_rbac_from_path(method, path)
|
|
|
|
|
|
|
|
|
|
if not check_permission(role, module, action_type):
|
|
|
|
|
return {
|
|
|
|
|
"error": "Insufficient permissions for this action",
|
|
|
|
|
"status_code": 403,
|
|
|
|
|
"success": False,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Execute the action
|
|
|
|
|
try:
|
|
|
|
|
exec_result = await _execute_api_action(db, tenant_id, user_id, method, path, body)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
exec_result = {"error": str(exc), "status_code": 500}
|
|
|
|
|
|
|
|
|
|
# Get next message index
|
|
|
|
|
count_q = select(func.count()).select_from(
|
|
|
|
|
select(AIMessage).where(AIMessage.conversation_id == conversation.id).subquery()
|
|
|
|
|
)
|
|
|
|
|
count_result = await db.execute(count_q)
|
|
|
|
|
msg_index = count_result.scalar_one()
|
|
|
|
|
|
|
|
|
|
# Store execution message
|
|
|
|
|
exec_msg = AIMessage(
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
conversation_id=conversation.id,
|
|
|
|
|
role="assistant",
|
|
|
|
|
content=f"Executed {method} {path}",
|
|
|
|
|
executed_action=action,
|
|
|
|
|
execution_result=exec_result,
|
|
|
|
|
message_index=msg_index,
|
|
|
|
|
)
|
|
|
|
|
db.add(exec_msg)
|
|
|
|
|
await db.flush()
|
|
|
|
|
await db.refresh(exec_msg)
|
|
|
|
|
|
|
|
|
|
# Log to audit
|
|
|
|
|
await log_audit(
|
2026-06-29 17:43:56 +02:00
|
|
|
db,
|
|
|
|
|
tenant_id,
|
|
|
|
|
user_id,
|
2026-06-29 02:44:13 +02:00
|
|
|
action="execute",
|
|
|
|
|
entity_type="ai_copilot",
|
|
|
|
|
entity_id=conversation.id,
|
|
|
|
|
changes={"action": action, "result": exec_result},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"conversation_id": str(conversation.id),
|
|
|
|
|
"success": exec_result.get("success", True),
|
|
|
|
|
"status_code": exec_result.get("status_code", 200),
|
|
|
|
|
"data": exec_result.get("data"),
|
|
|
|
|
"error": exec_result.get("error"),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_history(
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
tenant_id: uuid.UUID,
|
|
|
|
|
user_id: uuid.UUID,
|
|
|
|
|
page: int = 1,
|
|
|
|
|
page_size: int = 20,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
"""Get paginated conversation history for the current user."""
|
|
|
|
|
page = max(1, page)
|
|
|
|
|
page_size = max(1, min(100, page_size))
|
|
|
|
|
|
|
|
|
|
base = select(AIConversation).where(
|
|
|
|
|
AIConversation.tenant_id == tenant_id,
|
|
|
|
|
AIConversation.user_id == user_id,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
count_q = select(func.count()).select_from(base.subquery())
|
|
|
|
|
total_result = await db.execute(count_q)
|
|
|
|
|
total = total_result.scalar_one()
|
|
|
|
|
|
|
|
|
|
offset = (page - 1) * page_size
|
|
|
|
|
paginated = base.order_by(desc(AIConversation.created_at)).offset(offset).limit(page_size)
|
|
|
|
|
result = await db.execute(paginated)
|
|
|
|
|
conversations = result.scalars().all()
|
|
|
|
|
|
|
|
|
|
items: list[dict[str, Any]] = []
|
|
|
|
|
for conv in conversations:
|
|
|
|
|
# Get messages for each conversation
|
2026-06-29 17:43:56 +02:00
|
|
|
msg_q = (
|
|
|
|
|
select(AIMessage)
|
|
|
|
|
.where(
|
|
|
|
|
AIMessage.conversation_id == conv.id,
|
|
|
|
|
AIMessage.tenant_id == tenant_id,
|
|
|
|
|
)
|
|
|
|
|
.order_by(AIMessage.message_index)
|
|
|
|
|
)
|
2026-06-29 02:44:13 +02:00
|
|
|
msg_result = await db.execute(msg_q)
|
|
|
|
|
messages = msg_result.scalars().all()
|
|
|
|
|
|
2026-06-29 17:43:56 +02:00
|
|
|
items.append(
|
|
|
|
|
{
|
|
|
|
|
**_conversation_to_dict(conv),
|
|
|
|
|
"messages": [_message_to_dict(m) for m in messages],
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-06-29 02:44:13 +02:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"items": items,
|
|
|
|
|
"total": total,
|
|
|
|
|
"page": page,
|
|
|
|
|
"page_size": page_size,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _execute_api_action(
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
tenant_id: uuid.UUID,
|
|
|
|
|
user_id: uuid.UUID,
|
|
|
|
|
method: str,
|
|
|
|
|
path: str,
|
|
|
|
|
body: dict[str, Any],
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
"""Execute an API action directly against the database.
|
|
|
|
|
|
|
|
|
|
Supports companies and contacts CRUD, plus workflow listing.
|
|
|
|
|
"""
|
|
|
|
|
# Parse path to determine entity and operation
|
|
|
|
|
parts = path.replace("/api/v1/", "").strip("/").split("/")
|
|
|
|
|
entity = parts[0] if parts else ""
|
|
|
|
|
entity_id = parts[1] if len(parts) > 1 else None
|
|
|
|
|
|
|
|
|
|
if entity == "companies":
|
|
|
|
|
return await _exec_companies(db, tenant_id, user_id, method, entity_id, body)
|
|
|
|
|
elif entity == "contacts":
|
|
|
|
|
return await _exec_contacts(db, tenant_id, user_id, method, entity_id, body)
|
|
|
|
|
elif entity == "workflows":
|
|
|
|
|
return await _exec_workflows(db, tenant_id, user_id, method, entity_id, body)
|
|
|
|
|
else:
|
|
|
|
|
return {"error": f"Unsupported entity: {entity}", "status_code": 400, "success": False}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _exec_companies(
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
tenant_id: uuid.UUID,
|
|
|
|
|
user_id: uuid.UUID,
|
|
|
|
|
method: str,
|
|
|
|
|
entity_id: str | None,
|
|
|
|
|
body: dict[str, Any],
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
"""Execute company operations."""
|
|
|
|
|
if method == "GET":
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Company).where(
|
|
|
|
|
Company.tenant_id == tenant_id,
|
|
|
|
|
Company.deleted_at.is_(None),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
companies = result.scalars().all()
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"status_code": 200,
|
2026-06-29 17:43:56 +02:00
|
|
|
"data": [{"id": str(c.id), "name": c.name, "industry": c.industry} for c in companies],
|
2026-06-29 02:44:13 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
elif method == "POST":
|
|
|
|
|
company = Company(
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
name=body.get("name", "Untitled"),
|
|
|
|
|
industry=body.get("industry"),
|
|
|
|
|
phone=body.get("phone"),
|
|
|
|
|
email=body.get("email"),
|
|
|
|
|
website=body.get("website"),
|
|
|
|
|
description=body.get("description"),
|
|
|
|
|
created_by=user_id,
|
|
|
|
|
updated_by=user_id,
|
|
|
|
|
)
|
|
|
|
|
db.add(company)
|
|
|
|
|
await db.flush()
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"status_code": 201,
|
|
|
|
|
"data": {"id": str(company.id), "name": company.name},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
elif method == "DELETE":
|
|
|
|
|
if not entity_id or entity_id == "{id}":
|
|
|
|
|
return {"error": "Company ID required", "status_code": 400, "success": False}
|
|
|
|
|
comp_uuid = uuid.UUID(entity_id)
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Company).where(
|
|
|
|
|
Company.id == comp_uuid,
|
|
|
|
|
Company.tenant_id == tenant_id,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
company = result.scalar_one_or_none()
|
|
|
|
|
if company is None:
|
|
|
|
|
return {"error": "Company not found", "status_code": 404, "success": False}
|
2026-06-29 17:43:56 +02:00
|
|
|
company.deleted_at = datetime.now(UTC)
|
2026-06-29 02:44:13 +02:00
|
|
|
await db.flush()
|
2026-06-29 17:43:56 +02:00
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"status_code": 200,
|
|
|
|
|
"data": {"id": str(company.id), "deleted": True},
|
|
|
|
|
}
|
2026-06-29 02:44:13 +02:00
|
|
|
|
|
|
|
|
elif method == "PATCH":
|
|
|
|
|
if not entity_id or entity_id == "{id}":
|
|
|
|
|
return {"error": "Company ID required", "status_code": 400, "success": False}
|
|
|
|
|
comp_uuid = uuid.UUID(entity_id)
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Company).where(
|
|
|
|
|
Company.id == comp_uuid,
|
|
|
|
|
Company.tenant_id == tenant_id,
|
|
|
|
|
Company.deleted_at.is_(None),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
company = result.scalar_one_or_none()
|
|
|
|
|
if company is None:
|
|
|
|
|
return {"error": "Company not found", "status_code": 404, "success": False}
|
|
|
|
|
for key in ("name", "industry", "phone", "email", "website", "description"):
|
|
|
|
|
if key in body:
|
|
|
|
|
setattr(company, key, body[key])
|
|
|
|
|
company.updated_by = user_id
|
|
|
|
|
await db.flush()
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"status_code": 200,
|
|
|
|
|
"data": {"id": str(company.id), "name": company.name, "industry": company.industry},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {"error": f"Unsupported method: {method}", "status_code": 400, "success": False}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _exec_contacts(
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
tenant_id: uuid.UUID,
|
|
|
|
|
user_id: uuid.UUID,
|
|
|
|
|
method: str,
|
|
|
|
|
entity_id: str | None,
|
|
|
|
|
body: dict[str, Any],
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
"""Execute contact operations."""
|
|
|
|
|
if method == "GET":
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Contact).where(
|
|
|
|
|
Contact.tenant_id == tenant_id,
|
|
|
|
|
Contact.deleted_at.is_(None),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
contacts = result.scalars().all()
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"status_code": 200,
|
2026-06-29 17:43:56 +02:00
|
|
|
"data": [{"id": str(c.id), "name": c.name, "email": c.email} for c in contacts],
|
2026-06-29 02:44:13 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
elif method == "POST":
|
|
|
|
|
contact = Contact(
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
name=body.get("name", "Untitled"),
|
|
|
|
|
email=body.get("email"),
|
|
|
|
|
phone=body.get("phone"),
|
|
|
|
|
created_by=user_id,
|
|
|
|
|
updated_by=user_id,
|
|
|
|
|
)
|
|
|
|
|
db.add(contact)
|
|
|
|
|
await db.flush()
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"status_code": 201,
|
|
|
|
|
"data": {"id": str(contact.id), "name": contact.name},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {"error": f"Unsupported method: {method}", "status_code": 400, "success": False}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _exec_workflows(
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
tenant_id: uuid.UUID,
|
|
|
|
|
user_id: uuid.UUID,
|
|
|
|
|
method: str,
|
|
|
|
|
entity_id: str | None,
|
|
|
|
|
body: dict[str, Any],
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
"""Execute workflow operations."""
|
|
|
|
|
if method == "GET":
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Workflow).where(
|
|
|
|
|
Workflow.tenant_id == tenant_id,
|
|
|
|
|
Workflow.is_active.is_(True),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
workflows = result.scalars().all()
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"status_code": 200,
|
|
|
|
|
"data": [
|
|
|
|
|
{"id": str(w.id), "name": w.name, "trigger_event": w.trigger_event}
|
|
|
|
|
for w in workflows
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {"error": f"Unsupported method: {method}", "status_code": 400, "success": False}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _derive_rbac_from_path(method: str, path: str) -> tuple[str, str]:
|
|
|
|
|
"""Derive module and action_type from HTTP method and path for RBAC.
|
|
|
|
|
|
|
|
|
|
Returns (module, action_type) suitable for check_permission().
|
|
|
|
|
"""
|
|
|
|
|
parts = path.replace("/api/v1/", "").strip("/").split("/")
|
|
|
|
|
entity = parts[0] if parts else ""
|
|
|
|
|
|
|
|
|
|
method_to_action = {
|
|
|
|
|
"GET": "read",
|
|
|
|
|
"POST": "create",
|
|
|
|
|
"PATCH": "update",
|
|
|
|
|
"DELETE": "delete",
|
|
|
|
|
}
|
|
|
|
|
action_type = method_to_action.get(method, "read")
|
|
|
|
|
|
|
|
|
|
# Map path entities to permission modules
|
|
|
|
|
entity_to_module = {
|
|
|
|
|
"companies": "companies",
|
|
|
|
|
"contacts": "contacts",
|
|
|
|
|
"workflows": "workflows",
|
|
|
|
|
"ai": "ai_copilot",
|
|
|
|
|
}
|
|
|
|
|
module = entity_to_module.get(entity, entity)
|
|
|
|
|
|
|
|
|
|
return module, action_type
|