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] = {
+28 -3
View File
@@ -8,9 +8,15 @@ rollup) with graveyarded (written-off) investors excluded, "Total Funded" is dro
(the grid has no funded-vs-committed concept), and the /api/lp-profiles* + lp-breakdown
endpoints are gone.
This boots the REAL server against a temp DB, seeds two grid investors (one live, one
graveyarded), and asserts: total_committed reflects the live grid rollup only, the
metrics no longer carry a total_funded key, and the retired routes 404. Synthetic only.
v0.1.0:106 repointed "Total LPs" / "Prospects" off the retired contacts.contact_type onto
the canonical grid (investor entities): an LP = a grid investor with total_invested > 0
(graveyard excluded); a prospect = a live grid row with $0 committed (graveyard + the
'Untitled Investor' blank-row placeholder excluded).
This boots the REAL server against a temp DB, seeds grid investors (live LP, graveyarded,
live prospect, blank placeholder), and asserts: total_committed reflects the live grid
rollup only, total_lps / total_prospects use the grid-entity definitions, the metrics no
longer carry a total_funded key, and the retired routes 404. Synthetic only.
Run: cd backend && python3 test_dashboard_report.py
"""
@@ -68,6 +74,17 @@ def seed():
"VALUES ('fiLive','Harbor LP','rowLive',3000000,0)")
c.execute("INSERT INTO fundraising_investors (id,investor_name,source_row_id,total_invested,graveyard) "
"VALUES ('fiDead','Passed LP','rowDead',500000,1)")
# a live prospect (in the grid, $0 committed) and a blank placeholder row — the prospect
# count includes the former and excludes the latter ('Untitled Investor' = a blank grid row)
c.execute("INSERT INTO fundraising_investors (id,investor_name,source_row_id,total_invested,graveyard) "
"VALUES ('fiProspect','Prospect Co','rowProspect',0,0)")
c.execute("INSERT INTO fundraising_investors (id,investor_name,source_row_id,total_invested,graveyard) "
"VALUES ('fiBlank','Untitled Investor','rowBlank',0,0)")
# one live + one soft-deleted contact: total_contacts must count only the live one
# (guards the deleted_at filter added alongside the contact_type repoint)
c.execute("INSERT INTO contacts (id,first_name,last_name) VALUES ('ctLive','Ann','Live')")
c.execute("INSERT INTO contacts (id,first_name,last_name,deleted_at) "
"VALUES ('ctGone','Bob','Gone','2026-06-01T00:00:00Z')")
c.commit()
c.close()
@@ -90,6 +107,14 @@ def main():
check("total_funded" not in metrics,
f"total_funded key dropped from metrics (got keys {sorted(metrics)})")
print("\n[Total LPs / Prospects derived from the grid, not the retired contacts.contact_type]")
check(metrics.get("total_lps") == 1,
f"total_lps = grid investors committed>0, graveyard excluded (1; got {metrics.get('total_lps')})")
check(metrics.get("total_prospects") == 1,
f"total_prospects = grid rows with $0 committed; graveyard + 'Untitled Investor' excluded (1; got {metrics.get('total_prospects')})")
check(metrics.get("total_contacts") == 1,
f"total_contacts excludes soft-deleted contacts (1; got {metrics.get('total_contacts')})")
print("\n[retired lp_profiles endpoints 404]")
for path in ("/api/lp-profiles", "/api/lp-profiles/anything", "/api/reports/lp-breakdown"):
st, _ = _get(port, path, token)