323 lines
15 KiB
Python
323 lines
15 KiB
Python
"""Communication/inbox-classification helpers for ClientFlow v4.5.
|
|
|
|
A communication is the durable record of an inbound/outbound email, Chatwoot
|
|
message or other customer message. Tasks and outbox items are the actions that
|
|
come from it; this module deliberately keeps the original communication separate
|
|
from human work and automation work.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from sqlalchemy import text
|
|
|
|
from app.db import engine
|
|
|
|
|
|
COMMUNICATION_STATUSES = {
|
|
"new",
|
|
"classified",
|
|
"needs_review",
|
|
"linked",
|
|
"task_created",
|
|
"done",
|
|
"ignored",
|
|
}
|
|
|
|
|
|
ACTION_CLASSIFICATION_MAP: dict[str, tuple[str, str, str]] = {
|
|
"pedido_orcamento": ("Comercial", "Criar/validar oportunidade", "cf-chip-blue"),
|
|
"pedido_informacao": ("Comercial", "Responder pedido de informação", "cf-chip-blue"),
|
|
"aceitacao_orcamento": ("Financeiro", "Converter em fatura/pró-forma", "cf-chip-green"),
|
|
"comprovativo_pagamento": ("Financeiro", "Confirmar pagamento", "cf-chip-green"),
|
|
"pedido_fatura": ("Financeiro", "Emitir/enviar fatura", "cf-chip-green"),
|
|
"dados_fiscais": ("Financeiro", "Atualizar dados fiscais", "cf-chip-green"),
|
|
"pedido_tracking": ("Operações", "Verificar envio/tracking", "cf-chip-purple"),
|
|
"reclamacao": ("Suporte", "Responder reclamação", "cf-chip-orange"),
|
|
"pedido_remocao_lista": ("Marketing", "Remover contacto da lista", "cf-chip-gray"),
|
|
}
|
|
|
|
|
|
def ensure_communication_schema() -> None:
|
|
"""Create/upgrade v4.5 communications and timeline tables.
|
|
|
|
The statements are additive to be safe on field deployments.
|
|
"""
|
|
with engine.begin() as conn:
|
|
conn.execute(text("CREATE EXTENSION IF NOT EXISTS pgcrypto"))
|
|
|
|
conn.execute(text("""
|
|
CREATE TABLE IF NOT EXISTS communications (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
source_system TEXT NOT NULL DEFAULT 'email',
|
|
source_message_id TEXT,
|
|
thread_id TEXT,
|
|
conversation_id TEXT,
|
|
contact_id TEXT,
|
|
direction TEXT NOT NULL DEFAULT 'inbound',
|
|
sender_name TEXT,
|
|
sender_email TEXT,
|
|
recipient TEXT,
|
|
subject TEXT,
|
|
body TEXT,
|
|
classification TEXT,
|
|
confidence NUMERIC(4,3),
|
|
status TEXT NOT NULL DEFAULT 'new',
|
|
customer_id UUID,
|
|
opportunity_id UUID,
|
|
task_id UUID,
|
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
"""))
|
|
|
|
for statement in [
|
|
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS source_system TEXT NOT NULL DEFAULT 'email'",
|
|
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS source_message_id TEXT",
|
|
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS thread_id TEXT",
|
|
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS conversation_id TEXT",
|
|
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS contact_id TEXT",
|
|
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS direction TEXT NOT NULL DEFAULT 'inbound'",
|
|
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS sender_name TEXT",
|
|
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS sender_email TEXT",
|
|
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS recipient TEXT",
|
|
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS subject TEXT",
|
|
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS body TEXT",
|
|
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS classification TEXT",
|
|
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS confidence NUMERIC(4,3)",
|
|
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'new'",
|
|
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS customer_id UUID",
|
|
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS opportunity_id UUID",
|
|
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS task_id UUID",
|
|
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'::jsonb",
|
|
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT now()",
|
|
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now()",
|
|
]:
|
|
conn.execute(text(statement))
|
|
|
|
conn.execute(text("""
|
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_communications_source_message
|
|
ON communications(source_system, source_message_id)
|
|
WHERE source_message_id IS NOT NULL
|
|
"""))
|
|
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_communications_created ON communications(created_at DESC)"))
|
|
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_communications_status ON communications(status)"))
|
|
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_communications_classification ON communications(classification)"))
|
|
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_communications_sender_email ON communications(sender_email)"))
|
|
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_communications_customer ON communications(customer_id)"))
|
|
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_communications_opportunity ON communications(opportunity_id)"))
|
|
|
|
# v4.5 task context columns. Existing code still uses action/route; these
|
|
# columns let the UI connect a task back to the communication/document/etc.
|
|
for statement in [
|
|
"ALTER TABLE tasks ADD COLUMN IF NOT EXISTS communication_id UUID",
|
|
"ALTER TABLE tasks ADD COLUMN IF NOT EXISTS document_id UUID",
|
|
"ALTER TABLE tasks ADD COLUMN IF NOT EXISTS shipment_id UUID",
|
|
"ALTER TABLE tasks ADD COLUMN IF NOT EXISTS outbox_id UUID",
|
|
"ALTER TABLE tasks ADD COLUMN IF NOT EXISTS priority TEXT NOT NULL DEFAULT 'normal'",
|
|
"ALTER TABLE tasks ADD COLUMN IF NOT EXISTS assigned_to TEXT",
|
|
]:
|
|
conn.execute(text(statement))
|
|
|
|
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_tasks_communication ON tasks(communication_id)"))
|
|
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_tasks_document ON tasks(document_id)"))
|
|
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_tasks_shipment ON tasks(shipment_id)"))
|
|
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_tasks_outbox ON tasks(outbox_id)"))
|
|
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority)"))
|
|
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_tasks_due_status ON tasks(status, due_at)"))
|
|
|
|
conn.execute(text("""
|
|
CREATE TABLE IF NOT EXISTS timeline_events (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
opportunity_id UUID,
|
|
customer_id UUID,
|
|
event_type TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
description TEXT,
|
|
source TEXT NOT NULL DEFAULT 'clientflow',
|
|
related_type TEXT,
|
|
related_id TEXT,
|
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
created_by TEXT NOT NULL DEFAULT 'system',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
"""))
|
|
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_timeline_opportunity ON timeline_events(opportunity_id, created_at DESC)"))
|
|
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_timeline_customer ON timeline_events(customer_id, created_at DESC)"))
|
|
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_timeline_related ON timeline_events(related_type, related_id)"))
|
|
|
|
|
|
def _row_dict(row: Any) -> Dict[str, Any]:
|
|
return dict(row) if row is not None else {}
|
|
|
|
|
|
def normalize_status(status: Optional[str]) -> Optional[str]:
|
|
if not status:
|
|
return None
|
|
status = status.strip().lower()
|
|
return status if status in COMMUNICATION_STATUSES else None
|
|
|
|
|
|
def list_communications(
|
|
*,
|
|
status: Optional[str] = None,
|
|
classification: Optional[str] = None,
|
|
q: Optional[str] = None,
|
|
opportunity_id: Optional[str] = None,
|
|
limit: int = 50,
|
|
) -> List[Dict[str, Any]]:
|
|
"""List classified inbox items with optional filters."""
|
|
filters = []
|
|
params: Dict[str, Any] = {"limit": int(limit)}
|
|
if normalize_status(status):
|
|
filters.append("c.status = :status")
|
|
params["status"] = normalize_status(status)
|
|
if classification:
|
|
filters.append("c.classification = :classification")
|
|
params["classification"] = classification
|
|
if opportunity_id:
|
|
filters.append("c.opportunity_id = CAST(:opportunity_id AS UUID)")
|
|
params["opportunity_id"] = opportunity_id
|
|
if q:
|
|
filters.append("(c.sender_email ILIKE :q OR c.sender_name ILIKE :q OR c.subject ILIKE :q OR c.body ILIKE :q OR c.classification ILIKE :q)")
|
|
params["q"] = f"%{q}%"
|
|
where = "WHERE " + " AND ".join(filters) if filters else ""
|
|
|
|
with engine.begin() as conn:
|
|
rows = conn.execute(text(f"""
|
|
SELECT c.id::text, c.source_system, c.source_message_id, c.thread_id,
|
|
c.conversation_id, c.contact_id, c.direction, c.sender_name,
|
|
c.sender_email, c.recipient, c.subject, c.body, c.classification,
|
|
c.confidence, c.status, c.customer_id::text, c.opportunity_id::text,
|
|
c.task_id::text, c.metadata, c.created_at, c.updated_at,
|
|
cu.name AS customer_name, o.title AS opportunity_title
|
|
FROM communications c
|
|
LEFT JOIN customers cu ON cu.id = c.customer_id
|
|
LEFT JOIN opportunities o ON o.id = c.opportunity_id
|
|
{where}
|
|
ORDER BY c.created_at DESC
|
|
LIMIT :limit
|
|
"""), params).mappings().all()
|
|
return [dict(row) for row in rows]
|
|
|
|
|
|
def list_communications_for_opportunity(opportunity_id: str, limit: int = 20) -> List[Dict[str, Any]]:
|
|
return list_communications(opportunity_id=opportunity_id, limit=limit)
|
|
|
|
|
|
def get_communication(communication_id: str) -> Optional[Dict[str, Any]]:
|
|
with engine.begin() as conn:
|
|
row = conn.execute(text("""
|
|
SELECT c.id::text, c.source_system, c.source_message_id, c.thread_id,
|
|
c.conversation_id, c.contact_id, c.direction, c.sender_name,
|
|
c.sender_email, c.recipient, c.subject, c.body, c.classification,
|
|
c.confidence, c.status, c.customer_id::text, c.opportunity_id::text,
|
|
c.task_id::text, c.metadata, c.created_at, c.updated_at,
|
|
cu.name AS customer_name, o.title AS opportunity_title
|
|
FROM communications c
|
|
LEFT JOIN customers cu ON cu.id = c.customer_id
|
|
LEFT JOIN opportunities o ON o.id = c.opportunity_id
|
|
WHERE c.id = CAST(:id AS UUID)
|
|
"""), {"id": communication_id}).mappings().first()
|
|
return dict(row) if row else None
|
|
|
|
|
|
def get_communications_summary() -> Dict[str, int]:
|
|
with engine.begin() as conn:
|
|
row = conn.execute(text("""
|
|
SELECT
|
|
COUNT(*)::int AS total,
|
|
COUNT(*) FILTER (WHERE status IN ('new','classified','needs_review'))::int AS open,
|
|
COUNT(*) FILTER (WHERE status = 'needs_review')::int AS needs_review,
|
|
COUNT(*) FILTER (WHERE customer_id IS NULL AND direction = 'inbound')::int AS without_customer,
|
|
COUNT(*) FILTER (WHERE opportunity_id IS NULL AND direction = 'inbound')::int AS without_opportunity,
|
|
COUNT(*) FILTER (WHERE task_id IS NOT NULL)::int AS with_task,
|
|
COUNT(*) FILTER (WHERE created_at >= now() - interval '24 hours')::int AS last_24h
|
|
FROM communications
|
|
""")).mappings().first()
|
|
return {k: int(v or 0) for k, v in dict(row or {}).items()}
|
|
|
|
|
|
def set_communication_status(communication_id: str, status: str) -> None:
|
|
status = normalize_status(status)
|
|
if not status:
|
|
raise ValueError("Estado de comunicação inválido.")
|
|
with engine.begin() as conn:
|
|
conn.execute(text("""
|
|
UPDATE communications
|
|
SET status = :status, updated_at = now()
|
|
WHERE id = CAST(:id AS UUID)
|
|
"""), {"id": communication_id, "status": status})
|
|
|
|
|
|
def link_communication_to_customer(communication_id: str, customer_id: Optional[str]) -> None:
|
|
with engine.begin() as conn:
|
|
conn.execute(text("""
|
|
UPDATE communications
|
|
SET customer_id = CASE WHEN :customer_id = '' THEN NULL ELSE CAST(:customer_id AS UUID) END,
|
|
status = CASE WHEN status IN ('new','classified','needs_review') THEN 'linked' ELSE status END,
|
|
updated_at = now()
|
|
WHERE id = CAST(:id AS UUID)
|
|
"""), {"id": communication_id, "customer_id": customer_id or ""})
|
|
|
|
|
|
def link_communication_to_opportunity(communication_id: str, opportunity_id: Optional[str]) -> None:
|
|
with engine.begin() as conn:
|
|
conn.execute(text("""
|
|
UPDATE communications
|
|
SET opportunity_id = CASE WHEN :opportunity_id = '' THEN NULL ELSE CAST(:opportunity_id AS UUID) END,
|
|
status = CASE WHEN status IN ('new','classified','needs_review') THEN 'linked' ELSE status END,
|
|
updated_at = now()
|
|
WHERE id = CAST(:id AS UUID)
|
|
"""), {"id": communication_id, "opportunity_id": opportunity_id or ""})
|
|
|
|
|
|
def create_timeline_event(
|
|
*,
|
|
opportunity_id: Optional[str] = None,
|
|
customer_id: Optional[str] = None,
|
|
event_type: str,
|
|
title: str,
|
|
description: str = "",
|
|
source: str = "clientflow",
|
|
related_type: Optional[str] = None,
|
|
related_id: Optional[str] = None,
|
|
payload: Optional[Dict[str, Any]] = None,
|
|
created_by: str = "system",
|
|
) -> Optional[str]:
|
|
if not opportunity_id and not customer_id:
|
|
return None
|
|
with engine.begin() as conn:
|
|
row = conn.execute(text("""
|
|
INSERT INTO timeline_events (
|
|
opportunity_id, customer_id, event_type, title, description,
|
|
source, related_type, related_id, payload, created_by
|
|
) VALUES (
|
|
CASE WHEN :opportunity_id = '' THEN NULL ELSE CAST(:opportunity_id AS UUID) END,
|
|
CASE WHEN :customer_id = '' THEN NULL ELSE CAST(:customer_id AS UUID) END,
|
|
:event_type, :title, :description, :source, :related_type,
|
|
:related_id, CAST(:payload AS JSONB), :created_by
|
|
)
|
|
RETURNING id::text
|
|
"""), {
|
|
"opportunity_id": opportunity_id or "",
|
|
"customer_id": customer_id or "",
|
|
"event_type": event_type,
|
|
"title": title,
|
|
"description": description,
|
|
"source": source,
|
|
"related_type": related_type,
|
|
"related_id": related_id,
|
|
"payload": json.dumps(payload or {}, ensure_ascii=False),
|
|
"created_by": created_by,
|
|
}).first()
|
|
return str(row[0]) if row else None
|
|
|
|
|
|
def classification_action(classification: Optional[str]) -> Dict[str, str]:
|
|
key = (classification or "").strip().lower()
|
|
queue, action, chip = ACTION_CLASSIFICATION_MAP.get(key, ("Revisão", "Rever classificação", "cf-chip-orange"))
|
|
return {"queue": queue, "action": action, "chip": chip}
|