diff --git a/backend/server.py b/backend/server.py
index b199a0f..c05d3d2 100644
--- a/backend/server.py
+++ b/backend/server.py
@@ -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 (v78→v104).
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] = {
diff --git a/backend/test_dashboard_report.py b/backend/test_dashboard_report.py
index fdf5859..d5e7ade 100644
--- a/backend/test_dashboard_report.py
+++ b/backend/test_dashboard_report.py
@@ -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)
diff --git a/frontend/index.html b/frontend/index.html
index 38979f2..2b38738 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4373,6 +4373,22 @@
);
};
+ // Derived contact status from the live grid signals the API injects (committed / pipeline_stage) —
+ // replaces the retired contact_type. An existing LP (committed > 0) shows an LP badge; an
+ // in-pipeline contact shows its stage chip (both can show at once — a committed LP still in an
+ // active deal); a contact with neither is a Prospect. Used by the desktop Contacts list + detail.
+ const renderContactStatus = (c) => {
+ const existing = Number((c && c.committed) || 0) > 0;
+ const stage = c && c.pipeline_stage;
+ if (!existing && !stage) return Prospect;
+ return (
+
+ {existing && LP}
+ {stage &&