Retire lp_profiles + LP Tracker; repoint Dashboard committed to the grid (v0.1.0:78)
The fundraising grid + email capture is the canonical system of record. lp_profiles was a superseded single-fund model with no reachable create/edit path, and the LP Tracker page was already orphaned (no nav entry + a redirect bouncing it to the grid). - Remove /api/lp-profiles* endpoints + handlers, the unused lp-breakdown report, the contact-dossier LP section, the demo-seed LP block, and (frontend) the LPTrackerPage component + its lp-tracker->fundraising-grid redirect. - Dashboard "Total Committed" now sums fundraising_investors.total_invested (graveyarded investors excluded) instead of the orphaned lp_profiles table, which read ~$0. "Total Funded" dropped: the grid tracks commitments, not a funded amount, and the frontend never rendered it. - Leave the empty lp_profiles table/index, the contact-delete soft-delete cascade, and the --reset-all-data clear in place (never-hard-delete). - Tests: add test_dashboard_report.py; update test_soft_delete_reads.py. 21/21 green.
This commit is contained in:
+7
-186
@@ -1781,20 +1781,11 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
if path == '/api/communications':
|
||||
return self.handle_list_communications(user, params)
|
||||
|
||||
# LP Profiles
|
||||
if path == '/api/lp-profiles':
|
||||
return self.handle_list_lp_profiles(user, params)
|
||||
if re.match(r'^/api/lp-profiles/[^/]+$', path):
|
||||
lp_id = path.split('/')[-1]
|
||||
return self.handle_get_lp_profile(user, lp_id)
|
||||
|
||||
# Reports
|
||||
if path == '/api/reports/dashboard':
|
||||
return self.handle_dashboard_report(user)
|
||||
if path == '/api/reports/pipeline':
|
||||
return self.handle_pipeline_report(user)
|
||||
if path == '/api/reports/lp-breakdown':
|
||||
return self.handle_lp_breakdown_report(user)
|
||||
if path == '/api/reports/activity':
|
||||
return self.handle_activity_report(user, params)
|
||||
|
||||
@@ -1907,8 +1898,6 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
return self.handle_create_opportunity(user, body)
|
||||
if path == '/api/communications':
|
||||
return self.handle_create_communication(user, body)
|
||||
if path == '/api/lp-profiles':
|
||||
return self.handle_create_lp_profile(user, body)
|
||||
if path == '/api/import/csv':
|
||||
return self.handle_import_csv(user, body)
|
||||
if path == '/api/feature-requests':
|
||||
@@ -1992,8 +1981,6 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
return self.handle_update_opportunity(user, path.split('/')[-1], body)
|
||||
if re.match(r'^/api/communications/[^/]+$', path):
|
||||
return self.handle_update_communication(user, path.split('/')[-1], body)
|
||||
if re.match(r'^/api/lp-profiles/[^/]+$', path):
|
||||
return self.handle_update_lp_profile(user, path.split('/')[-1], body)
|
||||
if path == '/api/fundraising/state':
|
||||
return self.handle_update_fundraising_state(user, body)
|
||||
if re.match(r'^/api/thesis/nodes/[^/]+$', path):
|
||||
@@ -2224,9 +2211,6 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
(contact_id,)
|
||||
).fetchall())
|
||||
|
||||
lp = conn.execute("SELECT * FROM lp_profiles WHERE contact_id = ? AND deleted_at IS NULL", (contact_id,)).fetchone()
|
||||
result['lp_profile'] = row_to_dict(lp) if lp else None
|
||||
|
||||
conn.close()
|
||||
return self.send_json({"data": result})
|
||||
|
||||
@@ -2958,115 +2942,6 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
conn.close()
|
||||
return self.send_json({"message": "Communication deleted"})
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# LP PROFILE HANDLERS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def handle_list_lp_profiles(self, user, params):
|
||||
conn = get_db()
|
||||
query = """
|
||||
SELECT lp.*, c.first_name, c.last_name, c.email, o.name as organization_name
|
||||
FROM lp_profiles lp
|
||||
LEFT JOIN contacts c ON lp.contact_id = c.id
|
||||
LEFT JOIN organizations o ON c.organization_id = o.id
|
||||
WHERE 1=1
|
||||
"""
|
||||
args = []
|
||||
if params.get('fund_name'):
|
||||
query += " AND lp.fund_name = ?"
|
||||
args.append(params['fund_name'])
|
||||
if params.get('search'):
|
||||
search = f"%{params['search']}%"
|
||||
query += " AND (c.first_name LIKE ? OR c.last_name LIKE ? OR o.name LIKE ?)"
|
||||
args.extend([search, search, search])
|
||||
|
||||
query += " ORDER BY lp.commitment_amount DESC"
|
||||
profiles = rows_to_list(conn.execute(query, args).fetchall())
|
||||
conn.close()
|
||||
return self.send_json({"data": profiles, "total": len(profiles)})
|
||||
|
||||
def handle_get_lp_profile(self, user, lp_id):
|
||||
conn = get_db()
|
||||
lp = conn.execute("""
|
||||
SELECT lp.*, c.first_name, c.last_name, c.email, c.phone, o.name as organization_name
|
||||
FROM lp_profiles lp
|
||||
LEFT JOIN contacts c ON lp.contact_id = c.id
|
||||
LEFT JOIN organizations o ON c.organization_id = o.id
|
||||
WHERE lp.id = ? AND lp.deleted_at IS NULL
|
||||
""", (lp_id,)).fetchone()
|
||||
if not lp:
|
||||
conn.close()
|
||||
return self.send_error_json("LP profile not found", 404)
|
||||
conn.close()
|
||||
return self.send_json({"data": row_to_dict(lp)})
|
||||
|
||||
def handle_create_lp_profile(self, user, body):
|
||||
if not body.get('contact_id'):
|
||||
return self.send_error_json("contact_id is required")
|
||||
|
||||
conn = get_db()
|
||||
existing = conn.execute("SELECT id FROM lp_profiles WHERE contact_id = ?",
|
||||
(body['contact_id'],)).fetchone()
|
||||
if existing:
|
||||
conn.close()
|
||||
return self.send_error_json("LP profile already exists for this contact")
|
||||
|
||||
lp_id = generate_id()
|
||||
conn.execute("""
|
||||
INSERT INTO lp_profiles (id, contact_id, commitment_amount, funded_amount,
|
||||
commitment_date, fund_name, investor_type, accredited, legal_docs_signed,
|
||||
signed_date, wire_received, wire_date, k1_sent, preferred_communication, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
lp_id, body['contact_id'], body.get('commitment_amount', 0),
|
||||
body.get('funded_amount', 0), body.get('commitment_date'),
|
||||
body.get('fund_name'), body.get('investor_type'),
|
||||
body.get('accredited', 0), body.get('legal_docs_signed', 0),
|
||||
body.get('signed_date'), body.get('wire_received', 0),
|
||||
body.get('wire_date'), body.get('k1_sent', 0),
|
||||
body.get('preferred_communication', 'email'), body.get('notes')
|
||||
))
|
||||
|
||||
# Update contact type to investor
|
||||
conn.execute("UPDATE contacts SET contact_type = 'investor', updated_at = ? WHERE id = ?",
|
||||
(now(), body['contact_id']))
|
||||
|
||||
log_audit(conn, user['user_id'], 'lp_profile', lp_id, 'create')
|
||||
conn.commit()
|
||||
|
||||
lp = row_to_dict(conn.execute("SELECT * FROM lp_profiles WHERE id = ?", (lp_id,)).fetchone())
|
||||
conn.close()
|
||||
return self.send_json({"data": lp}, 201)
|
||||
|
||||
def handle_update_lp_profile(self, user, lp_id, body):
|
||||
conn = get_db()
|
||||
existing = conn.execute("SELECT id FROM lp_profiles WHERE id = ?", (lp_id,)).fetchone()
|
||||
if not existing:
|
||||
conn.close()
|
||||
return self.send_error_json("LP profile not found", 404)
|
||||
|
||||
updatable = ['commitment_amount', 'funded_amount', 'commitment_date', 'fund_name',
|
||||
'investor_type', 'accredited', 'legal_docs_signed', 'signed_date',
|
||||
'wire_received', 'wire_date', 'k1_sent', 'preferred_communication', 'notes']
|
||||
sets = []
|
||||
args = []
|
||||
for field in updatable:
|
||||
if field in body:
|
||||
sets.append(f"{field} = ?")
|
||||
args.append(body[field])
|
||||
|
||||
if sets:
|
||||
sets.append("updated_at = ?")
|
||||
args.append(now())
|
||||
args.append(lp_id)
|
||||
conn.execute(f"UPDATE lp_profiles SET {', '.join(sets)} WHERE id = ?", args)
|
||||
log_audit(conn, user['user_id'], 'lp_profile', lp_id, 'update', body)
|
||||
conn.commit()
|
||||
|
||||
lp = row_to_dict(conn.execute("SELECT * FROM lp_profiles WHERE id = ?", (lp_id,)).fetchone())
|
||||
conn.close()
|
||||
return self.send_json({"data": lp})
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# REPORT HANDLERS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -3079,11 +2954,14 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
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']
|
||||
|
||||
# Committed capital comes from the canonical fundraising grid (per-investor
|
||||
# rollup of per-fund commitments). Graveyarded (written-off) investors are
|
||||
# excluded so the headline reflects live committed capital — a deliberate
|
||||
# divergence from /api/fundraising/relational-summary, which sums all rows.
|
||||
# The legacy lp_profiles table is retired; the grid tracks commitments, not a
|
||||
# separate "funded" amount, so total_funded is no longer reported.
|
||||
total_committed = conn.execute(
|
||||
"SELECT COALESCE(SUM(commitment_amount), 0) as total FROM lp_profiles"
|
||||
).fetchone()['total']
|
||||
total_funded = conn.execute(
|
||||
"SELECT COALESCE(SUM(funded_amount), 0) as total FROM lp_profiles"
|
||||
"SELECT COALESCE(SUM(total_invested), 0) as total FROM fundraising_investors WHERE graveyard = 0"
|
||||
).fetchone()['total']
|
||||
|
||||
pipeline_value = conn.execute(
|
||||
@@ -3156,7 +3034,6 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
"total_prospects": total_prospects,
|
||||
"total_contacts": total_contacts,
|
||||
"total_committed": total_committed,
|
||||
"total_funded": total_funded,
|
||||
"pipeline_value": pipeline_value,
|
||||
"active_opportunities": active_opportunities,
|
||||
"comms_this_month": comms_this_month,
|
||||
@@ -3210,46 +3087,6 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
}
|
||||
})
|
||||
|
||||
def handle_lp_breakdown_report(self, user):
|
||||
conn = get_db()
|
||||
lps = rows_to_list(conn.execute("""
|
||||
SELECT lp.*, c.first_name, c.last_name, c.email, o.name as organization_name,
|
||||
(SELECT MAX(communication_date) FROM communications WHERE contact_id = c.id) as last_contact_date,
|
||||
(SELECT COUNT(*) FROM communications WHERE contact_id = c.id) as total_communications
|
||||
FROM lp_profiles lp
|
||||
LEFT JOIN contacts c ON lp.contact_id = c.id
|
||||
LEFT JOIN organizations o ON c.organization_id = o.id
|
||||
ORDER BY lp.commitment_amount DESC
|
||||
""").fetchall())
|
||||
|
||||
summary = conn.execute("""
|
||||
SELECT COUNT(*) as total_lps,
|
||||
COALESCE(SUM(commitment_amount), 0) as total_committed,
|
||||
COALESCE(SUM(funded_amount), 0) as total_funded,
|
||||
COALESCE(AVG(commitment_amount), 0) as avg_commitment,
|
||||
MAX(commitment_amount) as largest_commitment,
|
||||
MIN(CASE WHEN commitment_amount > 0 THEN commitment_amount END) as smallest_commitment
|
||||
FROM lp_profiles
|
||||
""").fetchone()
|
||||
|
||||
by_type = rows_to_list(conn.execute("""
|
||||
SELECT COALESCE(investor_type, 'Unknown') as investor_type,
|
||||
COUNT(*) as count,
|
||||
COALESCE(SUM(commitment_amount), 0) as total_committed
|
||||
FROM lp_profiles
|
||||
GROUP BY investor_type
|
||||
ORDER BY total_committed DESC
|
||||
""").fetchall())
|
||||
|
||||
conn.close()
|
||||
return self.send_json({
|
||||
"data": {
|
||||
"lps": lps,
|
||||
"summary": row_to_dict(summary),
|
||||
"by_type": by_type
|
||||
}
|
||||
})
|
||||
|
||||
def handle_activity_report(self, user, params):
|
||||
conn = get_db()
|
||||
days = int(params.get('days', 30))
|
||||
@@ -5203,22 +5040,6 @@ def seed_demo_data():
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (*c, admin_id))
|
||||
|
||||
# Create LP profiles for investors
|
||||
lp_data = [
|
||||
(contacts[0][0], 25000000, 25000000, "2024-03-15", "Fund I", "institutional"),
|
||||
(contacts[1][0], 15000000, 15000000, "2024-04-01", "Fund I", "family_office"),
|
||||
(contacts[2][0], 20000000, 20000000, "2024-05-15", "Fund I", "pension"),
|
||||
(contacts[3][0], 10000000, 10000000, "2024-06-01", "Fund I", "endowment"),
|
||||
(contacts[4][0], 5000000, 5000000, "2024-07-15", "Fund I", "family_office"),
|
||||
(contacts[5][0], 8000000, 8000000, "2024-08-01", "Fund I", "institutional"),
|
||||
]
|
||||
for lp in lp_data:
|
||||
conn.execute("""
|
||||
INSERT INTO lp_profiles (id, contact_id, commitment_amount, funded_amount,
|
||||
commitment_date, fund_name, investor_type, accredited, legal_docs_signed, wire_received)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, 1)
|
||||
""", (generate_id(), *lp))
|
||||
|
||||
# Create opportunities
|
||||
opp_data = [
|
||||
(contacts[6][0], orgs[6][0], "Cascade Wealth - Fund II", "meeting", 10000000, 10000000, 40, user2_id),
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression test for the dashboard KPI repoint + lp_profiles retirement (2026-06-16).
|
||||
|
||||
"Total Committed" used to SUM lp_profiles.commitment_amount — an orphaned table with no
|
||||
reachable input path, so the dashboard read ~$0 while the real commitments lived in the
|
||||
fundraising grid. It now sums fundraising_investors.total_invested (the canonical grid
|
||||
rollup) with graveyarded (written-off) investors excluded, "Total Funded" is dropped
|
||||
(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.
|
||||
|
||||
Run: cd backend && python3 test_dashboard_report.py
|
||||
"""
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from http.server import ThreadingHTTPServer
|
||||
|
||||
_DATA = tempfile.mkdtemp()
|
||||
os.environ["CRM_DATA_DIR"] = _DATA
|
||||
os.environ["CRM_DB_PATH"] = os.path.join(_DATA, "crm.db")
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import server # noqa: E402
|
||||
|
||||
FAILS = []
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
print((" PASS " if cond else " FAIL ") + msg)
|
||||
if not cond:
|
||||
FAILS.append(msg)
|
||||
|
||||
|
||||
class _Quiet(server.CRMHandler):
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
def _get(port, path, token):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
|
||||
conn.request("GET", path, headers={"Authorization": "Bearer " + token})
|
||||
resp = conn.getresponse()
|
||||
body = resp.read().decode("utf-8", "replace")
|
||||
conn.close()
|
||||
data = None
|
||||
if body:
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except ValueError:
|
||||
pass
|
||||
return resp.status, data
|
||||
|
||||
|
||||
def seed():
|
||||
c = sqlite3.connect(os.environ["CRM_DB_PATH"])
|
||||
c.execute("INSERT INTO users (id,username,email,password_hash,full_name,role,is_active) "
|
||||
"VALUES ('u1','grant','grant@ten31.example','x','Grant','admin',1)")
|
||||
# live investor committed 3,000,000; graveyarded investor committed 500,000 (must be excluded)
|
||||
c.execute("INSERT INTO fundraising_investors (id,investor_name,source_row_id,total_invested,graveyard) "
|
||||
"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)")
|
||||
c.commit()
|
||||
c.close()
|
||||
|
||||
|
||||
def main():
|
||||
server.init_db()
|
||||
seed()
|
||||
token = server.create_token("u1", "grant", "admin")
|
||||
|
||||
httpd = ThreadingHTTPServer(("127.0.0.1", 0), _Quiet)
|
||||
port = httpd.server_address[1]
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
try:
|
||||
print("\n[dashboard total_committed comes from the grid, graveyard excluded]")
|
||||
st, dash = _get(port, "/api/reports/dashboard", token)
|
||||
check(st == 200, f"GET dashboard -> 200 (got {st})")
|
||||
metrics = (dash or {}).get("data", {}).get("metrics", {})
|
||||
check(metrics.get("total_committed") == 3000000,
|
||||
f"total_committed = live grid rollup only (3,000,000; got {metrics.get('total_committed')})")
|
||||
check("total_funded" not in metrics,
|
||||
f"total_funded key dropped from metrics (got keys {sorted(metrics)})")
|
||||
|
||||
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)
|
||||
check(st == 404, f"GET {path} -> 404 (got {st})")
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
|
||||
print()
|
||||
if FAILS:
|
||||
print(f"FAILED ({len(FAILS)}):")
|
||||
for f in FAILS:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("ALL PASS (dashboard KPI repoint + lp_profiles retirement)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -11,7 +11,7 @@ payload. The fix added `deleted_at IS NULL` to every get-by-id + nested sub-sele
|
||||
This boots the REAL server, hand-builds active + soft-deleted rows across the five
|
||||
soft-deletable tables, and drives the live HTTP read paths with a real token. It
|
||||
asserts: get-by-id 404s a soft-deleted contact/org, and nested sub-selects
|
||||
(org->contacts/opportunities, contact->communications/opportunities/lp_profile)
|
||||
(org->contacts/opportunities, contact->communications/opportunities)
|
||||
omit soft-deleted children while keeping the live ones. Synthetic only (guardrail #9).
|
||||
|
||||
Run: cd backend && python3 test_soft_delete_reads.py
|
||||
@@ -70,7 +70,7 @@ def seed():
|
||||
# organizations: one live, one soft-deleted
|
||||
c.execute("INSERT INTO organizations (id,name) VALUES ('orgA','Harbor & Vine')")
|
||||
c.execute("INSERT INTO organizations (id,name,deleted_at) VALUES ('orgX','Deleted Org',?)", (DEL,))
|
||||
# contacts under orgA: one live (with children), one soft-deleted, one live w/ deleted lp
|
||||
# contacts under orgA: one live (with children), one soft-deleted, one extra live (for org aggregates)
|
||||
c.execute("INSERT INTO contacts (id,first_name,last_name,organization_id) VALUES ('cLive','Ada','Live','orgA')")
|
||||
c.execute("INSERT INTO contacts (id,first_name,last_name,organization_id,deleted_at) VALUES ('cDead','Boris','Gone','orgA',?)", (DEL,))
|
||||
c.execute("INSERT INTO contacts (id,first_name,last_name,organization_id) VALUES ('cLp','Cora','Lp','orgA')")
|
||||
@@ -83,9 +83,6 @@ def seed():
|
||||
# communications on cLive
|
||||
c.execute("INSERT INTO communications (id,contact_id,communication_date,created_by,subject) VALUES ('cmLive','cLive','2026-05-01','u1','Live note')")
|
||||
c.execute("INSERT INTO communications (id,contact_id,communication_date,created_by,subject,deleted_at) VALUES ('cmDead','cLive','2026-05-02','u1','Dead note',?)", (DEL,))
|
||||
# lp_profiles: live one on cLive, soft-deleted one on cLp
|
||||
c.execute("INSERT INTO lp_profiles (id,contact_id,fund_name) VALUES ('lpLive','cLive','Fund III')")
|
||||
c.execute("INSERT INTO lp_profiles (id,contact_id,fund_name,deleted_at) VALUES ('lpDead','cLp','Fund III',?)", (DEL,))
|
||||
c.commit()
|
||||
c.close()
|
||||
|
||||
@@ -115,11 +112,6 @@ def main():
|
||||
opp_ids = {x["id"] for x in d.get("opportunities", [])}
|
||||
check("cmLive" in comm_ids and "cmDead" not in comm_ids, f"communications: live only (got {comm_ids})")
|
||||
check("opLive" in opp_ids and "opDead" not in opp_ids, f"opportunities: live only (got {opp_ids})")
|
||||
check(bool(d.get("lp_profile")) and d["lp_profile"].get("id") == "lpLive", "live lp_profile present on contact")
|
||||
|
||||
# soft-deleted lp_profile must read back as None (nested single-row sub-select)
|
||||
_, lpc = _get(port, "/api/contacts/cLp", token)
|
||||
check((lpc or {}).get("data", {}).get("lp_profile") is None, "soft-deleted lp_profile reads back as None")
|
||||
|
||||
# ── organization detail nested sub-selects exclude soft-deleted children ──
|
||||
print("\n[organization detail nested sub-selects]")
|
||||
|
||||
Reference in New Issue
Block a user