Mobile P3b: investor name + contact-pill editing (update-row)

Adds the editable half of BRIEF §3a's mobile grid set: rename an investor
and add/edit/remove its contact pills from the mobile detail sheet.

New POST /api/fundraising/update-row is the one-row read-fresh-modify-write
twin of log-communication: it mutates only the target row's name/contacts in
the canonical grid blob server-side, then bumps the version + re-syncs the
relational tables. It never accepts a whole-grid payload, so a stale mobile
client can't clobber concurrent edits to other rows (the reason mobile avoids
the whole-grid PUT). _sanitize_fundraising_contacts whitelists the known pill
fields as the trust boundary; removing a pill is soft on the classic contacts
directory (only the grid pill + fundraising_contacts row drop).

Frontend: MobileFundraisingGrid gains an Edit bottom-sheet (name input + pill
editor with client-side dedup); money stays desktop-only. New CSS is
theme-var-only so it flips in light mode.

Verified: test_fundraising_update_row.py (24 assertions, real HTTP), full
suite 37/37, render-smoke + a 375px jsdom interaction harness green.
This commit is contained in:
Keysat
2026-06-19 17:07:29 -05:00
parent 099d87dad2
commit 3f93daf28e
4 changed files with 436 additions and 27 deletions
+118
View File
@@ -1621,6 +1621,32 @@ def sanitize_fundraising_grid(grid):
return {"columns": clean_columns, "rows": clean_rows}
# The full set of fields a fundraising contact "pill" carries (what the desktop contacts-cell
# editor produces). The mobile pill editor edits name/email/title and carries the rest through
# untouched; this whitelist sanitizes an inbound contacts array on the single-row update path.
_FUNDRAISING_CONTACT_FIELDS = ('name', 'email', 'title', 'source', 'city', 'state',
'country', 'location_query', 'linkedin_url')
def _sanitize_fundraising_contacts(raw):
"""Coerce a client-supplied contacts array to clean pill dicts: keep only the known pill
fields, trim strings, and drop entries with neither a name nor an email (the same emptiness
rule sync_fundraising_relational uses, so a sanitized list round-trips through sync without
surprise drops). Dedup is the client's job (BRIEF §3a); this only guards blanks + stray keys."""
out = []
if not isinstance(raw, list):
return out
for c in raw:
if not isinstance(c, dict):
continue
pill = {}
for k in _FUNDRAISING_CONTACT_FIELDS:
if k in c and c.get(k) is not None:
pill[k] = str(c.get(k)).strip()
if not pill.get('name') and not pill.get('email'):
continue
out.append(pill)
return out
# ─── Grid ↔ Pipeline link (Adopt the Pipeline) ────────────────────────────────
# The fundraising grid is canonical; the Pipeline board is a view of the deals it
# drives. opportunities.fundraising_investor_id is the durable join. Two ownership
@@ -2355,6 +2381,8 @@ class CRMHandler(BaseHTTPRequestHandler):
return self.handle_create_feature_request(user, body)
if path == '/api/fundraising/log-communication':
return self.handle_log_fundraising_communication(user, body)
if path == '/api/fundraising/update-row':
return self.handle_update_fundraising_row(user, body)
if path == '/api/fundraising/pipeline/link':
return self.handle_pipeline_link(user, body)
if path == '/api/fundraising/pipeline/unlink':
@@ -3381,6 +3409,96 @@ class CRMHandler(BaseHTTPRequestHandler):
conn.close()
return self.send_json({"data": {"communication": comm, "row": target_row, "version": next_version}}, 201)
def handle_update_fundraising_row(self, user, body):
"""Version-safe single-row edit of an investor's NAME and/or contact pills (P3b).
The one-row twin of handle_log_fundraising_communication: the canonical grid blob is read
FRESH server-side, ONLY the target row's investor_name / contacts are mutated, then the
blob is written back with a version bump + relational re-sync. It never accepts a
whole-grid payload, so a stale mobile client can't clobber concurrent edits to OTHER rows
the way the grid PUT (handle_update_fundraising_state) would (BRIEF §3a). Same-row
name/contacts is last-write-wins, exactly like the notes append in log-communication.
Body: { row_id (required), investor_name?, contacts? } at least one of investor_name /
contacts must be present. `contacts` REPLACES the row's full pill list (the client owns
add/edit/remove + dedup). Removing a pill drops it from the row + its fundraising_contacts
row on re-sync, but never hard-deletes the classic contacts directory entry
(soft-delete-only convention _upsert_contact_from_fundraising only ever upserts)."""
row_id = str(body.get('row_id') or '').strip()
if not row_id:
return self.send_error_json("row_id is required")
has_name = 'investor_name' in body
has_contacts = 'contacts' in body
if not has_name and not has_contacts:
return self.send_error_json("investor_name or contacts is required")
new_name = None
if has_name:
new_name = str(body.get('investor_name') or '').strip()
if not new_name:
return self.send_error_json("investor_name cannot be blank")
new_contacts = None
if has_contacts:
if not isinstance(body.get('contacts'), list):
return self.send_error_json("contacts must be an array")
new_contacts = _sanitize_fundraising_contacts(body.get('contacts'))
conn = get_db()
ensure_fundraising_state_row(conn)
state = conn.execute("SELECT * FROM fundraising_state WHERE id = 'main'").fetchone()
if not state:
conn.close()
return self.send_error_json("Fundraising state not found", 404)
try:
grid = json.loads(state['grid_json']) if state['grid_json'] else {}
except Exception:
grid = {}
grid = sanitize_fundraising_grid(grid)
rows = grid.get('rows', [])
if not isinstance(rows, list):
rows = []
target_index = -1
target_row = None
for idx, row in enumerate(rows):
if isinstance(row, dict) and str(row.get('id') or '').strip() == row_id:
target_index = idx
target_row = row
break
if target_row is None:
conn.close()
return self.send_error_json("Fundraising row not found", 404)
if new_name is not None:
target_row['investor_name'] = new_name
if new_contacts is not None:
target_row['contacts'] = new_contacts
rows[target_index] = target_row
try:
views = json.loads(state['views_json']) if state['views_json'] else []
except Exception:
views = []
views = sanitize_grid_views(views)
next_version = int(state['version'] or 1) + 1
conn.execute("""
UPDATE fundraising_state
SET grid_json = ?, views_json = ?, version = ?, updated_by = ?, updated_at = ?
WHERE id = 'main'
""", (json.dumps(grid), json.dumps(views), next_version, user['user_id'], now()))
sync_fundraising_relational(conn, grid, views, actor_user_id=user['user_id'])
log_audit(conn, user['user_id'], 'fundraising_state', 'main', 'update_row',
{"version": next_version, "row_id": row_id})
conn.commit()
conn.close()
# The returned row lacks the read-only computed columns (pipeline/existing_investor/…),
# which are injected only on the GET path — the mobile client reloads after a save.
return self.send_json({"data": {"row": target_row, "version": next_version}}, 200)
def _fetch_opportunity_row(self, conn, opp_id):
return row_to_dict(conn.execute("""
SELECT op.*, c.first_name, c.last_name, c.email as contact_email,
+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""Integration test for the version-safe single-row update endpoint (P3b).
POST /api/fundraising/update-row edits ONE investor row's name and/or contact pills by
reading the canonical grid blob fresh server-side and mutating only the target row — never
accepting a whole-grid payload (BRIEF §3a), so it can't clobber concurrent edits to other
rows. This boots the REAL server against a throwaway DB, seeds a two-row grid, then drives
the live HTTP endpoint to assert:
* rename + pill add/edit persist into the blob and bump the version;
* removing a pill drops it from the row + fundraising_contacts, but the classic contacts
directory entry is NOT hard-deleted (soft-delete-only convention);
* preserved pill fields (title/city/linkedin) survive an edit that only touches the name;
* the OTHER grid row is untouched (no whole-grid clobber);
* guards: missing row_id -> 400, unknown row_id -> 404, blank name -> 400,
no-op body (neither field) -> 400.
Synthetic data only (guardrail #9).
Run: cd backend && python3 test_fundraising_update_row.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 _post(port, path, token, payload):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
conn.request("POST", path, body=json.dumps(payload),
headers={"Authorization": "Bearer " + token, "Content-Type": "application/json"})
resp = conn.getresponse()
raw = resp.read().decode("utf-8", "replace")
conn.close()
data = json.loads(raw) if raw else None
return resp.status, data
GRID = {
"columns": [
{"id": "investor_name", "label": "Investor Name", "type": "text"},
{"id": "contacts", "label": "Contacts", "type": "contacts"},
{"id": "notes", "label": "Notes", "type": "text"},
],
"rows": [
{"id": "row-1", "investor_name": "Acme Capital", "notes": "",
"contacts": [
{"name": "Jane Doe", "email": "jane@acme.com", "title": "Partner",
"city": "Austin", "state": "TX", "country": "USA", "location_query": "Austin",
"linkedin_url": "https://linkedin.com/in/janedoe"},
{"name": "Bob Roe", "email": "bob@acme.com", "title": ""},
]},
{"id": "row-2", "investor_name": "Beacon Fund", "notes": "untouched",
"contacts": [{"name": "Carl Vane", "email": "carl@beacon.com"}]},
],
}
def seed():
c = sqlite3.connect(os.environ["CRM_DB_PATH"])
c.row_factory = sqlite3.Row
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)")
c.execute("INSERT INTO fundraising_state (id, grid_json, views_json, version, updated_by) "
"VALUES ('main', ?, '[]', 1, 'u1') "
"ON CONFLICT(id) DO UPDATE SET grid_json=excluded.grid_json, views_json='[]', version=1",
(json.dumps(GRID),))
server.sync_fundraising_relational(c, server.sanitize_fundraising_grid(GRID), [], actor_user_id="u1")
c.commit()
c.close()
def blob_rows():
c = sqlite3.connect(os.environ["CRM_DB_PATH"])
c.row_factory = sqlite3.Row
row = c.execute("SELECT grid_json, version FROM fundraising_state WHERE id='main'").fetchone()
c.close()
grid = json.loads(row["grid_json"])
by_id = {r["id"]: r for r in grid["rows"]}
return by_id, int(row["version"])
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:
# ── rename + add a third contact, edit Bob's title, keep Jane unchanged ──
print("\n[rename + pill add/edit]")
new_contacts = [
# Jane: only her name is "re-sent"; the client preserves her other fields by spread.
{"name": "Jane Doe", "email": "jane@acme.com", "title": "Partner",
"city": "Austin", "state": "TX", "country": "USA", "location_query": "Austin",
"linkedin_url": "https://linkedin.com/in/janedoe"},
{"name": "Bob Roe", "email": "bob@acme.com", "title": "Principal"}, # edited title
{"name": "Dana Fox", "email": "dana@acme.com", "title": "Analyst"}, # added
]
st, data = _post(port, "/api/fundraising/update-row", token,
{"row_id": "row-1", "investor_name": "Acme Capital LLC", "contacts": new_contacts})
check(st == 200, f"update-row -> 200 (got {st})")
by_id, version = blob_rows()
check(version == 2, f"version bumped 1 -> 2 (got {version})")
r1 = by_id.get("row-1", {})
check(r1.get("investor_name") == "Acme Capital LLC", f"name renamed in blob (got {r1.get('investor_name')!r})")
names = [c.get("name") for c in r1.get("contacts", [])]
check(names == ["Jane Doe", "Bob Roe", "Dana Fox"], f"three pills in order (got {names})")
bob = next((c for c in r1["contacts"] if c["name"] == "Bob Roe"), {})
check(bob.get("title") == "Principal", f"Bob's title edited (got {bob.get('title')!r})")
jane = next((c for c in r1["contacts"] if c["name"] == "Jane Doe"), {})
check(jane.get("linkedin_url") == "https://linkedin.com/in/janedoe" and jane.get("city") == "Austin",
f"Jane's preserved fields survived (got {jane})")
# ── the OTHER row is byte-for-byte untouched (no whole-grid clobber) ──
print("\n[other row untouched]")
r2 = by_id.get("row-2", {})
check(r2.get("investor_name") == "Beacon Fund" and r2.get("notes") == "untouched"
and [c.get("name") for c in r2.get("contacts", [])] == ["Carl Vane"],
f"row-2 unchanged (got {r2})")
# ── relational sync: classic contacts directory now has Dana; Dana also in fundraising_contacts ──
print("\n[relational + classic-contacts sync]")
c = sqlite3.connect(os.environ["CRM_DB_PATH"])
c.row_factory = sqlite3.Row
dana = c.execute("SELECT id, deleted_at FROM contacts WHERE lower(email)='dana@acme.com'").fetchone()
check(bool(dana), "added contact Dana propagated to classic contacts directory")
inv = c.execute("SELECT id FROM fundraising_investors WHERE source_row_id='row-1'").fetchone()
fc_names = {r["full_name"] for r in c.execute(
"SELECT full_name FROM fundraising_contacts WHERE investor_id=?", (inv["id"],)).fetchall()}
check(fc_names == {"Jane Doe", "Bob Roe", "Dana Fox"}, f"fundraising_contacts mirrors the three pills (got {fc_names})")
c.close()
# ── remove Bob: pill drops + fundraising_contacts drops, but classic contact NOT hard-deleted ──
print("\n[remove pill is soft on the classic directory]")
st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1", "contacts": [
{"name": "Jane Doe", "email": "jane@acme.com", "title": "Partner"},
{"name": "Dana Fox", "email": "dana@acme.com", "title": "Analyst"},
]})
check(st == 200, f"remove-pill update -> 200 (got {st})")
by_id, version = blob_rows()
names = [c.get("name") for c in by_id["row-1"].get("contacts", [])]
check(names == ["Jane Doe", "Dana Fox"], f"Bob removed from the row (got {names})")
check(version == 3, f"version bumped 2 -> 3 (got {version})")
c = sqlite3.connect(os.environ["CRM_DB_PATH"])
c.row_factory = sqlite3.Row
inv = c.execute("SELECT id FROM fundraising_investors WHERE source_row_id='row-1'").fetchone()
fc_names = {r["full_name"] for r in c.execute(
"SELECT full_name FROM fundraising_contacts WHERE investor_id=?", (inv["id"],)).fetchall()}
check("Bob Roe" not in fc_names, f"Bob dropped from fundraising_contacts (got {fc_names})")
bob_classic = c.execute("SELECT id FROM contacts WHERE lower(email)='bob@acme.com'").fetchone()
check(bool(bob_classic), "removing a pill does NOT hard-delete the classic contacts row (soft-delete only)")
c.close()
# ── name-only update (no contacts key) leaves the pill list intact ──
print("\n[name-only update preserves contacts]")
st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1", "investor_name": "Acme Capital"})
check(st == 200, f"name-only update -> 200 (got {st})")
by_id, _ = blob_rows()
names = [c.get("name") for c in by_id["row-1"].get("contacts", [])]
check(by_id["row-1"].get("investor_name") == "Acme Capital", "name updated again")
check(names == ["Jane Doe", "Dana Fox"], f"contacts untouched by a name-only edit (got {names})")
# ── a name-only pill (no email) is KEPT; a fully-blank pill is dropped ──
# (locks _sanitize_fundraising_contacts's emptiness rule = name OR email, not AND.)
print("\n[name-only pill kept, blank pill dropped]")
st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1", "contacts": [
{"name": "Jane Doe", "email": "jane@acme.com"},
{"name": "Erin Pope", "email": ""}, # name only -> kept
{"name": "", "email": ""}, # fully blank -> dropped
{"name": " ", "title": "Ghost"}, # whitespace-only name, no email -> dropped
]})
check(st == 200, f"name-only-pill update -> 200 (got {st})")
by_id, _ = blob_rows()
names = [c.get("name") for c in by_id["row-1"].get("contacts", [])]
check(names == ["Jane Doe", "Erin Pope"],
f"name-only pill kept, blank/whitespace pills dropped (got {names})")
# ── guards ──
print("\n[validation guards]")
st, _ = _post(port, "/api/fundraising/update-row", token, {"investor_name": "No Id"})
check(st == 400, f"missing row_id -> 400 (got {st})")
st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-nope", "investor_name": "Ghost"})
check(st == 404, f"unknown row_id -> 404 (got {st})")
st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1", "investor_name": " "})
check(st == 400, f"blank name -> 400 (got {st})")
st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1"})
check(st == 400, f"no-op body (neither name nor contacts) -> 400 (got {st})")
st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1", "contacts": "nope"})
check(st == 400, f"contacts wrong type -> 400 (got {st})")
finally:
httpd.shutdown()
print()
if FAILS:
print(f"FAILED ({len(FAILS)}):")
for f in FAILS:
print(f" - {f}")
sys.exit(1)
print("ALL PASS (fundraising update-row: version-safe single-row name/pill edit)")
if __name__ == "__main__":
main()