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:
Keysat
2026-06-16 10:48:53 -05:00
parent 5cda84a7c0
commit 108210d8e1
8 changed files with 180 additions and 486 deletions
+7 -186
View File
@@ -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),