Grid/contacts unification step 1: real contact_id link + grid as front door (v0.1.0:52)
Structural fix for the duplicate-people class of bug: instead of matching a grid contact "pill" to a contacts row heuristically by name/email (which drifted and caused the 1406 double-count), link them by id. Backend: - Migration 0004: fundraising_contacts.contact_id (additive, nullable, logical FK to contacts(id)) + index. Paired down migration. - sync_fundraising_relational now stores the id that _upsert_contact_from_fundraising already returns, so every grid contact carries its contacts-table id. - _backfill_grid_contact_ids: one-time, idempotent backfill on startup (re-runs the grid sync once if any row lacks contact_id), so existing data links immediately. - entity_resolution: grid pass prefers the explicit contact_id link (match_kind 'grid_link') over heuristic email / name+investor, guarded by a PRAGMA check so older DBs without the column still work. Frontend: - Fundraising grid "+ Row" -> "+ Investor" (clear, single investor entry point). - Contacts page: the "+ Add Contact" trigger is replaced by a pointer to the grid; the page is now a read/search/edit view (ContactDetailPanel still edits all fields). New people are added from the grid. No contact data is removed. Tests: backend/ingest/test_entity_resolution.py extended (explicit-link case, 11/11) and a new backend/test_grid_contact_link.py integration test (init_db applies 0004, sync populates contact_id to the right contact, re-sync is idempotent). py_compile + frontend html.parser clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+41
-8
@@ -462,6 +462,13 @@ def init_db():
|
||||
except Exception as _e:
|
||||
print(f"[migrations] core migration warning: {_e}")
|
||||
|
||||
# One-time: populate the new fundraising_contacts.contact_id (migration 0004)
|
||||
# by re-running the grid→relational sync. No-op once every row is linked.
|
||||
try:
|
||||
_backfill_grid_contact_ids(conn)
|
||||
except Exception as _e:
|
||||
print(f"[backfill] grid contact_id backfill warning: {_e}")
|
||||
|
||||
conn.close()
|
||||
print(f"Database initialized at {DB_PATH}")
|
||||
|
||||
@@ -977,6 +984,32 @@ def _sync_contact_to_fundraising_state(conn, contact_row, actor_user_id=None, re
|
||||
""", (json.dumps(grid), json.dumps(next_views), next_version, actor_user_id, now()))
|
||||
sync_fundraising_relational(conn, grid, next_views, actor_user_id=actor_user_id)
|
||||
|
||||
def _backfill_grid_contact_ids(conn):
|
||||
"""One-time backfill for migration 0004: populate fundraising_contacts.contact_id
|
||||
by re-running the grid→relational sync once. Fires only when the column exists
|
||||
AND some row still lacks a contact_id, so it runs once after the migration and is
|
||||
a no-op thereafter. Safe + idempotent: the fundraising_* tables are derived and
|
||||
rebuilt on every sync, and _upsert_contact_from_fundraising matches existing
|
||||
contacts by email/name (never creates a duplicate on re-run)."""
|
||||
try:
|
||||
need = conn.execute("SELECT 1 FROM fundraising_contacts WHERE contact_id IS NULL LIMIT 1").fetchone()
|
||||
except sqlite3.OperationalError:
|
||||
return # contact_id column not present (migration 0004 not applied)
|
||||
if not need:
|
||||
return
|
||||
row = conn.execute("SELECT grid_json, views_json FROM fundraising_state WHERE id = 'main'").fetchone()
|
||||
if not row or not row[0]:
|
||||
return
|
||||
try:
|
||||
grid = json.loads(row[0])
|
||||
views = json.loads(row[1]) if row[1] else []
|
||||
except Exception:
|
||||
return
|
||||
sync_fundraising_relational(conn, sanitize_fundraising_grid(grid), views)
|
||||
conn.commit()
|
||||
print("[backfill] populated fundraising_contacts.contact_id from grid sync")
|
||||
|
||||
|
||||
def sync_fundraising_relational(conn, grid, views, actor_user_id=None):
|
||||
columns = grid.get('columns', []) if isinstance(grid, dict) else []
|
||||
rows = grid.get('rows', []) if isinstance(grid, dict) else []
|
||||
@@ -1084,23 +1117,23 @@ def sync_fundraising_relational(conn, grid, views, actor_user_id=None):
|
||||
contact_payload = dict(c)
|
||||
if lead_source and not str(contact_payload.get('source') or '').strip():
|
||||
contact_payload['source'] = lead_source
|
||||
_upsert_contact_from_fundraising(conn, investor_name, contact_payload, actor_user_id=actor_user_id)
|
||||
linked_contact_id = _upsert_contact_from_fundraising(conn, investor_name, contact_payload, actor_user_id=actor_user_id)
|
||||
conn.execute("""
|
||||
INSERT INTO fundraising_contacts (
|
||||
id, investor_id, full_name, email, title, city, state, country, location_query, sort_order, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
id, investor_id, full_name, email, title, city, state, country, location_query, sort_order, contact_id, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
generate_id(), investor_id, full_name, email, str(c.get('title') or ''),
|
||||
str(c.get('city') or ''), str(c.get('state') or ''), str(c.get('country') or ''),
|
||||
str(c.get('location_query') or ''), i, now()
|
||||
str(c.get('location_query') or ''), i, linked_contact_id, now()
|
||||
))
|
||||
elif isinstance(contacts, str) and contacts.strip():
|
||||
_upsert_contact_from_fundraising(conn, investor_name, {"name": contacts.strip(), "email": "", "title": "", "source": lead_source}, actor_user_id=actor_user_id)
|
||||
linked_contact_id = _upsert_contact_from_fundraising(conn, investor_name, {"name": contacts.strip(), "email": "", "title": "", "source": lead_source}, actor_user_id=actor_user_id)
|
||||
conn.execute("""
|
||||
INSERT INTO fundraising_contacts (
|
||||
id, investor_id, full_name, email, title, city, state, country, location_query, sort_order, updated_at
|
||||
) VALUES (?, ?, ?, '', '', '', '', '', '', 0, ?)
|
||||
""", (generate_id(), investor_id, contacts.strip(), now()))
|
||||
id, investor_id, full_name, email, title, city, state, country, location_query, sort_order, contact_id, updated_at
|
||||
) VALUES (?, ?, ?, '', '', '', '', '', '', 0, ?, ?)
|
||||
""", (generate_id(), investor_id, contacts.strip(), linked_contact_id, now()))
|
||||
|
||||
conn.execute("DELETE FROM fundraising_commitments WHERE investor_id = ?", (investor_id,))
|
||||
for _, col in fund_columns:
|
||||
|
||||
Reference in New Issue
Block a user