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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user