Import ClientFlow production v4928.1.5.132.4

This commit is contained in:
plx
2026-07-29 13:11:01 +00:00
parent 6445044ac6
commit 261d342057
405 changed files with 48373 additions and 1401 deletions

View File

@@ -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,