diff --git a/AGENTS.md b/AGENTS.md index 96df096..f3f8d33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,5 +115,5 @@ _**Box live at v0.1.0:104 (deployed + verified 2026-06-20)** — clean StartOS m - **New (v0.1.0:104):** admin-only **Purge Deleted Data** (Settings → Admin) — guarded, type-to-confirm hard-delete of soft-deleted rows; see the soft-delete convention + `test_purge_soft_deleted.py`. - **Verification:** **45/45** backend, render-smoke green, reviewer-agent APPROVE after fixing **1 blocker** (contact purge left a dangling `reminders.contact_id` — now NULLed + test-guarded). New UI behavior is **live-smoke / on-device only** (jsdom can't drive touch). - **Bug A — Grant is handling:** `odell/marty/finance/ten31@` can't enroll for email capture ("could not resolve user_id") because the enroll flow requires a CRM `users` row; Grant is creating user accounts for those mailboxes. -- **Next:** (A) confirm the two stuck mailboxes pulled current + Grant's 4 new mailbox users enroll; (B) **retire `contact_type`** — replace the Contacts Investors/Prospects tabs + TYPE badge with grid-derived `existing_investor`/`pipeline_stage`, then drop the column (see ROADMAP); (C) **contacts ↔ `fundraising_contacts` consolidation** — census-first (count A/linked, B/contacts-only, C/pill-only on the box; see ROADMAP); (D) carried: bell approve-on-phone → Matrix-thread-clears round-trip spot-check. +- **Next:** (A) confirm the two stuck mailboxes pulled current + Grant's 4 new mailbox users enroll; (B) **retire `contact_type`** — replace the Contacts Investors/Prospects tabs + TYPE badge with grid-derived `existing_investor`/`pipeline_stage`, then drop the column (see ROADMAP); (C) **contacts ↔ `fundraising_contacts` consolidation** — census-first (count A/linked, B/contacts-only, C/pill-only on the box; see ROADMAP). **A TEMPORARY admin census ships in v0.1.0:105 to read A/B/C off the box: `GET /api/admin/contacts-census` + a Settings → Admin "Run census" button (mirrors `backend/scripts/contacts_census.sql`). DELETE the endpoint + handler + route + button once the numbers are captured** (all tagged `TEMPORARY` in code). (D) carried: bell approve-on-phone → Matrix-thread-clears round-trip spot-check. - **Open / risks:** the Contacts pagination, the purge, and the email-sync auto-recovery are **live-smoke / not yet device-confirmed**. Carried: **Claude/Architect path unverified live on the box**; vision OCR small-in-frame misread (`mara.com→marac.com`); doc drift — `crm-overview.md` narrative + `EVALUATION.md` still describe `lp_profiles` (the active API/schema claims were fixed; the deeper Phase-0 narrative is deferred to a doc pass). diff --git a/ROADMAP.md b/ROADMAP.md index 49b0daf..8a96e9a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -86,7 +86,7 @@ - **Retire `contacts.contact_type`** (the Contacts Investors/Prospects tabs + TYPE badge). It's a legacy binary that's set mechanically — `'investor'` just means "exists in the grid" (stamped unconditionally by `_upsert_contact_from_fundraising`), `'prospect'` means "imported/added, not in the grid" — and is superseded by the grid-derived signals `contact_grid_signals()` already injects (`existing_investor`/`committed`, `pipeline_stage`). Plan: replace the tabs + TYPE badge with those signals, repoint the dashboard `total_lps`/`total_prospects` counts, then drop the column. Live UI change → its own small design pass. (Grant: "I want to delete it, next session.") -- **Consolidate `contacts` ↔ `fundraising_contacts` into one linked model.** Goal (Grant): everyone in `contacts` maps to a `fundraising_investors` row (an individual maps to their own row). Today `contacts` is the canonical person directory (FK target for `communications`/`opportunities`); `fundraising_contacts.contact_id` (migration `0004`) points INTO it; the mobile Contacts page reads `contacts`. Three populations: **A** linked (grid pill ↔ contact), **B** `contacts`-only (imported prospects / manual adds — need a grid row), **C** pill-only (`fundraising_contacts.contact_id IS NULL` — need a contact row). **Census-first:** before designing any migration, count A/B/C on the box — Grant runs the SQL himself (he is **not** providing a DB copy), so hand him a counts-only script. The census decides whether this is a ~20-row cleanup or a ~300-row structural migration with `communications`/`opportunities` repointing. Then Grant reconciles B (add grid rows/pills) and C (add contact rows) and ensures all are linked. +- **Consolidate `contacts` ↔ `fundraising_contacts` into one linked model.** Goal (Grant): everyone in `contacts` maps to a `fundraising_investors` row (an individual maps to their own row). Today `contacts` is the canonical person directory (FK target for `communications`/`opportunities`); `fundraising_contacts.contact_id` (migration `0004`) points INTO it; the mobile Contacts page reads `contacts`. Three populations: **A** linked (grid pill ↔ contact), **B** `contacts`-only (imported prospects / manual adds — need a grid row), **C** pill-only (`fundraising_contacts.contact_id IS NULL` — need a contact row). **Census-first:** before designing any migration, count A/B/C on the box — Grant runs the SQL himself (he is **not** providing a DB copy), so hand him a counts-only script. The census decides whether this is a ~20-row cleanup or a ~300-row structural migration with `communications`/`opportunities` repointing. Then Grant reconciles B (add grid rows/pills) and C (add contact rows) and ensures all are linked. **(v0.1.0:105) A TEMPORARY admin census ships to read A/B/C off the box without shell access: `GET /api/admin/contacts-census` (`handle_contacts_census`) + a Settings → Admin "Run census" button, mirroring `backend/scripts/contacts_census.sql` (counts only). DELETE the endpoint + route + button after the numbers are captured — all tagged `TEMPORARY` in code.** ### Follow-ups/reminders + NL search + bot grid-mutations (agreed plan, 2026-06-18) *Agreed with Grant 2026-06-18. Three workstreams, sequenced **W1 → W2 → W3**. **Overarching constraint (Grant):** the dominant risk is **leaking LP data (names, $, notes, contacts) to third-party LLMs — NOT write-safety.** A wrong number is recoverable; investor substance reaching Claude is not. Consequences: W2 keeps LP rows off Claude (only the question text + schema vocabulary leave the box; entity names resolved locally); W3 keeps bot mutation-parsing on local Qwen. Because this DB *logs* commitments/pipeline but doesn't move money, a bot mutation is low-stakes → **any team member may approve one in Matrix**; the guardrail is "the bot can't silently mass-change numbers," enforced by the per-mutation human approval gate, not a tight money gate.* diff --git a/backend/server.py b/backend/server.py index b80e120..b199a0f 100644 --- a/backend/server.py +++ b/backend/server.py @@ -2359,6 +2359,8 @@ class CRMHandler(BaseHTTPRequestHandler): return self.handle_get_digest_policy(user) if path == '/api/admin/soft-deleted': return self.handle_list_soft_deleted(user) + if path == '/api/admin/contacts-census': # TEMPORARY — remove after the consolidation census + return self.handle_contacts_census(user) if path == '/api/fundraising/relational-summary': return self.handle_get_fundraising_relational_summary(user) if path == '/api/fundraising/automations': @@ -4868,6 +4870,39 @@ class CRMHandler(BaseHTTPRequestHandler): finally: conn.close() + # ─── TEMPORARY (v0.1.0:105) — DELETE AFTER the contacts<->fundraising_contacts census ── + # A throwaway admin diagnostic for the deferred consolidation (ROADMAP backlog): mirrors + # backend/scripts/contacts_census.sql so the A/B/C populations can be read from the box + # without shell access. Counts only — no names/PII. Remove this handler + its route + the + # Settings "Contacts census" button once the numbers are captured. + def handle_contacts_census(self, user): + if not require_admin(user): + return self.send_error_json("Admin required", 403) + conn = get_db() + try: + def n(sql): + return conn.execute(sql).fetchone()[0] + no_pill = ("NOT EXISTS (SELECT 1 FROM fundraising_contacts fc WHERE fc.contact_id = c.id)") + return self.send_json({"data": { + "total_live_contacts": n("SELECT COUNT(*) FROM contacts WHERE deleted_at IS NULL"), + "A_linked": n("SELECT COUNT(*) FROM contacts c WHERE c.deleted_at IS NULL " + "AND EXISTS (SELECT 1 FROM fundraising_contacts fc WHERE fc.contact_id = c.id)"), + "B_contacts_only": n(f"SELECT COUNT(*) FROM contacts c WHERE c.deleted_at IS NULL AND {no_pill}"), + "B_investor": n(f"SELECT COUNT(*) FROM contacts c WHERE c.deleted_at IS NULL AND c.contact_type='investor' AND {no_pill}"), + "B_prospect": n(f"SELECT COUNT(*) FROM contacts c WHERE c.deleted_at IS NULL AND c.contact_type='prospect' AND {no_pill}"), + "B_with_live_communication": n(f"SELECT COUNT(*) FROM contacts c WHERE c.deleted_at IS NULL AND {no_pill} " + "AND EXISTS (SELECT 1 FROM communications cm WHERE cm.contact_id=c.id AND cm.deleted_at IS NULL)"), + "B_with_live_opportunity": n(f"SELECT COUNT(*) FROM contacts c WHERE c.deleted_at IS NULL AND {no_pill} " + "AND EXISTS (SELECT 1 FROM opportunities o WHERE o.contact_id=c.id AND o.deleted_at IS NULL)"), + "C_pill_only": n("SELECT COUNT(*) FROM fundraising_contacts WHERE contact_id IS NULL"), + "dangling_pills": n("SELECT COUNT(*) FROM fundraising_contacts fc WHERE fc.contact_id IS NOT NULL " + "AND NOT EXISTS (SELECT 1 FROM contacts c WHERE c.id=fc.contact_id AND c.deleted_at IS NULL)"), + "total_grid_pills": n("SELECT COUNT(*) FROM fundraising_contacts"), + "total_grid_rows": n("SELECT COUNT(*) FROM fundraising_investors"), + }}) + finally: + conn.close() + def handle_decide_activity_proposal(self, user, proposal_id, decision, body): if not require_admin(user): return self.send_error_json("Admin required", 403) diff --git a/frontend/index.html b/frontend/index.html index a63094a..38979f2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -10699,6 +10699,31 @@ ); }; + // TEMPORARY (v0.1.0:105) — DELETE AFTER the contacts<->fundraising_contacts census. + // Admin button that runs GET /api/admin/contacts-census and shows the counts (no PII), so the + // A/B/C populations can be read off the box without shell access. Remove with the endpoint. + const ContactsCensus = ({ token }) => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const run = async () => { + setLoading(true); + try { const r = await api('/api/admin/contacts-census', {}, token); setData(r.data || {}); } + catch (err) { setData({ error: getErrorMessage(err, 'failed') }); } + finally { setLoading(false); } + }; + return ( +
{JSON.stringify(data, null, 2)}
+ )}
+