Import ClientFlow production v4928.1.5.132.4
This commit is contained in:
@@ -203,8 +203,107 @@ def list_communications(
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def _list_message_backed_chatwoot_items_for_opportunity(opportunity_id: str, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""Return inbound Chatwoot messages visible from raw_events/messages.
|
||||
|
||||
Some historical Chatwoot ingestions created `messages`/`raw_events` and
|
||||
tasks/opportunities but did not create rows in the later `communications`
|
||||
inbox table. The opportunity detail must still show those messages; hiding
|
||||
them makes the page say a conversation is linked while "no messages" exist.
|
||||
"""
|
||||
if not str(opportunity_id or "").strip():
|
||||
return []
|
||||
with engine.begin() as conn:
|
||||
rows = conn.execute(text("""
|
||||
WITH opp AS (
|
||||
SELECT id, conversation_id, contact_id
|
||||
FROM opportunities
|
||||
WHERE id = CAST(:opportunity_id AS UUID)
|
||||
LIMIT 1
|
||||
), ranked AS (
|
||||
SELECT DISTINCT ON (m.id)
|
||||
m.id::text AS id,
|
||||
m.source_system,
|
||||
COALESCE(re.source_event_id, m.source_event_id, m.id::text) AS source_message_id,
|
||||
m.conversation_id,
|
||||
m.contact_id,
|
||||
m.direction,
|
||||
COALESCE(
|
||||
re.payload #>> '{sender,name}',
|
||||
re.payload #>> '{sender,available_name}',
|
||||
re.payload #>> '{sender,display_name}',
|
||||
re.payload #>> '{contact,name}',
|
||||
re.payload #>> '{conversation,contact,name}',
|
||||
re.payload #>> '{message,sender,name}'
|
||||
) AS sender_name,
|
||||
COALESCE(
|
||||
re.payload #>> '{sender,email}',
|
||||
re.payload #>> '{contact,email}',
|
||||
re.payload #>> '{conversation,contact,email}',
|
||||
re.payload #>> '{message,sender,email}'
|
||||
) AS sender_email,
|
||||
NULL::text AS recipient,
|
||||
COALESCE(
|
||||
re.payload #>> '{message,content_attributes,email,subject}',
|
||||
re.payload #>> '{content_attributes,email,subject}',
|
||||
re.payload #>> '{message,content_attributes,subject}',
|
||||
re.payload #>> '{content_attributes,subject}',
|
||||
re.payload #>> '{conversation,additional_attributes,mail_subject}',
|
||||
'Chatwoot #' || COALESCE(re.source_event_id, m.source_event_id, m.id::text)
|
||||
) AS subject,
|
||||
COALESCE(m.clean_body, m.raw_body, re.payload #>> '{content}', re.payload #>> '{message,content}') AS body,
|
||||
COALESCE(ar.action_result ->> 'action_code', ar.action_decision ->> 'action_code') AS classification,
|
||||
COALESCE(t.status, 'indexed') AS status,
|
||||
NULL::text AS customer_id,
|
||||
COALESCE(t.opportunity_id::text, opp.id::text) AS opportunity_id,
|
||||
t.id::text AS task_id,
|
||||
jsonb_build_object(
|
||||
'source_kind', 'message_raw_event',
|
||||
'raw_event_id', re.id::text,
|
||||
'task_id', t.id::text,
|
||||
'linked_by', CASE
|
||||
WHEN t.opportunity_id = opp.id THEN 'task_opportunity'
|
||||
WHEN NULLIF(opp.conversation_id, '') IS NOT NULL AND m.conversation_id = opp.conversation_id THEN 'conversation_id'
|
||||
WHEN NULLIF(opp.conversation_id, '') IS NOT NULL AND re.conversation_id = opp.conversation_id THEN 'raw_event_conversation_id'
|
||||
ELSE 'unknown'
|
||||
END
|
||||
) AS metadata,
|
||||
m.created_at,
|
||||
m.created_at AS updated_at
|
||||
FROM opp
|
||||
JOIN messages m ON m.source_system = 'chatwoot'
|
||||
LEFT JOIN raw_events re ON re.id = m.raw_event_id OR re.message_id = m.id
|
||||
LEFT JOIN action_runs ar ON ar.message_id = m.id OR ar.raw_event_id = re.id
|
||||
LEFT JOIN tasks t ON t.message_id = m.id OR t.raw_event_id = re.id OR t.action_run_id = ar.id
|
||||
WHERE
|
||||
t.opportunity_id = opp.id
|
||||
OR (
|
||||
NULLIF(opp.conversation_id, '') IS NOT NULL
|
||||
AND (m.conversation_id = opp.conversation_id OR re.conversation_id = opp.conversation_id)
|
||||
)
|
||||
ORDER BY m.id, COALESCE(t.created_at, m.created_at) DESC
|
||||
)
|
||||
SELECT *
|
||||
FROM ranked
|
||||
ORDER BY created_at DESC
|
||||
LIMIT :limit
|
||||
"""), {"opportunity_id": opportunity_id, "limit": max(1, int(limit or 20))}).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)
|
||||
canonical = list_communications(opportunity_id=opportunity_id, limit=limit)
|
||||
fallback = _list_message_backed_chatwoot_items_for_opportunity(opportunity_id, limit=limit)
|
||||
seen: set[str] = set()
|
||||
combined: List[Dict[str, Any]] = []
|
||||
for item in canonical + fallback:
|
||||
key = f"{item.get('source_system')}:{item.get('source_message_id') or item.get('id')}"
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
combined.append(item)
|
||||
combined.sort(key=lambda row: str(row.get("created_at") or ""), reverse=True)
|
||||
return combined[: max(1, int(limit or 20))]
|
||||
|
||||
|
||||
def get_communication(communication_id: str) -> Optional[Dict[str, Any]]:
|
||||
@@ -274,6 +373,63 @@ def link_communication_to_opportunity(communication_id: str, opportunity_id: Opt
|
||||
"""), {"id": communication_id, "opportunity_id": opportunity_id or ""})
|
||||
|
||||
|
||||
|
||||
|
||||
def record_outbound_communication(
|
||||
*,
|
||||
source_system: str = "chatwoot",
|
||||
source_message_id: Optional[str] = None,
|
||||
conversation_id: Optional[str] = None,
|
||||
contact_id: Optional[str] = None,
|
||||
body: str,
|
||||
customer_id: Optional[str] = None,
|
||||
opportunity_id: Optional[str] = None,
|
||||
task_id: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[str]:
|
||||
"""Persist an outbound customer message in ClientFlow.
|
||||
|
||||
This makes the opportunity timeline auditable even when Chatwoot remains the
|
||||
transport layer.
|
||||
"""
|
||||
ensure_communication_schema()
|
||||
with engine.begin() as conn:
|
||||
row = conn.execute(text("""
|
||||
INSERT INTO communications (
|
||||
source_system, source_message_id, conversation_id, contact_id,
|
||||
direction, body, status, customer_id, opportunity_id, task_id,
|
||||
metadata
|
||||
) VALUES (
|
||||
:source_system, :source_message_id, :conversation_id, :contact_id,
|
||||
'outbound', :body, 'done',
|
||||
CASE WHEN :customer_id = '' THEN NULL ELSE CAST(:customer_id AS UUID) END,
|
||||
CASE WHEN :opportunity_id = '' THEN NULL ELSE CAST(:opportunity_id AS UUID) END,
|
||||
CASE WHEN :task_id = '' THEN NULL ELSE CAST(:task_id AS UUID) END,
|
||||
CAST(:metadata AS JSONB)
|
||||
)
|
||||
ON CONFLICT (source_system, source_message_id) WHERE source_message_id IS NOT NULL
|
||||
DO UPDATE SET
|
||||
body = EXCLUDED.body,
|
||||
status = 'done',
|
||||
customer_id = COALESCE(EXCLUDED.customer_id, communications.customer_id),
|
||||
opportunity_id = COALESCE(EXCLUDED.opportunity_id, communications.opportunity_id),
|
||||
task_id = COALESCE(EXCLUDED.task_id, communications.task_id),
|
||||
metadata = COALESCE(communications.metadata, '{}'::jsonb) || EXCLUDED.metadata,
|
||||
updated_at = now()
|
||||
RETURNING id::text
|
||||
"""), {
|
||||
"source_system": source_system or "chatwoot",
|
||||
"source_message_id": source_message_id,
|
||||
"conversation_id": conversation_id or "",
|
||||
"contact_id": contact_id or "",
|
||||
"body": body or "",
|
||||
"customer_id": customer_id or "",
|
||||
"opportunity_id": opportunity_id or "",
|
||||
"task_id": task_id or "",
|
||||
"metadata": json.dumps(metadata or {}, ensure_ascii=False, default=str),
|
||||
}).first()
|
||||
return str(row[0]) if row else None
|
||||
|
||||
def create_timeline_event(
|
||||
*,
|
||||
opportunity_id: Optional[str] = None,
|
||||
|
||||
Reference in New Issue
Block a user