diff --git a/AGENTS.md b/AGENTS.md
index a231769..2493f38 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -69,7 +69,7 @@ Subsystem rules live in `docs/guides/` and lazy-load in Claude Code via `.claude
## Conventions
- **Investor model — the grid is canonical (since v0.1.0:78).** The `fundraising_*` grid is the **system of record**: an investor entity (row) → many contact "pills" → per-fund commitments. The classic `contacts` table is a **read-only per-person directory**, auto-populated from the grid — create/edit people in the grid, not the Contacts page. Email capture rolls multiple people up to one investor. The legacy single-fund `lp_profiles` model is **retired** (empty table kept, per never-hard-delete). Reconciling grid ↔ classic `contacts` to canonical IDs is the core entity-resolution task — see `docs/crm-overview.md`.
-- **Soft-delete only:** `deleted_at` and/or `status='retired'`; never hard-delete. Every READ path must filter `deleted_at IS NULL` — list handlers, get-by-id, nested related-data sub-selects, **and aggregate sub-selects (`COUNT`/`SUM`/`MAX`)**. Audits found leaks in all of these (2026-06-12 detail + nested; 2026-06-13 list-view `contact_count`/`total_funded`/`comm_count`); the **reports** subsystem aggregates still leak (see Current state). Regression-guarded by `backend/test_soft_delete_reads.py`. (Thesis has a subtlety here — see the thesis guide.)
+- **Soft-delete only:** `deleted_at` and/or `status='retired'`; never hard-delete. Every READ path must filter `deleted_at IS NULL` — list handlers, get-by-id, nested related-data sub-selects, **and aggregate sub-selects (`COUNT`/`SUM`/`MAX`)**. Audits found leaks in all of these (2026-06-12 detail + nested; 2026-06-13 list-view `contact_count`/`total_funded`/`comm_count`); the **opportunities/pipeline** aggregates were fixed in v0.1.0:87 (`handle_pipeline_report` + dashboard pipeline metrics now filter `deleted_at`), but the **reports** subsystem's **communications-side** aggregates (dashboard `recent_comms`/`comms_this_month`/`meetings_this_month`, activity report) still leak (see Current state). Regression-guarded by `backend/test_soft_delete_reads.py`. (Thesis has a subtlety here — see the thesis guide.)
- **Env:** secrets in `.env` (gitignored); names in `.env.example`. Verified names: `ANTHROPIC_API_KEY`, `SPARK_CONTROL_URL`, `SPARK_CONTROL_VERIFY_TLS`, `QDRANT_URL`, `X_API_KEY`, `CRM_DB_PATH`, `CRM_DEV_DB_PATH`. Also used: `CRM_SECRET_KEY` (beta/prod), `CRM_HOST`/`CRM_PORT`, `CRM_DATA_DIR`; digest mailer: `CRM_DIGEST_SENDER` (DWD impersonation sender) + `SMTP_HOST`/`SMTP_PORT`/`SMTP_SECURITY`/`SMTP_FROM`/`SMTP_USERNAME`/`SMTP_PASSWORD` (SMTP fallback); daily digest (Phase B): `CRM_DIGEST_ENABLED` + `CRM_DIGEST_SEND_HOUR` **only seed the first-boot default** — the live control is the DB policy (`app_settings.digest_policy`, set in Settings → Admin).
- **Config placement:** operational/feature toggles live in the **admin panel**, DB-backed via `app_settings` (read-merge through a `load_*_policy(conn)` helper shared by the API + any scheduler; precedence DB-row → env-seed → default), so they're discoverable and take effect live. Reserve StartOS actions / env for **secrets and deploy-time config** (SMTP creds, API keys, DWD sender). Precedent: `digest_policy` (`GET/PATCH /api/admin/digest/policy`), `fundraising_backup_policy`.
- **Commit style:** imperative subject, concise body explaining the *why*; put the package version in the subject (`… (v0.1.0:NN)`) for shippable changes. **No AI co-author / attribution trailers** — commits are authored by the user.
@@ -103,7 +103,9 @@ Subsystem rules live in `docs/guides/` and lazy-load in Claude Code via `.claude
## Current state
-_Phase 0 + Phase 1 built; **box and repo at v0.1.0:86** (deployed & verified live 2026-06-17). **The fundraising grid + email capture is the canonical system of record** (2026-06-16) — vestigial classic-CRM surfaces get pruned/repurposed. Deploy/feature history lives in git log + `start9/0.4/startos/versions/`; longer-term backlog + debt in `ROADMAP.md` / `EVALUATION.md`._
+_Phase 0 + Phase 1 built; **box live on v0.1.0:86; repo at v0.1.0:87 (built + locally verified, s9pk build + install pending)**. **The fundraising grid + email capture is the canonical system of record** (2026-06-16) — vestigial classic-CRM surfaces get pruned/repurposed. Deploy/feature history lives in git log + `start9/0.4/startos/versions/`; longer-term backlog + debt in `ROADMAP.md` / `EVALUATION.md`._
+
+- **Adopt the Pipeline — grid drives the deal board — BUILT + locally verified 2026-06-17 (v0.1.0:87); s9pk build + box install pending.** An **"Add to Pipeline"** row action on the fundraising grid opens a seed modal (primary contact / target fund / expected amount / stage / probability) and creates a durably-linked `opportunities` row via the new **`opportunities.fundraising_investor_id`** (migration 0005, additive + reversible). **Grid owns the link + seed; the board owns stage/probability/owner** — a grid save never reseeds a live opp (`POST /api/fundraising/pipeline/link` is idempotent, one live opp/investor). Contact is **reused from the grid's synced `fundraising_contacts.contact_id`** (the `POST /api/contacts` side-door is gone); grid `lead`→owner. Two **read-only** grid columns (Pipeline action + Pipeline Stage) are **injected on read** from the live opp and **stripped on write** (never persisted, never dirty the autosave). **Remove from pipeline** (`POST .../unlink`) **soft-deletes the opp; the grid row stays fully intact**; deleting an investor from the grid archives its orphaned opp (`reconcile_grid_pipeline_links`, after `sync_fundraising_relational`). **Folded in:** the standing P2 soft-delete leak in `handle_pipeline_report` + dashboard pipeline aggregates (archived opps no longer counted). Tests: `backend/test_grid_pipeline_link.py`; 28/28 suite green, render-smoke green; migration verified on a copy of `data/crm.db`. **Next: build the s9pk (v87) and install to the box (needs authorization).** Detail + locked decisions in `ROADMAP.md` "Adopt the Pipeline".
- **Matrix intake bot — DEPLOYED & LIVE (2026-06-17), `backend/matrix_intake/`:** a separate-process bot (its `matrix-nio` dep isolated from the stdlib CRM) turning a typed Matrix-room message into a proposed fundraising-grid add/edit, written only after **in-thread human approval** (`yes`/`edit field=value`/`no`). Parse = local Qwen via Spark Control (no Claude/scrub, like the digest); writes reuse the CRM's own `POST /api/fundraising/log-communication` tagged `source="matrix_intake"`; new-vs-existing via read-only `GET /api/intake/match` (returns the grid row id → no duplicate). **Runs on the Spark as a docker-compose service** (`modelo32`, container `matrix-intake`, `restart: unless-stopped` → survives a reboot; `docker-compose.yml` at the repo root + `backend/matrix_intake/Dockerfile` bundling `backend/matrix_intake` + the stdlib `backend/ingest` Spark client; retired the old nohup launch, 2026-06-17). A spark-control dashboard card is still pending (handoff: `docs/handoffs/add-intake-bot-to-spark-control.md`). **Live-smoked end-to-end** (new-investor create + existing-investor note matched & appended, no dup). Server side shipped to the box as **v0.1.0:84** (`/api/intake/match` + `source` provenance — these were missing on v83, so the bot 404'd until v84); then UX adds: main-timeline nudge pointer, top-level-`yes`→thread redirect, clearer commit wording, note text in the grid line (v85 dropped the `[note]` tag). M3 (business-card photo) deferred (no Spark vision model). Guide: `docs/guides/matrix-intake.md`.
- **Matrix intake — fuzzy-match + conversational-edit pass — DEPLOYED & LIVE 2026-06-17 (box on v0.1.0:86, bot restarted on the Spark; `candidates` endpoint verified live); `revise` leg live-smoked 2026-06-17, fuzzy disambiguation grammar still un-smoked.** Closes the two locked post-deploy enhancements (ROADMAP). **(a) Fuzzy matching (server-side, ships in the s9pk):** `find_intake_candidates` in `server.py` (deterministic — stdlib `difflib` name similarity + token-set Jaccard, legal-suffix-aware via `_strip_legal_suffix`, + email Levenshtein ≤ 2; ranked, ≥0.62, top 5); `GET /api/intake/match` now returns `{match, candidates}`. The bot surfaces a numbered shortlist (`_stage="disambiguate"`) so a near-duplicate ("Charlie"/"Charles", "Acme Capital"/"Acme Capital LLC", a one-char email typo) is **confirmed by a human** instead of silently creating a second investor — never auto-attached. **The optional LLM-judge re-rank was deferred** (deterministic filter already surfaces the cases; LLM is the right shortlist *pruner* if noise proves real). **(b) Conversational edits (bot-side, ships on the Spark):** any in-thread reply that isn't `yes`/`no`/`edit field=value` → `parse.revise` re-runs `{proposal + instruction}` through local Qwen and re-renders the card; **email integrity preserved** (a changed address must literally appear in the instruction; the model's email field is never trusted); no-op revisions re-prompt (`same_fields`). **Deploy is split:** the `candidates` need an **s9pk build+install** (v86); the bot's disambiguation+revise need a **Spark `git pull` + restart** — a bot restart alone won't deliver `candidates` (box returns `[]`, bot safely proposes new). Tests green; the Qwen `revise` leg is now live-smoked (2026-06-17, with the roster fix below); the fuzzy **disambiguation** numbered-pick grammar is the one in-room path still un-smoked. Guide updated.
@@ -111,6 +113,6 @@ _Phase 0 + Phase 1 built; **box and repo at v0.1.0:86** (deployed & verified liv
- **Working (all draft-only):** CRM + ingest (chunk→embed→Qdrant + retrieval) + redaction boundary; Gmail capture (DWD) + email-activity propose→approve; Thesis Workshop + Architect (Claude) with dual-approval gate; Outreach Draft Assistant + follow-up radar + per-user voice + Tier-B in-thread Gmail draft creation.
- **Deploy history (done — git log + `start9/0.4/startos/versions/` + guides):** v74 security/path-traversal hardening; v78 retired `lp_profiles`/LP Tracker (grid is canonical); v80–83 email-activity Communications tab (typed investor facet, date filter, full-body view, semantic content search) + daily-digest windowed preview→send (`docs/guides/email.md`); v82 vendored + SRI-pinned front-end libs + jsdom render-smoke build gate (`docs/guides/packaging.md`).
- **Tests:** **27/27 backend green** (`python3 backend/run_tests.py`), `py_compile` clean; frontend render-smoke gates the default `make` build.
-- **Debt (P2, not deploy-blocking; full list `EVALUATION.md`):** reports-subsystem soft-delete sweep (`handle_pipeline_report` + report aggregates still count tombstoned rows) + report-endpoint tests; `?limit=abc` crashes the request thread; auth regression test for the 3 v79-gated GETs (`/api/users`, `/api/email/status`, `/api/email/accounts`); scrub-gateway TLS verify off; hardcoded Spark/Qdrant IPs + **oversized StartOS package icon** (fix before the next s9pk upload); the 5.4k-line `server.py` monolith.
+- **Debt (P2, not deploy-blocking; full list `EVALUATION.md`):** reports-subsystem soft-delete sweep — **pipeline/opportunities aggregates fixed v87**; remaining: the dashboard **communications** aggregates (`recent_comms`/`comms_this_month`/`meetings_this_month`) + activity report + report-endpoint tests; `?limit=abc` crashes the request thread; auth regression test for the 3 v79-gated GETs (`/api/users`, `/api/email/status`, `/api/email/accounts`); scrub-gateway TLS verify off; hardcoded Spark/Qdrant IPs + **oversized StartOS package icon** (fix before the next s9pk upload); the 5.4k-line `server.py` monolith.
- **Open / risks:** the v2.0 reserve-asset spine is the *working* approved spine but **not a canonical `thesis_version`** (needs Grant + Jonathan dual sign-off; Appendix-A conviction incl. ~40% Strike stays Grant's working read, not fed to the engine); **Claude/Architect path still unverified live on the box**; the intake matcher reads only the grid blob (not classic `contacts`); doc drift — `crm-overview.md` + `EVALUATION.md` still call `lp_profiles` live (doc-auditor pass).
-- **Next:** 1) **Pipeline adoption** — grid flag → auto-sync an `opportunities` row onto the Pipeline board (the agreed next major build; **design the grid↔pipeline link first** — ROADMAP "Adopt the Pipeline"); 2) **spark-control intake dashboard card** (separate session in the spark-control repo — handoff at `docs/handoffs/add-intake-bot-to-spark-control.md`), and longer-term **extract the bot to its own repo** (ROADMAP); 3) in-room smoke of the intake **disambiguation** numbered-pick grammar (the one unexercised path) — and a roster-tuning pass if any teammate name/initial still slips through; 4) **NL→safe-query** (search item 3 — separate, larger build); 5) Grant + Jonathan freeze v2.0 canonical; 6) reply-all for Tier-B drafts; then clear the P2 debt above.
+- **Next:** 1) **Pipeline adoption — BUILT (v0.1.0:87, above); ship it** — build the s9pk + install to the box (needs authorization), then live-smoke the "Add to Pipeline"/board round-trip; 2) **spark-control intake dashboard card** (separate session in the spark-control repo — handoff at `docs/handoffs/add-intake-bot-to-spark-control.md`), and longer-term **extract the bot to its own repo** (ROADMAP); 3) in-room smoke of the intake **disambiguation** numbered-pick grammar (the one unexercised path) — and a roster-tuning pass if any teammate name/initial still slips through; 4) **NL→safe-query** (search item 3 — separate, larger build); 5) Grant + Jonathan freeze v2.0 canonical; 6) reply-all for Tier-B drafts; then clear the P2 debt above.
diff --git a/ROADMAP.md b/ROADMAP.md
index b34c5cd..db99f6e 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -173,10 +173,13 @@ Open design questions (settled at build time): send time = **6 PM box-local** (c
- **Dashboard KPIs repointed:** "Total Committed" now sums `fundraising_investors.total_invested` (the canonical grid rollup), **excluding graveyarded investors** so the headline reflects live committed capital — a deliberate divergence from `/api/fundraising/relational-summary`, which sums all rows. "Total Funded" dropped — the grid has no funded-vs-committed concept and the frontend never rendered it. (If a funded/wired status is wanted later, that's a new grid feature, not a revival of lp_profiles.) Regression-guarded by `test_dashboard_report.py`.
- **Left in place (intentional):** the empty `lp_profiles` table + index (no destructive drop, per never-hard-delete); the contact-delete soft-delete cascade; the `--reset-all-data` clear; and the inert MOCK_MODE `mockDb.lp_profiles` fixtures (dev-only fallback, never hits the backend — its dashboard mock still reads mock lp_profiles, a known dev-only divergence from the real backend). Updated `test_soft_delete_reads.py` to drop the now-removed `lp_profile` assertions (kept its org `total_funded` opportunities-aggregate checks).
-**Adopt the Pipeline — wire it to the grid.** *(Priority: second build, after the Matrix-bridge intake — confirmed 2026-06-16.)*
+**Adopt the Pipeline — wire it to the grid. — BUILT + locally verified 2026-06-17 (v0.1.0:87); s9pk build + box install pending.** *(Was: second build after the Matrix-bridge intake.)*
- Pipeline (`opportunities`) is fully built and functional but unused. Keep it: it's the one classic surface that tracks something the grid doesn't — a forward-looking deal funnel (stage, `expected_amount × probability`, owner, close date) vs. the grid's actual committed dollars + flags.
- New idea (Grant, 2026-06-16): let users **flag an investor in the grid as a pipeline opportunity** (a grid column/control) so it **auto-creates / syncs an `opportunities` row** that loads into the Pipeline board. Design the grid↔pipeline link (which fund seeds it? what sets stage/expected amount? keep them reconciled). Turns Pipeline from a disconnected second data-entry surface into a view driven by the canonical grid.
-- Revisit the stray contact-create side-door (the "Create Opportunity" modal `POST /api/contacts`, `frontend/index.html:6030`) once the grid-driven flow exists.
+- Revisit the stray contact-create side-door (the "Create Opportunity" modal `POST /api/contacts`) once the grid-driven flow exists.
+
+**As built (decisions locked with Grant 2026-06-17):** UX = **row action + seed modal** ("Add to Pipeline" per grid row → captures primary contact / target fund / expected amount / stage / probability). The durable link is `opportunities.fundraising_investor_id` (**migration 0005**, additive + reversible); "is in pipeline?" / "what stage?" are **derived from a live opp join**, never a denormalized flag (no drift). **Ownership split:** the grid owns whether the link exists + the seed; the **board owns stage/probability/owner/close/next-step** — a grid save never reseeds a live opp (`POST /api/fundraising/pipeline/link` is idempotent: one live opp/investor, re-link returns the existing one). Contact is **reused from the grid's synced `fundraising_contacts.contact_id`** — the `POST /api/contacts` side-door is **gone**. Grid `lead` → opp owner (fallback acting user). Two **read-only** grid columns (Pipeline action + Pipeline Stage) injected on read; their row values are stripped on write so they never persist or dirty the autosave. **Remove from pipeline** (`POST .../unlink`) **soft-deletes the opp; the grid row is left fully intact** (Grant's explicit ask). Deleting an investor from the grid archives its orphaned opp (`reconcile_grid_pipeline_links`, called after `sync_fundraising_relational`). **Folded in:** the P2 soft-delete leak in `handle_pipeline_report` + dashboard pipeline aggregates (archived opps no longer counted). Tests: `backend/test_grid_pipeline_link.py` (link/idempotent/round-trip/guards/unlink-intact/re-link/orphan/aggregates), 28/28 suite green, render-smoke green. **Deploy:** server-side → needs an **s9pk build + install** (v87); get authorization first.
+- **Deferred (not built):** no write-back of committed dollars into grid fund cells (grid stays canonical for committed $); a graveyarded investor with a live opp still shows its stage (deliberate — a live deal is a live deal).
**Keep the Contacts table — as the read-only per-person directory it already is.** Confirmed 2026-06-16: the grid models **investor entity → many people** correctly today. The grid "contacts" column is a multi-pill editor; each pill syncs to a `fundraising_contacts` row AND its own classic `contacts` row (5-person family office → 1 investor + 5 contacts, linked via `fundraising_contacts.contact_id`, migration 0004). The Contacts page is **read-only for creation** (header: "added from the Fundraising Grid"; no New-Contact button), edit-only via the detail slide-over — the desired flow already holds. Email capture already rolls **multiple people up to one investor** (matcher indexes each pill's email separately, all → same `fundraising_investor_id`; `email_investor_links` records both investor and specific person). No build here — future email-surfacing UI should present comms grouped by investor across all its people.
diff --git a/backend/migrations/0005_grid_pipeline_link.down.sql b/backend/migrations/0005_grid_pipeline_link.down.sql
new file mode 100644
index 0000000..a4960b6
--- /dev/null
+++ b/backend/migrations/0005_grid_pipeline_link.down.sql
@@ -0,0 +1,7 @@
+-- Reversal of 0005_grid_pipeline_link.sql (manual; .down files are never auto-applied).
+--
+-- SQLite < 3.35 cannot DROP COLUMN. The added column is nullable and ignored by any code
+-- path predating it, so leaving it in place is harmless. The index drops freely. On
+-- SQLite >= 3.35 the column itself may also be dropped.
+DROP INDEX IF EXISTS idx_opportunities_fr_investor;
+-- ALTER TABLE opportunities DROP COLUMN fundraising_investor_id; -- SQLite >= 3.35 only
diff --git a/backend/migrations/0005_grid_pipeline_link.sql b/backend/migrations/0005_grid_pipeline_link.sql
new file mode 100644
index 0000000..5af111f
--- /dev/null
+++ b/backend/migrations/0005_grid_pipeline_link.sql
@@ -0,0 +1,22 @@
+-- Grid → Pipeline adoption — a durable link from a fundraising-grid investor to its
+-- Pipeline opportunity row.
+--
+-- ADDITIVE + REVERSIBLE (CLAUDE.md guardrail #3): adds one nullable column + index.
+-- Until now the grid's "Create Opportunity" button fired a one-shot POST with no
+-- back-reference, so a grid investor could spawn unlimited duplicate opportunities and
+-- an opp never knew which grid row it belonged to. opportunities.fundraising_investor_id
+-- records the link (set by the new POST /api/fundraising/pipeline/link endpoint), making
+-- the relationship dedup-able and reconcilable. "Is this investor in the pipeline?" and
+-- "what stage?" are then DERIVED from a live join on this column — deliberately not a
+-- denormalized mirror flag on fundraising_investors, which would only reintroduce the
+-- two-model drift this CRM exists to fight.
+--
+-- fundraising_investor_id is a LOGICAL foreign key to fundraising_investors(id). It is
+-- intentionally NOT a declared SQLite FOREIGN KEY: opportunities are soft-deleted (never
+-- hard-deleted) and fundraising_investors rows are rebuilt on every grid save, so there
+-- is nothing to cascade; SQLite's ALTER TABLE ADD COLUMN cannot add an enforced FK
+-- cleanly anyway. Nullable so every existing opportunity stays valid — a manually-created,
+-- non-grid opportunity simply has NULL here.
+ALTER TABLE opportunities ADD COLUMN fundraising_investor_id TEXT;
+
+CREATE INDEX IF NOT EXISTS idx_opportunities_fr_investor ON opportunities(fundraising_investor_id);
diff --git a/backend/server.py b/backend/server.py
index 3cbe3fa..acba154 100644
--- a/backend/server.py
+++ b/backend/server.py
@@ -1584,6 +1584,12 @@ def sanitize_fundraising_grid(grid):
if not isinstance(rows, list):
rows = deep_copy_json(DEFAULT_FUNDRAISING_ROWS)
+ # `pipeline` / `pipeline_stage` are read-only columns whose VALUES are derived from the
+ # linked opportunity and injected on read — never persisted as row data (the GET handler
+ # re-injects them after sanitize). The column DEFINITIONS persist like any other column
+ # so their position / width / hidden state is kept.
+ _computed_row_values = ('longshot_followup', 'pipeline', 'pipeline_stage')
+
clean_columns = []
seen = set()
for col in columns:
@@ -1600,11 +1606,83 @@ def sanitize_fundraising_grid(grid):
if not isinstance(row, dict):
continue
next_row = dict(row)
- next_row.pop('longshot_followup', None)
+ for _k in _computed_row_values:
+ next_row.pop(_k, None)
clean_rows.append(next_row)
return {"columns": clean_columns, "rows": clean_rows}
+# ─── Grid ↔ Pipeline link (Adopt the Pipeline) ────────────────────────────────
+# The fundraising grid is canonical; the Pipeline board is a view of the deals it
+# drives. opportunities.fundraising_investor_id is the durable join. Two ownership
+# rules keep them reconciled:
+# * Grid owns: whether the link exists, the investor, the primary contact, the seed.
+# * Pipeline owns: stage / probability / owner / close date / next step.
+# So a grid save NEVER reseeds an existing linked opp (it would clobber funnel state),
+# and "is in pipeline?" / "what stage?" are DERIVED from the live opp join — never a
+# denormalized flag that could drift.
+
+def _resolve_owner_from_lead(conn, lead_value, fallback_user_id):
+ """Map a grid 'lead' cell (a team member's name/initials) to a users.id.
+ opportunities.owner_id is NOT NULL, so a value is always returned — the acting user
+ is the fallback when no confident match exists. Owner is reassignable on the board,
+ so a forgiving prefix match is acceptable here."""
+ lead = str(lead_value or '').strip().lower()
+ if lead:
+ row = conn.execute(
+ "SELECT id FROM users WHERE is_active = 1 AND (lower(full_name) = ? OR lower(username) = ?) LIMIT 1",
+ (lead, lead)
+ ).fetchone()
+ if row:
+ return row['id']
+ row = conn.execute(
+ "SELECT id FROM users WHERE is_active = 1 AND (lower(full_name) LIKE ? OR lower(username) LIKE ?) "
+ "ORDER BY length(full_name) LIMIT 1",
+ (lead + '%', lead + '%')
+ ).fetchone()
+ if row:
+ return row['id']
+ return fallback_user_id
+
+
+def reconcile_grid_pipeline_links(conn):
+ """After a grid save + relational sync, archive (soft-delete) any pipeline
+ opportunity whose linked grid investor row no longer exists — i.e. the investor was
+ deleted from the grid. Creation is NEVER done here: a pipeline opp is only created
+ via the explicit /api/fundraising/pipeline/link endpoint (which carries the seed
+ fields), so this reconciler is a one-way orphan cleanup that can never spawn an
+ empty opp or reseed a live one."""
+ conn.execute(
+ """
+ UPDATE opportunities
+ SET deleted_at = ?, updated_at = ?
+ WHERE fundraising_investor_id IS NOT NULL
+ AND deleted_at IS NULL
+ AND fundraising_investor_id NOT IN (SELECT id FROM fundraising_investors)
+ """,
+ (now(), now())
+ )
+
+
+def pipeline_stage_by_source_row(conn):
+ """Return {grid source_row_id: current pipeline stage} for every investor with a
+ live (non-deleted) linked opportunity. The opportunities table is the single source
+ of truth, so this is always derived fresh and injected as read-only grid columns —
+ never stored in the grid blob, where it could go stale."""
+ out = {}
+ for r in conn.execute(
+ """
+ SELECT fi.source_row_id AS srid, o.stage AS stage
+ FROM opportunities o
+ JOIN fundraising_investors fi ON o.fundraising_investor_id = fi.id
+ WHERE o.deleted_at IS NULL
+ """
+ ).fetchall():
+ srid = str(r['srid'] or '')
+ if srid:
+ out[srid] = r['stage']
+ return out
+
def maybe_run_scheduled_backup():
conn = get_db()
try:
@@ -2066,6 +2144,10 @@ class CRMHandler(BaseHTTPRequestHandler):
return self.handle_create_feature_request(user, body)
if path == '/api/fundraising/log-communication':
return self.handle_log_fundraising_communication(user, body)
+ if path == '/api/fundraising/pipeline/link':
+ return self.handle_pipeline_link(user, body)
+ if path == '/api/fundraising/pipeline/unlink':
+ return self.handle_pipeline_unlink(user, body)
if path == '/api/fundraising/collab/heartbeat':
return self.handle_fundraising_collab_heartbeat(user, body)
if path == '/api/admin/users':
@@ -3066,6 +3148,152 @@ class CRMHandler(BaseHTTPRequestHandler):
conn.close()
return self.send_json({"data": {"communication": comm, "row": target_row, "version": next_version}}, 201)
+ def _fetch_opportunity_row(self, conn, opp_id):
+ return row_to_dict(conn.execute("""
+ SELECT op.*, c.first_name, c.last_name, c.email as contact_email,
+ o.name as organization_name, u.full_name as owner_name
+ FROM opportunities op
+ LEFT JOIN contacts c ON op.contact_id = c.id
+ LEFT JOIN organizations o ON op.organization_id = o.id
+ LEFT JOIN users u ON op.owner_id = u.id
+ WHERE op.id = ?
+ """, (opp_id,)).fetchone())
+
+ def _resolve_grid_primary_contact(self, conn, investor_id, contact_index, actor_user_id):
+ """Resolve the classic contacts.id for a grid investor's chosen contact pill,
+ reusing the link sync already records in fundraising_contacts.contact_id (no
+ bare POST /api/contacts side-door). contact_index matches the pill order
+ (fundraising_contacts.sort_order)."""
+ try:
+ idx = int(contact_index)
+ except (TypeError, ValueError):
+ idx = 0
+ if idx < 0:
+ idx = 0
+ fc = conn.execute(
+ "SELECT contact_id, full_name, email FROM fundraising_contacts "
+ "WHERE investor_id = ? ORDER BY sort_order, rowid LIMIT 1 OFFSET ?",
+ (investor_id, idx)
+ ).fetchone()
+ if not fc:
+ # requested index out of range — fall back to the first pill
+ fc = conn.execute(
+ "SELECT contact_id, full_name, email FROM fundraising_contacts "
+ "WHERE investor_id = ? ORDER BY sort_order, rowid LIMIT 1",
+ (investor_id,)
+ ).fetchone()
+ if not fc:
+ return None
+ if fc['contact_id']:
+ return fc['contact_id']
+ # Rows predating the contact_id backfill: resolve via the same grid→classic
+ # upsert the relational sync uses, not a fresh side-door create.
+ inv = conn.execute("SELECT investor_name FROM fundraising_investors WHERE id = ?", (investor_id,)).fetchone()
+ investor_name = str(inv['investor_name'] if inv else '') or ''
+ return _upsert_contact_from_fundraising(
+ conn, investor_name, {"name": fc['full_name'], "email": fc['email']}, actor_user_id=actor_user_id
+ )
+
+ def handle_pipeline_link(self, user, body):
+ """Create (or return the existing) Pipeline opportunity for a fundraising-grid
+ investor row and link it durably via opportunities.fundraising_investor_id.
+ Idempotent: one live opp per investor — a re-link returns the existing opp
+ without reseeding its Pipeline-owned funnel fields."""
+ source_row_id = str(body.get('source_row_id') or '').strip()
+ if not source_row_id:
+ return self.send_error_json("source_row_id is required")
+
+ conn = get_db()
+ investor = conn.execute(
+ "SELECT id, investor_name, lead FROM fundraising_investors WHERE source_row_id = ?",
+ (source_row_id,)
+ ).fetchone()
+ if not investor:
+ conn.close()
+ return self.send_error_json("Investor not found for that grid row — save the grid first", 404)
+ investor_id = investor['id']
+ investor_name = str(investor['investor_name'] or '').strip() or 'Untitled Investor'
+
+ existing = conn.execute(
+ "SELECT id FROM opportunities WHERE fundraising_investor_id = ? AND deleted_at IS NULL "
+ "ORDER BY created_at LIMIT 1",
+ (investor_id,)
+ ).fetchone()
+ if existing:
+ opp = self._fetch_opportunity_row(conn, existing['id'])
+ conn.close()
+ return self.send_json({"data": opp, "already_linked": True})
+
+ contact_id = self._resolve_grid_primary_contact(
+ conn, investor_id, body.get('contact_index'), actor_user_id=user['user_id']
+ )
+ if not contact_id:
+ conn.close()
+ return self.send_error_json("Add at least one contact to the investor row before adding it to the pipeline")
+
+ contact = conn.execute("SELECT organization_id FROM contacts WHERE id = ?", (contact_id,)).fetchone()
+ org_id = contact['organization_id'] if contact else None
+
+ stage = str(body.get('stage') or 'lead').strip() or 'lead'
+ if stage not in PIPELINE_STAGES:
+ conn.close()
+ return self.send_error_json(f"Invalid stage. Must be one of: {', '.join(PIPELINE_STAGES)}")
+ try:
+ expected_amount = float(body.get('expected_amount') or 0)
+ except (TypeError, ValueError):
+ expected_amount = 0.0
+ try:
+ probability = int(body.get('probability'))
+ except (TypeError, ValueError):
+ probability = 35
+ probability = max(0, min(100, probability))
+ fund_name = str(body.get('fund_name') or '').strip()
+ name = str(body.get('name') or '').strip() or f"{investor_name} — Pipeline"
+ owner_id = _resolve_owner_from_lead(conn, investor['lead'], user['user_id'])
+
+ opp_id = generate_id()
+ conn.execute("""
+ INSERT INTO opportunities (id, name, contact_id, organization_id, stage,
+ commitment_amount, expected_amount, probability, fund_name,
+ owner_id, priority, fundraising_investor_id)
+ VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 'medium', ?)
+ """, (opp_id, name, contact_id, org_id, stage, expected_amount, probability,
+ fund_name or None, owner_id, investor_id))
+ log_audit(conn, user['user_id'], 'opportunity', opp_id, 'create', {"source": "grid_pipeline_link"})
+ conn.commit()
+
+ opp = self._fetch_opportunity_row(conn, opp_id)
+ conn.close()
+ return self.send_json({"data": opp, "already_linked": False}, 201)
+
+ def handle_pipeline_unlink(self, user, body):
+ """Remove a grid investor from the Pipeline: soft-delete its linked opportunity.
+ The fundraising-grid row (investor, contacts, commitments, notes) is left fully
+ intact — only the opportunity is archived (recoverable via the board)."""
+ source_row_id = str(body.get('source_row_id') or '').strip()
+ if not source_row_id:
+ return self.send_error_json("source_row_id is required")
+ conn = get_db()
+ investor = conn.execute(
+ "SELECT id FROM fundraising_investors WHERE source_row_id = ?", (source_row_id,)
+ ).fetchone()
+ if not investor:
+ conn.close()
+ return self.send_error_json("Investor not found for that grid row", 404)
+ opp_rows = conn.execute(
+ "SELECT id FROM opportunities WHERE fundraising_investor_id = ? AND deleted_at IS NULL",
+ (investor['id'],)
+ ).fetchall()
+ archived = 0
+ for opp in opp_rows:
+ conn.execute("UPDATE opportunities SET deleted_at = ?, updated_at = ? WHERE id = ?",
+ (now(), now(), opp['id']))
+ log_audit(conn, user['user_id'], 'opportunity', opp['id'], 'delete', {"source": "grid_pipeline_unlink"})
+ archived += 1
+ conn.commit()
+ conn.close()
+ return self.send_json({"data": {"archived": archived}})
+
def handle_intake_match(self, user, params):
"""Read-only: does an investor matching this intake already exist? Used by the
Matrix intake bot to label its in-thread proposal new-vs-existing. Returns the
@@ -3154,11 +3382,11 @@ class CRMHandler(BaseHTTPRequestHandler):
).fetchone()['total']
pipeline_value = conn.execute(
- "SELECT COALESCE(SUM(expected_amount), 0) as total FROM opportunities WHERE stage NOT IN ('funded', 'lost')"
+ "SELECT COALESCE(SUM(expected_amount), 0) as total FROM opportunities WHERE stage NOT IN ('funded', 'lost') AND deleted_at IS NULL"
).fetchone()['total']
active_opportunities = conn.execute(
- "SELECT COUNT(*) as c FROM opportunities WHERE stage NOT IN ('funded', 'lost')"
+ "SELECT COUNT(*) as c FROM opportunities WHERE stage NOT IN ('funded', 'lost') AND deleted_at IS NULL"
).fetchone()['c']
# Pipeline by stage
@@ -3166,7 +3394,7 @@ class CRMHandler(BaseHTTPRequestHandler):
SELECT stage, COUNT(*) as count, COALESCE(SUM(expected_amount), 0) as total_value,
COALESCE(SUM(commitment_amount), 0) as committed_value
FROM opportunities
- WHERE stage != 'lost'
+ WHERE stage != 'lost' AND deleted_at IS NULL
GROUP BY stage
ORDER BY CASE stage
WHEN 'lead' THEN 1 WHEN 'outreach' THEN 2 WHEN 'meeting' THEN 3
@@ -3243,6 +3471,7 @@ class CRMHandler(BaseHTTPRequestHandler):
COALESCE(SUM(commitment_amount), 0) as total_committed,
COALESCE(AVG(probability), 0) as avg_probability
FROM opportunities
+ WHERE deleted_at IS NULL
GROUP BY stage
ORDER BY CASE stage
WHEN 'lead' THEN 1 WHEN 'outreach' THEN 2 WHEN 'meeting' THEN 3
@@ -3255,6 +3484,7 @@ class CRMHandler(BaseHTTPRequestHandler):
COALESCE(SUM(op.expected_amount), 0) as total_expected
FROM opportunities op
LEFT JOIN users u ON op.owner_id = u.id
+ WHERE op.deleted_at IS NULL
GROUP BY op.owner_id, op.stage
ORDER BY u.full_name, op.stage
""").fetchall())
@@ -3263,7 +3493,7 @@ class CRMHandler(BaseHTTPRequestHandler):
SELECT priority, COUNT(*) as count,
COALESCE(SUM(expected_amount), 0) as total_expected
FROM opportunities
- WHERE stage NOT IN ('funded', 'lost')
+ WHERE stage NOT IN ('funded', 'lost') AND deleted_at IS NULL
GROUP BY priority
""").fetchall())
@@ -4824,6 +5054,7 @@ class CRMHandler(BaseHTTPRequestHandler):
conn = get_db()
self._ensure_fundraising_state_row(conn)
row = conn.execute("SELECT * FROM fundraising_state WHERE id = 'main'").fetchone()
+ stage_by_row = pipeline_stage_by_source_row(conn)
conn.close()
try:
@@ -4842,6 +5073,16 @@ class CRMHandler(BaseHTTPRequestHandler):
columns = grid.get('columns', [])
rows = grid.get('rows', [])
+ # Inject the read-only pipeline columns, derived from the live linked opportunity
+ # (the opportunities table is canonical — never stored in the grid blob, so it
+ # can't go stale). The frontend renders these read-only and strips them on save.
+ for r in rows:
+ if not isinstance(r, dict):
+ continue
+ stage = stage_by_row.get(str(r.get('id') or ''))
+ r['pipeline'] = bool(stage)
+ r['pipeline_stage'] = stage or ''
+
return self.send_json({
"data": {
"grid": {"columns": columns, "rows": rows},
@@ -5020,6 +5261,9 @@ class CRMHandler(BaseHTTPRequestHandler):
WHERE id = 'main'
""", (json.dumps(grid), json.dumps(next_views), next_version, user['user_id'], now()))
sync_fundraising_relational(conn, grid, next_views, actor_user_id=user['user_id'])
+ # Archive pipeline opps orphaned by an investor deleted from the grid (one-way
+ # cleanup; never creates or reseeds — see reconcile_grid_pipeline_links).
+ reconcile_grid_pipeline_links(conn)
log_audit(conn, user['user_id'], 'fundraising_state', 'main', 'update', {"version": next_version})
conn.commit()
conn.close()
diff --git a/backend/test_grid_pipeline_link.py b/backend/test_grid_pipeline_link.py
new file mode 100644
index 0000000..9ef162b
--- /dev/null
+++ b/backend/test_grid_pipeline_link.py
@@ -0,0 +1,270 @@
+#!/usr/bin/env python3
+"""Tests for the grid → Pipeline link ("Adopt the Pipeline", v0.1.0:87).
+
+Boots the REAL server against a temp DB and exercises the new endpoints end-to-end:
+ - POST /api/fundraising/pipeline/link creates exactly ONE opportunity, linked via
+ opportunities.fundraising_investor_id, reusing the grid's synced contact (no
+ POST /api/contacts side-door) and mapping the grid 'lead' -> owner;
+ - the link is idempotent: a re-link returns the existing opp and NEVER reseeds its
+ Pipeline-owned funnel fields (stage/probability) — the board owns those;
+ - GET /api/fundraising/state injects read-only pipeline / pipeline_stage row values
+ derived from the live opp;
+ - linking a contactless row, or an unknown row, is refused;
+ - POST .../unlink soft-deletes the opp (off the board, recoverable) while leaving the
+ grid investor row fully intact;
+ - deleting an investor from the grid archives its orphaned opp on the next save;
+ - the pipeline report + dashboard aggregates exclude archived (soft-deleted) opps.
+Synthetic data only.
+
+Run: cd backend && python3 test_grid_pipeline_link.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 _req(port, method, path, token=None, body=None):
+ conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
+ headers = {}
+ if token:
+ headers["Authorization"] = "Bearer " + token
+ payload = None
+ if body is not None:
+ payload = json.dumps(body)
+ headers["Content-Type"] = "application/json"
+ conn.request(method, path, body=payload, headers=headers)
+ resp = conn.getresponse()
+ raw = resp.read().decode("utf-8", "replace")
+ conn.close()
+ data = None
+ if raw:
+ try:
+ data = json.loads(raw)
+ except ValueError:
+ pass
+ return resp.status, data
+
+
+def _put_grid(port, token, rows):
+ return _req(port, "PUT", "/api/fundraising/state", token,
+ {"grid": {"columns": [], "rows": rows}, "views": []})
+
+
+ROW_ACME = {"id": "rowAcme", "investor_name": "Acme Capital", "notes": "", "lead": "Grant",
+ "contacts": [{"name": "Jane Doe", "email": "jane@acme.com", "title": "GP"}]}
+ROW_BETA = {"id": "rowBeta", "investor_name": "Beta Capital LLC", "notes": "", "lead": "",
+ "contacts": [{"name": "Pat Roe", "email": "pat@beta.com", "title": ""}]}
+ROW_EMPTY = {"id": "rowEmpty", "investor_name": "Empty LP", "notes": "", "contacts": []}
+
+
+def _db():
+ return sqlite3.connect(os.environ["CRM_DB_PATH"])
+
+
+def seed():
+ c = _db()
+ 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)")
+ c.commit()
+ c.close()
+
+
+def _opp_count_live(fr_investor_id=None):
+ c = _db()
+ if fr_investor_id:
+ n = c.execute("SELECT COUNT(*) FROM opportunities WHERE fundraising_investor_id = ? "
+ "AND deleted_at IS NULL", (fr_investor_id,)).fetchone()[0]
+ else:
+ n = c.execute("SELECT COUNT(*) FROM opportunities WHERE deleted_at IS NULL").fetchone()[0]
+ c.close()
+ return n
+
+
+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:
+ st, _ = _put_grid(port, token, [ROW_ACME, ROW_BETA, ROW_EMPTY])
+ check(st == 200, f"seed grid via PUT /state (got {st})")
+
+ # ── link creates one linked opp with the seeds + resolved contact + mapped owner ──
+ print("\n[link: creates one linked opportunity with seeds]")
+ st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token, {
+ "source_row_id": "rowAcme", "fund_name": "Fund III",
+ "expected_amount": 250000, "probability": 40, "stage": "outreach",
+ })
+ opp = (d or {}).get("data") or {}
+ check(st == 201 and (d or {}).get("already_linked") is False, f"link -> 201 new (got {st}, {d})")
+ check(opp.get("stage") == "outreach" and opp.get("expected_amount") == 250000
+ and opp.get("probability") == 40 and opp.get("fund_name") == "Fund III",
+ f"seeds applied (got {{stage:{opp.get('stage')}, amt:{opp.get('expected_amount')}, "
+ f"prob:{opp.get('probability')}, fund:{opp.get('fund_name')}}})")
+ check(opp.get("first_name") == "Jane", f"reused synced contact Jane Doe (got {opp.get('first_name')})")
+ check(opp.get("owner_name") == "Grant", f"grid lead 'Grant' -> owner Grant (got {opp.get('owner_name')})")
+ fr_id = opp.get("fundraising_investor_id")
+ check(bool(fr_id), f"opportunity carries fundraising_investor_id (got {fr_id})")
+ check(_opp_count_live(fr_id) == 1, "exactly one live opp linked to the investor")
+ opp_id = opp.get("id")
+
+ # ── idempotent re-link: returns existing, board-owned stage NOT reseeded ──
+ print("\n[idempotent: re-link returns existing opp without reseeding funnel fields]")
+ st, _ = _req(port, "PATCH", f"/api/opportunities/{opp_id}/stage", token, {"stage": "meeting"})
+ check(st == 200, f"advance stage on the board -> meeting (got {st})")
+ st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token, {
+ "source_row_id": "rowAcme", "stage": "lead", "expected_amount": 999, "probability": 5,
+ })
+ opp2 = (d or {}).get("data") or {}
+ check(st == 200 and (d or {}).get("already_linked") is True, f"re-link -> already_linked (got {st}, {d})")
+ check(opp2.get("stage") == "meeting" and opp2.get("expected_amount") == 250000,
+ f"funnel fields preserved, not reseeded (got stage={opp2.get('stage')}, amt={opp2.get('expected_amount')})")
+ check(_opp_count_live(fr_id) == 1, "still exactly one live opp (no duplicate)")
+
+ # ── read-injection: GET state shows pipeline flag + stage, derived live ──
+ print("\n[read-injection: GET /state exposes read-only pipeline + pipeline_stage]")
+ st, d = _req(port, "GET", "/api/fundraising/state", token)
+ rows = {r["id"]: r for r in (d or {}).get("data", {}).get("grid", {}).get("rows", [])}
+ check(rows.get("rowAcme", {}).get("pipeline") is True
+ and rows.get("rowAcme", {}).get("pipeline_stage") == "meeting",
+ f"rowAcme pipeline true @meeting (got {rows.get('rowAcme', {}).get('pipeline')}, "
+ f"{rows.get('rowAcme', {}).get('pipeline_stage')})")
+ check(rows.get("rowBeta", {}).get("pipeline") is False
+ and rows.get("rowBeta", {}).get("pipeline_stage") == "",
+ f"rowBeta not in pipeline (got {rows.get('rowBeta', {}).get('pipeline')})")
+
+ # ── round-trip: a save echoing the injected read-only values is lossless ──
+ print("\n[round-trip: PUT carrying injected pipeline values strips them, link intact]")
+ st, d = _req(port, "GET", "/api/fundraising/state", token)
+ echoed = (d or {}).get("data", {}).get("grid", {}).get("rows", [])
+ st, _ = _put_grid(port, token, echoed) # as the frontend autosave would, rows still carry pipeline*
+ check(st == 200, f"echo-back save -> 200 (got {st})")
+ check(_opp_count_live(fr_id) == 1, "link survives the round-trip (no dup, not archived)")
+ c = _db()
+ blob = json.loads(c.execute("SELECT grid_json FROM fundraising_state WHERE id='main'").fetchone()[0])
+ c.close()
+ stored_acme = {r["id"]: r for r in blob.get("rows", [])}.get("rowAcme", {})
+ check("pipeline" not in stored_acme and "pipeline_stage" not in stored_acme,
+ "computed keys are NOT persisted into the grid blob")
+ st, d = _req(port, "GET", "/api/fundraising/state", token)
+ rt = {r["id"]: r for r in (d or {}).get("data", {}).get("grid", {}).get("rows", [])}.get("rowAcme", {})
+ check(rt.get("pipeline") is True and rt.get("pipeline_stage") == "meeting",
+ f"pipeline values re-injected after round-trip (got {rt.get('pipeline')}, {rt.get('pipeline_stage')})")
+
+ # ── guards ──
+ print("\n[guard: a contactless row cannot be added to the pipeline]")
+ st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token, {"source_row_id": "rowEmpty"})
+ check(st == 400, f"no contact -> 400 (got {st}, {d})")
+ check(_opp_count_live() == 1, "no stray opp created for the contactless row")
+
+ print("\n[guard: unknown grid row -> 404]")
+ st, _ = _req(port, "POST", "/api/fundraising/pipeline/link", token, {"source_row_id": "nope"})
+ check(st == 404, f"unknown row -> 404 (got {st})")
+
+ print("\n[guard: unauthenticated -> 401]")
+ st, _ = _req(port, "POST", "/api/fundraising/pipeline/link", None, {"source_row_id": "rowAcme"})
+ check(st == 401, f"no token -> 401 (got {st})")
+
+ # ── the opp loads on the board + counts in the dashboard while live ──
+ print("\n[board + dashboard count the live opp]")
+ st, d = _req(port, "GET", "/api/opportunities?limit=1000", token)
+ ids = [o["id"] for o in (d or {}).get("data", [])]
+ check(opp_id in ids, "linked opp appears on the board")
+ st, d = _req(port, "GET", "/api/reports/dashboard", token)
+ active = (d or {}).get("data", {}).get("metrics", {}).get("active_opportunities")
+ check(active == 1, f"dashboard active_opportunities == 1 (got {active})")
+
+ # ── unlink soft-deletes the opp; the GRID ROW stays fully intact ──
+ print("\n[unlink: archives the opp, leaves the grid investor intact]")
+ st, d = _req(port, "POST", "/api/fundraising/pipeline/unlink", token, {"source_row_id": "rowAcme"})
+ check(st == 200 and (d or {}).get("data", {}).get("archived") == 1, f"unlink -> archived 1 (got {st}, {d})")
+ check(_opp_count_live(fr_id) == 0, "opp is no longer live (soft-deleted)")
+ c = _db()
+ gone = c.execute("SELECT deleted_at FROM opportunities WHERE id = ?", (opp_id,)).fetchone()[0]
+ inv_still = c.execute("SELECT investor_name FROM fundraising_investors WHERE source_row_id = 'rowAcme'").fetchone()
+ contact_still = c.execute("SELECT COUNT(*) FROM fundraising_contacts WHERE investor_id = ?", (fr_id,)).fetchone()[0]
+ c.close()
+ check(gone is not None, "opp row tombstoned (deleted_at set), not hard-deleted")
+ check(inv_still and inv_still[0] == "Acme Capital", "grid investor row untouched by unlink")
+ check(contact_still >= 1, "grid investor's contacts untouched by unlink")
+ st, d = _req(port, "GET", "/api/opportunities?limit=1000", token)
+ check(opp_id not in [o["id"] for o in (d or {}).get("data", [])], "archived opp left the board")
+ st, d = _req(port, "GET", "/api/fundraising/state", token)
+ rows = {r["id"]: r for r in (d or {}).get("data", {}).get("grid", {}).get("rows", [])}
+ check(rows.get("rowAcme", {}).get("pipeline") is False, "grid no longer flags rowAcme as in-pipeline")
+
+ # ── aggregates exclude the archived opp ──
+ print("\n[aggregates exclude archived opps]")
+ st, d = _req(port, "GET", "/api/reports/dashboard", token)
+ active = (d or {}).get("data", {}).get("metrics", {}).get("active_opportunities")
+ check(active == 0, f"dashboard active_opportunities back to 0 (got {active})")
+ st, d = _req(port, "GET", "/api/reports/pipeline", token)
+ by_stage = (d or {}).get("data", {}).get("by_stage", [])
+ total = sum(s.get("count", 0) for s in by_stage)
+ check(total == 0, f"pipeline report by_stage excludes archived (got total {total})")
+
+ # ── re-link after unlink: a fresh opp is created (the archived one stays archived) ──
+ print("\n[re-link after unlink: creates a new opp, flag reappears]")
+ st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token, {
+ "source_row_id": "rowAcme", "stage": "outreach", "expected_amount": 50000,
+ })
+ relinked = (d or {}).get("data") or {}
+ check(st == 201 and (d or {}).get("already_linked") is False and relinked.get("id") != opp_id,
+ f"re-link -> a NEW opp distinct from the archived one (got {st}, {relinked.get('id')} vs {opp_id})")
+ check(_opp_count_live(fr_id) == 1, "exactly one live opp again after re-link")
+ st, _ = _req(port, "POST", "/api/fundraising/pipeline/unlink", token, {"source_row_id": "rowAcme"})
+ check(st == 200, "reset: unlink the re-linked opp")
+
+ # ── orphan reconciler: deleting the investor from the grid archives its opp ──
+ print("\n[orphan: deleting the grid investor archives its linked opp on next save]")
+ st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token, {
+ "source_row_id": "rowBeta", "stage": "lead", "expected_amount": 100000,
+ })
+ beta = (d or {}).get("data") or {}
+ beta_opp_id, beta_fr = beta.get("id"), beta.get("fundraising_investor_id")
+ check(st == 201 and _opp_count_live(beta_fr) == 1, f"beta linked (got {st})")
+ # drop rowBeta from the grid (keep the others)
+ st, _ = _put_grid(port, token, [ROW_ACME, ROW_EMPTY])
+ check(st == 200, f"save grid without rowBeta (got {st})")
+ check(_opp_count_live(beta_fr) == 0, "beta's orphaned opp archived by the reconciler")
+ st, d = _req(port, "GET", "/api/opportunities?limit=1000", token)
+ check(beta_opp_id not in [o["id"] for o in (d or {}).get("data", [])], "orphaned opp left the board")
+ finally:
+ httpd.shutdown()
+
+ print("\n" + ("ALL PASS" if not FAILS else f"{len(FAILS)} FAILURE(S):"))
+ for f in FAILS:
+ print(" - " + f)
+ sys.exit(1 if FAILS else 0)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/frontend/index.html b/frontend/index.html
index b67bcb8..03f7573 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4890,6 +4890,8 @@
{ id: 'follow_up', label: 'Follow up', type: 'checkbox', width: 110 },
{ id: 'lead', label: 'Lead', type: 'select', options: teamMembers, width: 130 },
{ id: 'graveyard', label: 'Graveyard', type: 'checkbox', width: 115 },
+ { id: 'pipeline', label: 'Pipeline', type: 'action', readOnly: true, width: 120 },
+ { id: 'pipeline_stage', label: 'Pipeline Stage', type: 'text', readOnly: true, width: 150 },
{ id: 'fund_i', label: 'Fund I', type: 'currency', isFund: true, width: 130 },
{ id: 'fund_ii', label: 'Fund II', type: 'currency', isFund: true, width: 130 },
{ id: 'fund_iii', label: 'Fund III', type: 'currency', isFund: true, width: 130 },
@@ -5039,6 +5041,25 @@
else cols.push(col);
changed = true;
}
+ // Pipeline-adoption columns: a per-row "Add to / In Pipeline" control and a
+ // read-only mirror of the linked opportunity's stage. Both are injected here so
+ // existing saved grids pick them up; their VALUES are server-computed on read.
+ const hasPipeline = cols.some((c) => c.id === 'pipeline');
+ if (!hasPipeline) {
+ const gy = cols.findIndex((c) => c.id === 'graveyard');
+ const col = { id: 'pipeline', label: 'Pipeline', type: 'action', readOnly: true, width: 120 };
+ if (gy >= 0) cols.splice(gy + 1, 0, col);
+ else cols.push(col);
+ changed = true;
+ }
+ const hasPipelineStage = cols.some((c) => c.id === 'pipeline_stage');
+ if (!hasPipelineStage) {
+ const pi = cols.findIndex((c) => c.id === 'pipeline');
+ const col = { id: 'pipeline_stage', label: 'Pipeline Stage', type: 'text', readOnly: true, width: 150 };
+ if (pi >= 0) cols.splice(pi + 1, 0, col);
+ else cols.push(col);
+ changed = true;
+ }
const rowsIn = Array.isArray(incomingRows) ? incomingRows : defaultRows;
const rowsOut = rowsIn.map((r) => {
const next = { ...r };
@@ -5059,6 +5080,16 @@
return { columns: cols, rows: rowsOut, changed };
};
+ // `pipeline` / `pipeline_stage` are read-only, server-computed (from the linked
+ // opportunity) and injected on GET. They must never participate in dirty-detection
+ // or be persisted — otherwise a link/unlink flips a value and triggers a no-op
+ // autosave + version bump. Strip them at every snapshot / persist boundary.
+ const stripComputedRows = (rs) => (Array.isArray(rs) ? rs.map((r) => {
+ if (!r || typeof r !== 'object') return r;
+ const { pipeline, pipeline_stage, ...rest } = r;
+ return rest;
+ }) : rs);
+
useEffect(() => {
let cancelled = false;
const hydrateFundraisingState = async () => {
@@ -5089,8 +5120,8 @@
setActiveView(incomingViews[0]?.id || 'view-main');
}
setRemoteVersion(Number.isFinite(incomingVersion) && incomingVersion > 0 ? incomingVersion : 1);
- lastSyncedSnapshotRef.current = JSON.stringify({ columns: incomingColumns, rows: incomingRows, views: incomingViews });
- localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns: incomingColumns, rows: incomingRows }));
+ lastSyncedSnapshotRef.current = JSON.stringify({ columns: incomingColumns, rows: stripComputedRows(incomingRows), views: incomingViews });
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns: incomingColumns, rows: stripComputedRows(incomingRows) }));
localStorage.setItem(FUNDRAISING_VIEWS_STORAGE_KEY, JSON.stringify(incomingViews));
} catch (err) {
onShowToast(getErrorMessage(err, 'Using local fundraising data'), 'error');
@@ -5108,7 +5139,8 @@
useEffect(() => {
if (!stateHydrated || !Number.isFinite(remoteVersion)) return;
- const snapshot = JSON.stringify({ columns, rows, views });
+ const persistRows = stripComputedRows(rows);
+ const snapshot = JSON.stringify({ columns, rows: persistRows, views });
if (snapshot === lastSyncedSnapshotRef.current) return;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
@@ -5117,7 +5149,7 @@
const response = await api('/api/fundraising/state', {
method: 'PUT',
body: JSON.stringify({
- grid: { columns, rows },
+ grid: { columns, rows: persistRows },
views,
expected_version: remoteVersion
})
@@ -5136,7 +5168,7 @@
next_version: Number.isFinite(nextVersion) ? nextVersion : null
});
lastSyncedSnapshotRef.current = snapshot;
- localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns, rows }));
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns, rows: persistRows }));
localStorage.setItem(FUNDRAISING_VIEWS_STORAGE_KEY, JSON.stringify(views));
} catch (err) {
if (err?.status === 409) {
@@ -5167,7 +5199,7 @@
}
onShowToast(getErrorMessage(err, 'Failed to save fundraising state'), 'error');
pushImportDebugEvent('autosave-error', { message: getErrorMessage(err, 'save failed') });
- localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns, rows }));
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns, rows: persistRows }));
localStorage.setItem(FUNDRAISING_VIEWS_STORAGE_KEY, JSON.stringify(views));
}
}, 550);
@@ -5724,7 +5756,11 @@
const openCreateOpportunityModal = (row) => {
if (!row) return;
const contacts = Array.isArray(row.contacts) ? row.contacts : [];
- const defaultName = `${row.investor_name || 'Investor'} Opportunity`;
+ if (contacts.length === 0) {
+ onShowToast('Add at least one contact to this investor row before adding it to the pipeline', 'error');
+ return;
+ }
+ const defaultName = `${row.investor_name || 'Investor'} — Pipeline`;
setCreateOppContext({ rowId: row.id, investorName: row.investor_name || '', contacts });
setCreateOppForm({
name: defaultName,
@@ -5737,78 +5773,65 @@
setShowCreateOppModal(true);
};
+ // Grid is canonical: the server resolves the contact from the row's already-synced
+ // contact pill (no POST /api/contacts side-door) and links the opp durably, so the
+ // bot/board can never spawn a duplicate. We only pass the seed.
const submitCreateOpportunity = async () => {
- if (!createOppContext?.investorName) return;
+ if (!createOppContext?.rowId) return;
setCreateOppSubmitting(true);
+ const rowId = createOppContext.rowId;
+ const payload = {
+ source_row_id: rowId,
+ contact_index: Number(createOppForm.contactIndex) || 0,
+ name: String(createOppForm.name || '').trim(),
+ stage: createOppForm.stage || 'lead',
+ expected_amount: parseNumericInput(createOppForm.expected_amount),
+ probability: Number(createOppForm.probability) || 35,
+ fund_name: String(createOppForm.fund_name || '').trim()
+ };
+ const doLink = () => api('/api/fundraising/pipeline/link', { method: 'POST', body: JSON.stringify(payload) }, token);
try {
- const contacts = Array.isArray(createOppContext.contacts) ? createOppContext.contacts : [];
- const selected = contacts[createOppForm.contactIndex] || contacts[0] || null;
- if (!selected) {
- onShowToast('Add at least one contact on the investor row first', 'error');
- return;
+ let resp;
+ try {
+ resp = await doLink();
+ } catch (err) {
+ // A row added moments ago may not have autosaved/synced yet — wait for the
+ // debounced save to flush, then retry once.
+ if (err?.status === 404) {
+ await new Promise((r) => setTimeout(r, 700));
+ resp = await doLink();
+ } else {
+ throw err;
+ }
}
-
- const allContactsResp = await api('/api/contacts?limit=1000', {}, token);
- const allContacts = Array.isArray(allContactsResp?.data) ? allContactsResp.data : [];
- const selectedEmail = String(selected.email || '').trim().toLowerCase();
- const selectedName = String(selected.name || '').trim().toLowerCase();
- const investorName = String(createOppContext.investorName || '').trim().toLowerCase();
-
- let matched = null;
- if (selectedEmail) {
- matched = allContacts.find((c) => String(c.email || '').trim().toLowerCase() === selectedEmail) || null;
- }
- if (!matched && selectedName) {
- matched = allContacts.find((c) => {
- const n = `${c.first_name || ''} ${c.last_name || ''}`.trim().toLowerCase();
- const org = String(c.organization_name || c.organization || '').trim().toLowerCase();
- return n === selectedName && org === investorName;
- }) || null;
- }
-
- let contactId = matched?.id || null;
- if (!contactId) {
- const split = splitFullName(selected.name || '');
- const created = await api('/api/contacts', {
- method: 'POST',
- body: JSON.stringify({
- first_name: split.first,
- last_name: split.last,
- email: selected.email || '',
- title: selected.title || '',
- organization: createOppContext.investorName,
- contact_type: 'investor',
- status: 'active'
- })
- }, token);
- contactId = created?.data?.id || null;
- }
-
- if (!contactId) throw new Error('Could not resolve contact');
-
- await api('/api/opportunities', {
- method: 'POST',
- body: JSON.stringify({
- name: String(createOppForm.name || '').trim() || `${createOppContext.investorName} Opportunity`,
- contact_id: contactId,
- stage: createOppForm.stage || 'lead',
- expected_amount: parseNumericInput(createOppForm.expected_amount),
- probability: Number(createOppForm.probability) || 35,
- fund_name: String(createOppForm.fund_name || '').trim(),
- priority: rows.find((r) => r.id === createOppContext.rowId)?.priority ? 'high' : 'medium'
- })
- }, token);
-
- onShowToast('Pipeline opportunity created', 'success');
+ const stage = resp?.data?.stage || payload.stage;
+ const already = resp?.already_linked;
+ setRows((prev) => prev.map((r) => (r.id === rowId ? { ...r, pipeline: true, pipeline_stage: stage } : r)));
+ onShowToast(already ? 'Already in the pipeline' : 'Added to the pipeline', already ? 'info' : 'success');
setShowCreateOppModal(false);
setCreateOppContext(null);
} catch (err) {
- onShowToast(getErrorMessage(err, 'Failed to create pipeline opportunity'), 'error');
+ onShowToast(getErrorMessage(err, 'Failed to add to the pipeline'), 'error');
} finally {
setCreateOppSubmitting(false);
}
};
+ // Remove from pipeline: archives (soft-deletes) the linked opportunity. The grid
+ // investor row — contacts, commitments, notes — is left fully intact.
+ const removeFromPipeline = async (row) => {
+ if (!row?.id) return;
+ const label = row.investor_name || 'this investor';
+ if (!window.confirm(`Remove ${label} from the pipeline? The deal is archived (recoverable); the grid row is untouched.`)) return;
+ try {
+ await api('/api/fundraising/pipeline/unlink', { method: 'POST', body: JSON.stringify({ source_row_id: row.id }) }, token);
+ setRows((prev) => prev.map((r) => (r.id === row.id ? { ...r, pipeline: false, pipeline_stage: '' } : r)));
+ onShowToast('Removed from the pipeline', 'success');
+ } catch (err) {
+ onShowToast(getErrorMessage(err, 'Failed to remove from the pipeline'), 'error');
+ }
+ };
+
const addRow = () => {
const base = buildEmptyRow();
setRows((prev) => [base, ...prev]);
@@ -6109,10 +6132,11 @@
const latest = await api('/api/fundraising/state', {}, token);
expectedVersion = Number(latest?.data?.version);
}
+ const persistRows = stripComputedRows(nextRows);
const saveResponse = await api('/api/fundraising/state', {
method: 'PUT',
body: JSON.stringify({
- grid: { columns: combinedColumns, rows: nextRows },
+ grid: { columns: combinedColumns, rows: persistRows },
views: nextViews,
expected_version: Number.isFinite(expectedVersion) && expectedVersion > 0 ? expectedVersion : 1
})
@@ -6121,8 +6145,8 @@
if (Number.isFinite(persistedVersion) && persistedVersion > 0) {
setRemoteVersion(persistedVersion);
}
- lastSyncedSnapshotRef.current = JSON.stringify({ columns: combinedColumns, rows: nextRows, views: nextViews });
- localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns: combinedColumns, rows: nextRows }));
+ lastSyncedSnapshotRef.current = JSON.stringify({ columns: combinedColumns, rows: persistRows, views: nextViews });
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns: combinedColumns, rows: persistRows }));
localStorage.setItem(FUNDRAISING_VIEWS_STORAGE_KEY, JSON.stringify(nextViews));
pushImportDebugEvent('import-persist-success', {
persisted_version: Number.isFinite(persistedVersion) ? persistedVersion : null,
@@ -6402,6 +6426,37 @@
if (typeof computed === 'number' && Number.isFinite(computed)) return computed.toLocaleString();
return String(computed ?? '');
}
+ if (col.id === 'pipeline') {
+ if (row.pipeline) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+ }
+ if (col.id === 'pipeline_stage') {
+ const stage = String(row.pipeline_stage || '');
+ if (!stage) return —;
+ return {stage.replace(/_/g, ' ')};
+ }
if (col.type === 'action' || col.id === 'log_action') {
return (