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,