Retire contacts.contact_type; derive Contacts status from the grid (v0.1.0:106)

The Investors/Prospects distinction is now derived live from the canonical
grid (contact_grid_signals -> committed/pipeline_stage), not the mechanically
set contact_type column:

- Desktop Contacts: drop the Investors/Prospects tabs + TYPE badge; show a
  derived Status (existing-LP badge + pipeline stage chip).
- Dashboard: repoint Total LPs / Prospects onto fundraising_investors entities
  (committed>0 vs $0, graveyard + blank-row placeholder excluded); fix a
  total_contacts soft-delete leak.
- Stop reading/writing contact_type across the create/update/import/sync paths.
  The column is left inert in place; a physical drop is deferred to a later
  signed-off table-rebuild migration (SQLite no-drop-column; contacts is
  FK-referenced) -- same retire-then-drop path lp_profiles took.
This commit is contained in:
Keysat
2026-06-20 22:09:02 -05:00
parent b23c48bf7a
commit 05f15b9197
6 changed files with 105 additions and 81 deletions
+34 -23
View File
@@ -161,6 +161,11 @@ def init_db():
mobile TEXT,
title TEXT,
organization_id TEXT REFERENCES organizations(id) ON DELETE SET NULL,
-- RETIRED (v0.1.0:106), inert: the Investors/Prospects distinction is now derived live
-- from the grid (contact_grid_signals committed/pipeline_stage), not this column. No
-- code reads or writes it; the DEFAULT keeps NOT NULL satisfied for inserts that omit it.
-- Physical drop deferred to a signed-off table-rebuild migration (SQLite has no DROP COLUMN
-- here; contacts is FK-referenced) same retire-then-drop path lp_profiles took (v78v104).
contact_type TEXT NOT NULL DEFAULT 'prospect',
status TEXT NOT NULL DEFAULT 'active',
source TEXT,
@@ -840,7 +845,7 @@ def _upsert_contact_from_fundraising(conn, investor_name, contact, actor_user_id
conn.execute("""
UPDATE contacts
SET first_name = ?, last_name = ?, email = ?, title = ?,
organization_id = ?, source = ?, contact_type = 'investor', city = ?, state = ?, country = ?, location_query = ?, linkedin_url = ?, phone = ?, mobile = ?, updated_at = ?
organization_id = ?, source = ?, city = ?, state = ?, country = ?, location_query = ?, linkedin_url = ?, phone = ?, mobile = ?, updated_at = ?
WHERE id = ?
""", (next_first, next_last, next_email, next_title, next_org, next_source, next_city, next_state, next_country, next_location_query, next_linkedin, next_phone, next_mobile, now(), existing['id']))
return existing['id']
@@ -848,8 +853,8 @@ def _upsert_contact_from_fundraising(conn, investor_name, contact, actor_user_id
contact_id = generate_id()
conn.execute("""
INSERT INTO contacts (
id, first_name, last_name, email, title, organization_id, source, contact_type, status, city, state, country, location_query, linkedin_url, phone, mobile, created_by, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 'investor', 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?)
id, first_name, last_name, email, title, organization_id, source, status, city, state, country, location_query, linkedin_url, phone, mobile, created_by, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
contact_id,
first_name or 'Unknown',
@@ -2724,9 +2729,6 @@ class CRMHandler(BaseHTTPRequestHandler):
"""
args = []
if params.get('type'):
query += " AND c.contact_type = ?"
args.append(params['type'])
if params.get('status'):
query += " AND c.status = ?"
args.append(params['status'])
@@ -2743,7 +2745,7 @@ class CRMHandler(BaseHTTPRequestHandler):
sort = params.get('sort', 'updated_at')
order = 'DESC' if params.get('order', 'desc').lower() == 'desc' else 'ASC'
allowed_sorts = ['first_name', 'last_name', 'email', 'created_at', 'updated_at', 'contact_type', 'source']
allowed_sorts = ['first_name', 'last_name', 'email', 'created_at', 'updated_at', 'source']
if sort in allowed_sorts:
query += f" ORDER BY c.{sort} {order}"
else:
@@ -2832,14 +2834,14 @@ class CRMHandler(BaseHTTPRequestHandler):
tags = json.dumps(body.get('tags', []))
conn.execute("""
INSERT INTO contacts (id, first_name, last_name, email, phone, mobile, title,
organization_id, contact_type, status, source, tags, notes, linkedin_url,
organization_id, status, source, tags, notes, linkedin_url,
city, state, country, location_query, preferred_contact, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
contact_id, body['first_name'], body['last_name'],
body.get('email'), body.get('phone'), body.get('mobile'),
body.get('title'), organization_id,
body.get('contact_type', 'prospect'), body.get('status', 'active'),
body.get('status', 'active'),
body.get('source'), tags, body.get('notes'),
body.get('linkedin_url'), body.get('city'), body.get('state'),
body.get('country'), body.get('location_query'),
@@ -2871,7 +2873,7 @@ class CRMHandler(BaseHTTPRequestHandler):
previous_contact = row_to_dict(existing)
updatable = ['first_name', 'last_name', 'email', 'phone', 'mobile', 'title',
'organization_id', 'contact_type', 'status', 'source', 'notes',
'organization_id', 'status', 'source', 'notes',
'linkedin_url', 'city', 'state', 'country', 'location_query', 'preferred_contact']
sets = []
args = []
@@ -4196,10 +4198,21 @@ class CRMHandler(BaseHTTPRequestHandler):
def handle_dashboard_report(self, user):
conn = get_db()
# Key metrics
total_lps = conn.execute("SELECT COUNT(*) as c FROM contacts WHERE contact_type = 'investor'").fetchone()['c']
total_prospects = conn.execute("SELECT COUNT(*) as c FROM contacts WHERE contact_type = 'prospect'").fetchone()['c']
total_contacts = conn.execute("SELECT COUNT(*) as c FROM contacts").fetchone()['c']
# Key metrics. "Total LPs" / "Prospects" are derived from the canonical grid
# (investor entities), not the retired contacts.contact_type: an LP is a grid
# investor with committed capital (total_invested > 0); a prospect is a live
# grid row with nothing committed yet. Graveyarded rows are excluded (same basis
# as Total Committed below); blank grid rows (synced as the 'Untitled Investor'
# placeholder, see sync_fundraising_relational) are excluded from the prospect
# count so empties don't inflate it.
total_lps = conn.execute(
"SELECT COUNT(*) as c FROM fundraising_investors WHERE total_invested > 0 AND graveyard = 0"
).fetchone()['c']
total_prospects = conn.execute(
"SELECT COUNT(*) as c FROM fundraising_investors "
"WHERE COALESCE(total_invested, 0) = 0 AND graveyard = 0 AND investor_name != 'Untitled Investor'"
).fetchone()['c']
total_contacts = conn.execute("SELECT COUNT(*) as c FROM contacts WHERE deleted_at IS NULL").fetchone()['c']
# Committed capital comes from the canonical fundraising grid (per-investor
# rollup of per-fund commitments). Graveyarded (written-off) investors are
@@ -4495,7 +4508,6 @@ class CRMHandler(BaseHTTPRequestHandler):
conn.execute("""
UPDATE contacts SET first_name=?, last_name=?, phone=?, title=?,
organization_id=COALESCE(?, organization_id),
contact_type=COALESCE(?, contact_type),
linkedin_url=COALESCE(?, linkedin_url),
city=COALESCE(?, city),
state=COALESCE(?, state),
@@ -4505,7 +4517,6 @@ class CRMHandler(BaseHTTPRequestHandler):
WHERE id=?
""", (first_name, last_name, data.get('phone'),
data.get('title'), org_id,
data.get('contact_type'),
linkedin_url if linkedin_url else None,
city if city else None,
state if state else None,
@@ -4530,12 +4541,12 @@ class CRMHandler(BaseHTTPRequestHandler):
contact_id = generate_id()
conn.execute("""
INSERT INTO contacts (id, first_name, last_name, email, phone,
title, organization_id, contact_type, status, source,
title, organization_id, status, source,
linkedin_url, city, state, country, location_query, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', 'import', ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, 'active', 'import', ?, ?, ?, ?, ?, ?)
""", (contact_id, first_name, last_name, email,
data.get('phone'), data.get('title'), org_id,
data.get('contact_type', 'prospect'), linkedin_url,
linkedin_url,
city, state, country, location_query, user['user_id']))
if email:
batch_email_matches[email_key] = {
@@ -4558,12 +4569,12 @@ class CRMHandler(BaseHTTPRequestHandler):
contact_id = generate_id()
conn.execute("""
INSERT INTO contacts (id, first_name, last_name, email, phone,
title, organization_id, contact_type, status, source,
title, organization_id, status, source,
linkedin_url, city, state, country, location_query, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', 'import', ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, 'active', 'import', ?, ?, ?, ?, ?, ?)
""", (contact_id, first_name, last_name, email,
data.get('phone'), data.get('title'), org_id,
data.get('contact_type', 'prospect'), linkedin_url,
linkedin_url,
city, state, country, location_query, user['user_id']))
if email:
batch_email_matches[email_key] = {