Files
ten31-database/backend/matrix_intake/crm_client.py
T
Keysat aefb2aa119 Matrix intake: main-timeline nudge, clearer messages, note text in grid
Four bot-side UX fixes surfaced by the live smoke:
- Post a brief pointer in the main timeline (a reply to the user's message)
  alongside the in-thread proposal card, so proposals aren't missed inside a
  thread. Pointer only — approvals still happen in the thread, where the note
  is visible (you can't make an informed yes/no without seeing it).
- A bare yes/no typed in the main timeline while a proposal is pending now
  gets a "reply in the thread" redirect instead of "couldn't tell what to record."
- Clearer commit confirmations: "Created a new grid entry for X" vs
  "Logged a note on X (existing grid entry)."
- Send a blank communication subject when a note is present so the grid's
  one-line note summary shows the note text, not the "(Matrix)" label
  (provenance stays in source="matrix_intake").
2026-06-17 17:14:08 -05:00

133 lines
5.5 KiB
Python

"""CRM API client for the intake bot's write-back leg.
The bot authenticates as a dedicated service user (Bearer JWT via /api/auth/login — the CRM
has no service-key path) and reuses the CRM's OWN canonical write endpoint
(/api/fundraising/log-communication) for both new-investor and existing-note cases, rather
than mutating the grid itself. That endpoint creates the grid row (create_investor_if_missing),
upserts the contact, logs the communication, appends a visible note, and re-syncs the
relational tables + audit — exactly as a UI grid edit would. We only tag provenance
(source="matrix_intake"). The payload builder is a pure function so it's unit-tested offline.
"""
import json
import ssl
import urllib.error
import urllib.request
from urllib.parse import urlencode
import settings
_token = None
def _http(method, path, body=None, token=None):
s = settings.crm_settings()
url = s["base"] + path
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, data=data, method=method, headers=headers)
ctx = None
if url.lower().startswith("https") and not s["verify_tls"]:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
with urllib.request.urlopen(req, timeout=30, context=ctx) as resp:
raw = resp.read()
return resp.status, (json.loads(raw) if raw else {})
except urllib.error.HTTPError as exc:
raw = exc.read()
try:
payload = json.loads(raw) if raw else {}
except Exception:
payload = {"raw": raw.decode("utf-8", "replace")}
return exc.code, payload
def _login():
global _token
s = settings.crm_settings()
if not s["username"] or not s["password"]:
raise RuntimeError("CRM bot creds not set (CRM_BOT_USERNAME / CRM_BOT_PASSWORD)")
status, data = _http("POST", "/api/auth/login",
{"username": s["username"], "password": s["password"]})
if status != 200 or not data.get("token"):
raise RuntimeError(f"CRM login failed ({status}): {data.get('error') or data}")
_token = data["token"]
return _token
def _authed(method, path, body=None):
"""Call the CRM with the cached token; re-login once on a 401 (token expiry)."""
global _token
token = _token or _login()
status, data = _http(method, path, body, token=token)
if status == 401:
token = _login()
status, data = _http(method, path, body, token=token)
return status, data
def match(proposal):
"""Return {'id', 'name'} for an existing investor matching this proposal, else None."""
q = proposal.get("investor_name") or proposal.get("contact_name") or ""
email = proposal.get("contact_email") or ""
if not q and not email:
return None
qs = urlencode({"q": q, "email": email})
status, data = _authed("GET", f"/api/intake/match?{qs}")
if status != 200:
raise RuntimeError(f"intake match failed ({status}): {data.get('error') or data}")
m = (data.get("data") or {}).get("match")
if not m:
return None
return {"id": m["id"], "name": m.get("investor_name") or q}
def build_commit_payload(proposal):
"""Pure: map a proposal to the /api/fundraising/log-communication request body.
Existing investor (carries _match_id) → target that exact grid row. Otherwise create the
investor if missing. The note becomes the communication body; the email is only sent when
it survived parse's source-text integrity check."""
contact = {
"name": proposal.get("contact_name") or proposal.get("investor_name") or "",
"email": proposal.get("contact_email") or "",
"title": proposal.get("contact_title") or "",
}
note = proposal.get("note") or ""
# The CRM's grid note line uses subject-or-body for its one-line summary, so a non-empty
# subject hides the actual note text. Send a blank subject when there's a note (let the note
# itself show in the grid); fall back to a provenance label only when there's nothing to
# show. Provenance is recorded via source="matrix_intake" either way.
intent_label = "Note (Matrix)" if proposal.get("intent") == "meeting_note" else "Intake (Matrix)"
payload = {
"contact": contact,
"type": "note",
"body": note,
"subject": "" if note.strip() else intent_label,
"append_note": True,
"source": "matrix_intake",
}
match_id = proposal.get("_match_id")
if match_id:
payload["row_id"] = match_id
else:
payload["investor_name"] = proposal.get("investor_name") or proposal.get("contact_name") or ""
payload["create_investor_if_missing"] = True
return payload
def commit(proposal):
"""Write the approved proposal to the CRM; return a short human summary for the thread."""
payload = build_commit_payload(proposal)
status, data = _authed("POST", "/api/fundraising/log-communication", payload)
if status not in (200, 201):
raise RuntimeError(f"log-communication failed ({status}): {data.get('error') or data}")
row = (data.get("data") or {}).get("row") or {}
name = row.get("investor_name") or payload.get("investor_name") or "investor"
if proposal.get("_match_id"):
return f"Logged a note on **{name}** (existing grid entry)."
return f"Created a new grid entry for **{name}**" + (" and logged a note." if payload.get("body") else ".")