Mobile Phase 8a+8b: re-author Grid/Contacts cards + Contacts/Pipeline detail bottom sheets
8a — Grid card: existing-LP earmark corner-triangle (replaces left-border), right-side
PRIORITY pill (replaces the rejected star), 4-stage chip, zero-commit dim; detail star ->
"Existing LP" pill. Contacts card: two-letter avatar initials + existing-LP ring + stage pill
+ recency; disposition badge dropped. New backend contact_grid_signals() injects derived
read-only committed/pipeline_stage on GET /api/contacts and /api/contacts/{id} (existing-LP
ring + stage pill); read-only directory, so no strip-point. DESIGN.md §4/§8 reconciled.
8b — Contacts and Pipeline detail surfaces converted from full-screen to drag-dismiss bottom
sheets matching the .dc.html anatomy: Contacts gets an email-copy pill, Log/Email actions, and
an Organization card; Pipeline gets stat tiles, an inline move-stage list, and a notes timeline
+ Log sheet. Both log via POST /api/communications; BottomSheet gains a `stacked` prop to layer
the Log sheet over a detail. Reviewer fixes: cancelled-flag fetch guards (stale-response race),
keyed single-contact signals query, multi-investor dedup test.
All deploy-pending (no s9pk built); not device-tested. 38/38 backend tests green.
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the mobile Contacts card's grid-derived signals (Phase 8a).
|
||||
|
||||
GET /api/contacts enriches each classic contact with two read-only, live-derived fields
|
||||
sourced from the fundraising grid (the canonical investor model), for the mobile card:
|
||||
- `committed` -> the linked investor's total_invested (>0 drives the existing-LP avatar ring),
|
||||
mirroring existing_investor_by_source_row (committed capital, not graveyard);
|
||||
- `pipeline_stage` -> that investor's live derived stage (drives the card's stage pill),
|
||||
or null when the investor isn't in the pipeline.
|
||||
A contact with no grid link (pure classic/legacy contact) gets committed 0 / stage null.
|
||||
Signals are derived fresh on read and never stored on the contact. Synthetic data only.
|
||||
|
||||
Run: cd backend && python3 test_contacts_grid_signals.py
|
||||
"""
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from http.server import ThreadingHTTPServer
|
||||
|
||||
_DATA = tempfile.mkdtemp()
|
||||
os.environ["CRM_DATA_DIR"] = _DATA
|
||||
os.environ["CRM_DB_PATH"] = os.path.join(_DATA, "crm.db")
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import server # noqa: E402
|
||||
|
||||
FAILS = []
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
print((" PASS " if cond else " FAIL ") + msg)
|
||||
if not cond:
|
||||
FAILS.append(msg)
|
||||
|
||||
|
||||
class _Quiet(server.CRMHandler):
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
def _req(port, method, path, token=None, body=None):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
|
||||
headers = {}
|
||||
if token:
|
||||
headers["Authorization"] = "Bearer " + token
|
||||
payload = None
|
||||
if body is not None:
|
||||
payload = json.dumps(body)
|
||||
headers["Content-Type"] = "application/json"
|
||||
conn.request(method, path, body=payload, headers=headers)
|
||||
resp = conn.getresponse()
|
||||
raw = resp.read().decode("utf-8", "replace")
|
||||
conn.close()
|
||||
data = None
|
||||
if raw:
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except ValueError:
|
||||
pass
|
||||
return resp.status, data
|
||||
|
||||
|
||||
# One fund column so a non-zero cell rolls up into total_invested (the "existing LP" signal).
|
||||
COLUMNS = [{"id": "fund1", "label": "Fund III", "isFund": True}]
|
||||
ROW_ACME = {"id": "rowAcme", "investor_name": "Acme Capital", "priority": True, "fund1": 250000,
|
||||
"contacts": [{"name": "Jane Doe", "email": "jane@acme.com", "title": "GP"}]}
|
||||
ROW_BETA = {"id": "rowBeta", "investor_name": "Beta Capital", "fund1": 0,
|
||||
"contacts": [{"name": "Pat Roe", "email": "pat@beta.com", "title": ""}]}
|
||||
|
||||
|
||||
def _db():
|
||||
return sqlite3.connect(os.environ["CRM_DB_PATH"])
|
||||
|
||||
|
||||
def seed():
|
||||
c = _db()
|
||||
c.execute("INSERT INTO users (id,username,email,password_hash,full_name,role,is_active) "
|
||||
"VALUES ('u1','grant','grant@ten31.example','x','Grant','admin',1)")
|
||||
# A pure classic contact with NO fundraising-grid link (not an investor).
|
||||
c.execute("INSERT INTO contacts (id,first_name,last_name,email,contact_type,status) "
|
||||
"VALUES ('cLegacy','Vendor','Vince','vince@vendor.com','other','active')")
|
||||
c.commit()
|
||||
c.close()
|
||||
|
||||
|
||||
def _by_email(contacts, email):
|
||||
return next((c for c in contacts if (c.get("email") or "").lower() == email), None)
|
||||
|
||||
|
||||
def main():
|
||||
server.init_db()
|
||||
seed()
|
||||
token = server.create_token("u1", "grant", "admin")
|
||||
|
||||
httpd = ThreadingHTTPServer(("127.0.0.1", 0), _Quiet)
|
||||
port = httpd.server_address[1]
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
try:
|
||||
st, _ = _req(port, "PUT", "/api/fundraising/state", token,
|
||||
{"grid": {"columns": COLUMNS, "rows": [ROW_ACME, ROW_BETA]}, "views": []})
|
||||
check(st == 200, f"seed grid via PUT /state (got {st})")
|
||||
|
||||
# Put Acme into the pipeline at 'engaged' so its contact's card shows a stage pill.
|
||||
st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token,
|
||||
{"source_row_id": "rowAcme", "stage": "engaged"})
|
||||
check(st in (200, 201), f"link Acme to pipeline @engaged (got {st}, {d})")
|
||||
|
||||
st, d = _req(port, "GET", "/api/contacts?limit=500", token)
|
||||
contacts = (d or {}).get("data") or []
|
||||
check(st == 200 and contacts, f"GET /api/contacts (got {st}, {len(contacts)} contacts)")
|
||||
|
||||
jane = _by_email(contacts, "jane@acme.com")
|
||||
pat = _by_email(contacts, "pat@beta.com")
|
||||
vince = _by_email(contacts, "vince@vendor.com")
|
||||
check(jane is not None, "Acme's synced contact Jane Doe is in the directory")
|
||||
check(pat is not None, "Beta's synced contact Pat Roe is in the directory")
|
||||
check(vince is not None, "the pure classic contact Vince is in the directory")
|
||||
|
||||
# ── existing-LP ring signal: committed reflects the linked investor's rollup ──
|
||||
print("\n[committed: existing-LP ring driven by the linked investor's total_invested]")
|
||||
check((jane or {}).get("committed") == 250000,
|
||||
f"Jane.committed == 250000 (existing LP) (got {(jane or {}).get('committed')})")
|
||||
check((pat or {}).get("committed") == 0,
|
||||
f"Pat.committed == 0 (zero-commit prospect, no ring) (got {(pat or {}).get('committed')})")
|
||||
check((vince or {}).get("committed") == 0,
|
||||
f"Vince.committed == 0 (no grid link) (got {(vince or {}).get('committed')})")
|
||||
|
||||
# ── stage-pill signal: pipeline_stage is the investor's live derived stage ──
|
||||
print("\n[pipeline_stage: stage pill driven by the investor's live opp stage]")
|
||||
check((jane or {}).get("pipeline_stage") == "engaged",
|
||||
f"Jane.pipeline_stage == 'engaged' (got {(jane or {}).get('pipeline_stage')!r})")
|
||||
check((pat or {}).get("pipeline_stage") is None,
|
||||
f"Pat.pipeline_stage is None (not in pipeline) (got {(pat or {}).get('pipeline_stage')!r})")
|
||||
check((vince or {}).get("pipeline_stage") is None,
|
||||
f"Vince.pipeline_stage is None (no grid link) (got {(vince or {}).get('pipeline_stage')!r})")
|
||||
|
||||
# ── the get-by-id endpoint carries the same signals (mobile detail sheet, 8b) ──
|
||||
print("\n[get-by-id: /api/contacts/{id} also injects committed + pipeline_stage]")
|
||||
st, d = _req(port, "GET", f"/api/contacts/{jane['id']}", token)
|
||||
detail = (d or {}).get("data") or {}
|
||||
check(st == 200 and detail.get("committed") == 250000 and detail.get("pipeline_stage") == "engaged",
|
||||
f"detail carries committed/pipeline_stage (got committed={detail.get('committed')}, stage={detail.get('pipeline_stage')!r})")
|
||||
st, d = _req(port, "GET", f"/api/contacts/{vince['id']}", token)
|
||||
vdetail = (d or {}).get("data") or {}
|
||||
check(st == 200 and vdetail.get("committed") == 0 and vdetail.get("pipeline_stage") is None,
|
||||
f"unlinked contact detail has committed 0 / stage None (got {vdetail.get('committed')}, {vdetail.get('pipeline_stage')!r})")
|
||||
|
||||
# ── stage tracks the board: advancing the opp re-derives the contact's stage ──
|
||||
print("\n[derived-live: advancing the board stage re-derives the contact's pill]")
|
||||
opp_id = None
|
||||
st, d = _req(port, "GET", "/api/fundraising/state", token)
|
||||
for r in (d or {}).get("data", {}).get("grid", {}).get("rows", []):
|
||||
if r.get("id") == "rowAcme":
|
||||
opp_id = r.get("opportunity_id")
|
||||
st, _ = _req(port, "PATCH", f"/api/opportunities/{opp_id}/stage", token, {"stage": "diligence"})
|
||||
check(st == 200, f"advance Acme's opp -> diligence (got {st})")
|
||||
st, d = _req(port, "GET", "/api/contacts?limit=500", token)
|
||||
jane2 = _by_email((d or {}).get("data") or [], "jane@acme.com")
|
||||
check((jane2 or {}).get("pipeline_stage") == "diligence",
|
||||
f"Jane.pipeline_stage re-derives to 'diligence' (got {(jane2 or {}).get('pipeline_stage')!r})")
|
||||
|
||||
# ── dedup: a contact linked to two investors exposes the highest-committed one ──
|
||||
print("\n[dedup: highest-committed linked investor wins for a multi-linked contact]")
|
||||
c = _db()
|
||||
# Link Jane's classic contact to a SECOND, richer investor (direct rows — the grid sync
|
||||
# makes one link per pill; this exercises the multi-link branch in contact_grid_signals).
|
||||
c.execute("INSERT INTO fundraising_investors (id, investor_name, source_row_id, total_invested) "
|
||||
"VALUES ('inv2','Mega Fund LP','rowMega',500000)")
|
||||
c.execute("INSERT INTO fundraising_contacts (id, investor_id, full_name, contact_id) "
|
||||
"VALUES ('fc2','inv2','Jane Doe',?)", (jane['id'],))
|
||||
c.commit()
|
||||
c.close()
|
||||
st, d = _req(port, "GET", f"/api/contacts/{jane['id']}", token)
|
||||
jd = (d or {}).get("data") or {}
|
||||
check(jd.get("committed") == 500000,
|
||||
f"multi-linked contact exposes the higher committed (500000 > 250000) (got {jd.get('committed')})")
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
|
||||
print()
|
||||
if FAILS:
|
||||
print(f"FAILED ({len(FAILS)}):")
|
||||
for m in FAILS:
|
||||
print(" - " + m)
|
||||
sys.exit(1)
|
||||
print("All contacts-grid-signals tests passed.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user