Remove Instructions/Feedback + lp_profiles; sync retry, purge, mobile fixes (v0.1.0:104)
Removals (net -570 lines): - Delete the Instructions and Feedback (feature_requests) pages + backend. - Retire lp_profiles + investor_type across server, ingest, and seeds; migration 0008 drops both empty tables (a sanctioned one-off exception to never-hard-delete). 0001's lp_profiles ALTER is removed so a fresh DB doesn't break the migration chain (live DBs already applied it). Fixes: - Email sync: a transient timeout no longer terminally parks a mailbox; the scheduler retries 'retrying' each cycle and re-includes errored accounts on an hourly backoff, so stuck mailboxes self-heal. - Mobile Contacts: page through the full directory (server caps 500/page) -- one fetch silently truncated at 720, hiding people from the list and from search. - Mobile email review: clock icon to set a reminder inline; approval cards show date/time. New: - Admin-only purge of soft-deleted rows (Settings -> Admin; type-to-confirm, refuses any row still linked to live data). Tests: 45/45 (adds test_sync_ready + test_purge_soft_deleted). Reviewer pass applied (NULL reminders.contact_id on contact purge). Bumped to v0.1.0:104.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Ten31 Venture CRM + Agentic System — AGENTS.md
|
# Ten31 Venture CRM + Agentic System — AGENTS.md
|
||||||
|
|
||||||
**The foundation is a self-hosted venture-fund CRM** — a purpose-built fundraising tool that replaced Airtable to (1) keep sensitive LP/prospect data off third-party servers, (2) drop subscription cost, and (3) fit the fund's workflow: managing ~150 existing LPs, tracking 250+ prospects, and running the capital-raise pipeline. Core CRM domain: contacts (investor/prospect/advisor), organizations, opportunities (the deal pipeline), and communications; investor commitments live in the canonical `fundraising_*` grid (the legacy single-fund `lp_profiles` table was retired in v0.1.0:78). The fund (Ten31, ~$200M AUM, bitcoin/energy/AI thesis) runs it on a Start9 box, accessed over ClearNet (StartOS StartTunnel) with app-level user auth by a team of ~5 (Tailscale is not in use). Schema/API tour: `docs/crm-overview.md`.
|
**The foundation is a self-hosted venture-fund CRM** — a purpose-built fundraising tool that replaced Airtable to (1) keep sensitive LP/prospect data off third-party servers, (2) drop subscription cost, and (3) fit the fund's workflow: managing ~150 existing LPs, tracking 250+ prospects, and running the capital-raise pipeline. Core CRM domain: contacts (investor/prospect/advisor), organizations, opportunities (the deal pipeline), and communications; investor commitments live in the canonical `fundraising_*` grid (the legacy single-fund `lp_profiles` table was retired in v0.1.0:78 and dropped in v0.1.0:104). The fund (Ten31, ~$200M AUM, bitcoin/energy/AI thesis) runs it on a Start9 box, accessed over ClearNet (StartOS StartTunnel) with app-level user auth by a team of ~5 (Tailscale is not in use). Schema/API tour: `docs/crm-overview.md`.
|
||||||
|
|
||||||
**The agentic system is new functionality built on top of that CRM** — an in-house AI layer to widen the fundraising funnel, sharpen the thesis, and automate outreach drafting. Frontier reasoning runs on Claude (Agent SDK/API); privacy-sensitive and bulk work runs on local DGX Spark models via the **Spark Control** gateway. **Phase 0/1 — no live outward-facing agents; agents draft, humans send.**
|
**The agentic system is new functionality built on top of that CRM** — an in-house AI layer to widen the fundraising funnel, sharpen the thesis, and automate outreach drafting. Frontier reasoning runs on Claude (Agent SDK/API); privacy-sensitive and bulk work runs on local DGX Spark models via the **Spark Control** gateway. **Phase 0/1 — no live outward-facing agents; agents draft, humans send.**
|
||||||
|
|
||||||
@@ -70,8 +70,8 @@ Subsystem rules live in `docs/guides/` and lazy-load in Claude Code via `.claude
|
|||||||
|
|
||||||
## Conventions
|
## 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`. **Derived read-only columns** (`pipeline`, `pipeline_stage`, `opportunity_id`, `reminder_status`, `existing_investor`, `last_activity_at`, `staleness`) are computed live and **injected on GET, never persisted** — any new one MUST be added to BOTH strip points (`server.py` `_computed_row_values` + frontend `stripComputedRows`) or it dirties the autosave / leaks into the blob. **Exception — the contact-pill email-heal** (`fundraising_contact_emails_by_row`, injected in `handle_get_fundraising_state`, v0.1.0:99): it fills a *blank* pill `email` from the linked classic contact and deliberately has **NO** strip point, because `email` is a real blob field, not a computed column — the next one-row save legitimately persists the recovered value (it's a self-healing backfill; don't "fix" it by adding a strip point). Pipeline stage is the 4-stage funnel `lead→engaged→diligence→commitment` (`PIPELINE_STAGES`), terminal at commitment.
|
- **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 and dropped** — the (empty) table was physically removed in v0.1.0:104 via migration `0008_drop_retired_tables`, a deliberate, documented one-off exception to never-hard-delete. The in-app **Instructions** and **Feedback** (`feature_requests`) pages were removed in the same release (the `feature_requests` table was dropped too). Reconciling grid ↔ classic `contacts` to canonical IDs is the core entity-resolution task — see `docs/crm-overview.md`. **Derived read-only columns** (`pipeline`, `pipeline_stage`, `opportunity_id`, `reminder_status`, `existing_investor`, `last_activity_at`, `staleness`) are computed live and **injected on GET, never persisted** — any new one MUST be added to BOTH strip points (`server.py` `_computed_row_values` + frontend `stripComputedRows`) or it dirties the autosave / leaks into the blob. **Exception — the contact-pill email-heal** (`fundraising_contact_emails_by_row`, injected in `handle_get_fundraising_state`, v0.1.0:99): it fills a *blank* pill `email` from the linked classic contact and deliberately has **NO** strip point, because `email` is a real blob field, not a computed column — the next one-row save legitimately persists the recovered value (it's a self-healing backfill; don't "fix" it by adding a strip point). Pipeline stage is the 4-stage funnel `lead→engaged→diligence→commitment` (`PIPELINE_STAGES`), terminal at commitment.
|
||||||
- **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` (+ `test_reminders.py` for the reminders read paths, incl. the recency rollup whose email-activity liveness signal is `email_account_messages.deleted_at`, not `emails`). (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` (+ `test_reminders.py` for the reminders read paths, incl. the recency rollup whose email-activity liveness signal is `email_account_messages.deleted_at`, not `emails`). (Thesis has a subtlety here — see the thesis guide.) **The ONE sanctioned hard-delete is the admin purge** (Settings → Admin "Purge Deleted Data"; `GET/POST /api/admin/soft-deleted[/purge]`, `handle_purge_soft_deleted`, v0.1.0:104): a guarded, type-to-confirm maintenance tool for clearing dummy/test data that hard-deletes ONLY `deleted_at IS NOT NULL` rows across contacts/orgs/opps/comms and **refuses (409) any contact/org whose `ON DELETE CASCADE`/`SET NULL` would touch a LIVE row** (and NULLs the bare logical-FK back-refs `fundraising_contacts.contact_id` + `reminders.contact_id`). Guarded by `backend/test_purge_soft_deleted.py`. It does **not** reach blank *live* grid rows (the grid blob has no soft-delete axis) — that's a separate cleanup.
|
||||||
- **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).
|
- **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`.
|
- **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`.
|
||||||
- **Agent/bot API access — three roles now (`admin`/`member`/`bot`).** `require_admin` is the only hard gate; everything else is "authenticated" (member, admin, *and* bot all pass). The **`bot` role** (added v0.1.0:89) is authenticated-but-never-admin: `require_bot_or_admin` gates agent-facing endpoints (e.g. `/api/intake/email-proposals*`) so a bot credential reaches *only* what it needs, never user-management/settings/security. Provision it via Settings → Admin edit-user dropdown (kept out of the teammate-invite form). **Two axes to keep separate as more agent capability lands:** the role controls *reach* (which endpoints); the per-feature human draft→approve gate controls *autonomy* (acting unattended). Money/merge/delete mutations stay behind the approval gate regardless of role. Don't build a finer capability/scope system until real NL-mutation endpoints exist to scope against.
|
- **Agent/bot API access — three roles now (`admin`/`member`/`bot`).** `require_admin` is the only hard gate; everything else is "authenticated" (member, admin, *and* bot all pass). The **`bot` role** (added v0.1.0:89) is authenticated-but-never-admin: `require_bot_or_admin` gates agent-facing endpoints (e.g. `/api/intake/email-proposals*`) so a bot credential reaches *only* what it needs, never user-management/settings/security. Provision it via Settings → Admin edit-user dropdown (kept out of the teammate-invite form). **Two axes to keep separate as more agent capability lands:** the role controls *reach* (which endpoints); the per-feature human draft→approve gate controls *autonomy* (acting unattended). Money/merge/delete mutations stay behind the approval gate regardless of role. Don't build a finer capability/scope system until real NL-mutation endpoints exist to scope against.
|
||||||
@@ -108,11 +108,12 @@ Subsystem rules live in `docs/guides/` and lazy-load in Claude Code via `.claude
|
|||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
_**Box live at v0.1.0:103 (deployed + verified 2026-06-20)** — clean migration chain (…→103, all no-op/frontend-only), server up on :8080. This session = a **mobile-UX feedback batch from Grant's device testing** (101 #1–5, 102 #6 email bell) + **103 reminders-require-a-date** (mobile + desktop); **Grant device-confirmed the mobile items + the date behavior on-device.** **The fundraising grid + email capture is the canonical system of record.** History: git log + `start9/0.4/startos/versions/`._
|
_**Box live at v0.1.0:104 (deployed + verified 2026-06-20)** — clean StartOS migration chain (…→104) and the in-app SQL chain through `0008_drop_retired_tables` (`lp_profiles` + `feature_requests` physically dropped on the box), server up on :8080. This session = a **removal + bug-fix + feature batch** (below). **The fundraising grid + email capture is the canonical system of record.** History: git log + `start9/0.4/startos/versions/`._
|
||||||
|
|
||||||
- **Mobile UX batch (Grant device feedback) — BUILT + LIVE (v0.1.0:101–102, 2026-06-20), on-device pass pending.** Six items (durable detail in the Design bullet → "Post-8 mobile-feedback primitives"): [1] ✕-clear on search/picker fields (`ClearableInput`); [2] tappable Grid contact pills (name→Contacts deep-link, email→mailto); [3] grid search already matched contact names — verified, no change; [4a] full-height Pipeline swipe area with bottom-pinned dots; [4b] editable pipeline `expected_amount` (add-to-pipeline + card detail, `PUT /api/opportunities/{id}`); [5] bottom sheets lift above the keyboard (visualViewport); [6] **`MobileEmailBell`** — admin-only email-approval bell, a third surface over `email_activity_proposals` that auto-syncs with the web panel + Matrix room.
|
- **Removed (v0.1.0:104):** the **Instructions** + **Feedback** (`feature_requests`) pages + backend, and `lp_profiles` + `investor_type` (across server / ingest / seeds). Migration `0008` drops both empty tables (a sanctioned one-off exception to never-hard-delete); `0001`'s `lp_profiles` ALTER was removed so a fresh DB doesn't break the migration chain. Net −570 lines.
|
||||||
- **Reminders require a due date — BUILT + LIVE (v0.1.0:103, deployed + verified 2026-06-20).** **Every** create surface (mobile add-investor / standalone Reminders / Grid-detail, **and desktop** Reminders page + grid modal) pre-fills the date to +1 week (editable) and blocks an empty save (`reminderDefaultDue()`); edit paths pre-fill it for legacy date-less reminders too. Detail in the Design bullet.
|
- **Fixes (v0.1.0:104):** [B] email sync no longer terminally parks a mailbox on a transient timeout — `'retrying'` retries every cycle, `'error'` re-included on an hourly backoff, so **Grant's & Jonathan's stuck mailboxes self-heal on this deploy** (`test_sync_ready.py`). [C] clock icon on the mobile email Review-log sets a reminder inline. [D] email-approval cards show date/time. **[Contacts 500-cap]** the mobile Contacts directory now pages through ALL contacts (was truncated at 500 of 720 — hid people from the list *and* search).
|
||||||
- **Verification: render-smoke green** (build-gated — JSX transforms + app mounts), reviewer-agent **APPROVE, no blockers** across all batches + a holistic pass (nits applied: ClearableInput conditional padding, bell `busyRef` double-submit guard, disabled-button dimming, reminder edit-path default-fill). All new work is **frontend-only — no schema / migration / dependency change**, so backend is untouched (43/43 backend tests still green from v100). New UI behavior is **live-smoke / on-device only** (jsdom can't drive touch/keyboard/mailto).
|
- **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`.
|
||||||
- **On-device — CONFIRMED (Grant, 2026-06-20):** the v101–102 mobile items (✕ clears, tappable contacts name→contact & email→mail, Pipeline swipe + bottom dots, amount round-trips, keyboard-lifted picker, the email bell) + the v103 date requirement all look good on his phone.
|
- **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).
|
||||||
- **Next:** (A) one remaining spot-check on the bell — the **approve-on-phone → Matrix-thread-clears** round-trip (the UI works; confirm the bidirectional sync end-to-end with a real proposal). (B) Carried from v100: #7 real-card spot-checks + the standing mobile light/dark + PWA-install gate.
|
- **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.
|
||||||
- **Open / risks:** `.pipeline-screen { height:100% }` leans on the `.content` flex chain for a definite height — confirm the swipe area fills + scrolls on Grant's iOS (resolves on iOS 16+; no speculative patch applied). Bell + amount-edit are admin/live-smoke only. Carried: **Claude/Architect path unverified live on the box**; vision OCR can misread a small-in-frame card (`mara.com→marac.com`, temp 0); phone/LinkedIn land on the contact record, not the grid pill; PWA iOS status bar fixed `black` in light theme; doc drift — `crm-overview.md`/`EVALUATION.md` still call `lp_profiles` live.
|
- **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.
|
||||||
|
- **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).
|
||||||
|
|||||||
+6
-4
@@ -12,9 +12,6 @@
|
|||||||
- `GET /api/fundraising/backups`
|
- `GET /api/fundraising/backups`
|
||||||
- `GET/PATCH /api/fundraising/backup-policy`
|
- `GET/PATCH /api/fundraising/backup-policy`
|
||||||
- `GET /api/fundraising/relational-summary`
|
- `GET /api/fundraising/relational-summary`
|
||||||
- `GET /api/feature-requests`
|
|
||||||
- `POST /api/feature-requests`
|
|
||||||
- `PATCH /api/feature-requests/:id`
|
|
||||||
- New DB tables:
|
- New DB tables:
|
||||||
- `fundraising_state`
|
- `fundraising_state`
|
||||||
- `fundraising_investors`
|
- `fundraising_investors`
|
||||||
@@ -22,7 +19,6 @@
|
|||||||
- `fundraising_funds`
|
- `fundraising_funds`
|
||||||
- `fundraising_commitments`
|
- `fundraising_commitments`
|
||||||
- `fundraising_views`
|
- `fundraising_views`
|
||||||
- `feature_requests`
|
|
||||||
- `app_settings`
|
- `app_settings`
|
||||||
- Grid saves/restores now sync into relational fundraising tables automatically.
|
- Grid saves/restores now sync into relational fundraising tables automatically.
|
||||||
- Formula engine is now sandboxed (no `eval`/`new Function`) with expanded function support.
|
- Formula engine is now sandboxed (no `eval`/`new Function`) with expanded function support.
|
||||||
@@ -86,6 +82,12 @@
|
|||||||
|
|
||||||
## Backlog (post-Phase-1 agentic)
|
## Backlog (post-Phase-1 agentic)
|
||||||
|
|
||||||
|
### Data-model cleanups (deferred from the v0.1.0:104 session)
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
### Follow-ups/reminders + NL search + bot grid-mutations (agreed plan, 2026-06-18)
|
### 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.*
|
*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.*
|
||||||
|
|
||||||
|
|||||||
@@ -57,10 +57,20 @@ def _json(v) -> str:
|
|||||||
# ------------------------------------------------------------------ email_accounts
|
# ------------------------------------------------------------------ email_accounts
|
||||||
|
|
||||||
def list_sync_ready_accounts(conn: sqlite3.Connection) -> list[sqlite3.Row]:
|
def list_sync_ready_accounts(conn: sqlite3.Connection) -> list[sqlite3.Row]:
|
||||||
|
# Ready = healthy ('pending'/'active') + transient-failing ('retrying', retried every
|
||||||
|
# cycle for fast recovery) + 'error' accounts whose last attempt was over an hour ago.
|
||||||
|
# The hour-backoff on 'error' means a terminal failure (auth/permanent) self-heals once
|
||||||
|
# the operator fixes it WITHOUT hammering Google, and un-sticks any mailbox parked by the
|
||||||
|
# pre-v0.1.0:104 bug where one timeout dark-listed it forever. (last_synced_at is stamped
|
||||||
|
# on every attempt, success or fail, so it doubles as the last-attempt clock here.)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT * FROM email_accounts "
|
"SELECT * FROM email_accounts "
|
||||||
"WHERE sync_enabled = 1 AND sync_status IN ('pending','active') "
|
"WHERE sync_enabled = 1 AND ("
|
||||||
|
" sync_status IN ('pending','active','retrying') "
|
||||||
|
" OR (sync_status = 'error' AND (last_synced_at IS NULL "
|
||||||
|
" OR last_synced_at < datetime('now','-1 hour')))"
|
||||||
|
") "
|
||||||
"ORDER BY last_synced_at IS NOT NULL, last_synced_at"
|
"ORDER BY last_synced_at IS NOT NULL, last_synced_at"
|
||||||
)
|
)
|
||||||
return cur.fetchall()
|
return cur.fetchall()
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import logging
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import traceback
|
import traceback
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from urllib.error import URLError
|
||||||
|
|
||||||
from . import attachments as _attach
|
from . import attachments as _attach
|
||||||
from . import config as _cfg
|
from . import config as _cfg
|
||||||
@@ -112,6 +113,15 @@ def sync_account(conn_factory, credential_provider, account,
|
|||||||
error_str = "history expired; fallback to date backfill"
|
error_str = "history expired; fallback to date backfill"
|
||||||
status = "partial"
|
status = "partial"
|
||||||
_fallback_date_backfill(conn_factory, client, account, index, run_stats)
|
_fallback_date_backfill(conn_factory, client, account, index, run_stats)
|
||||||
|
except (_errors.RateLimitError, _errors.TransientError, URLError, TimeoutError) as e:
|
||||||
|
# A network / 5xx / rate-limit error that outlived the in-pass retry loop.
|
||||||
|
# This is TRANSIENT, not terminal: park it as 'retrying' (which the scheduler
|
||||||
|
# still picks up every cycle) instead of 'error' (which it excludes). Fixes the
|
||||||
|
# v<=0.1.0:103 bug where a single timeout dark-listed a mailbox until a manual
|
||||||
|
# kick. Terminal causes (auth, permanent, unexpected) still fall through to 'error'.
|
||||||
|
error_str = f"transient: {type(e).__name__}: {e}"
|
||||||
|
status = "retrying"
|
||||||
|
log.warning("transient error during sync of %s: %s", email_addr, e)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_str = f"unexpected: {type(e).__name__}: {e}"
|
error_str = f"unexpected: {type(e).__name__}: {e}"
|
||||||
status = "error"
|
status = "error"
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Regression test for list_sync_ready_accounts (v0.1.0:104).
|
||||||
|
|
||||||
|
Guards the Bug-B fix: a transient network timeout used to flip a mailbox to terminal
|
||||||
|
sync_status='error', which the old `IN ('pending','active')` filter excluded forever —
|
||||||
|
so a single blip dark-listed a mailbox until a manual kick. The new filter:
|
||||||
|
* always includes 'pending' / 'active' / 'retrying' (the transient-retry state), and
|
||||||
|
* re-includes 'error' accounts whose last attempt was over an hour ago (gentle backoff,
|
||||||
|
so a fixed credential self-heals and the pre-fix stuck mailboxes recover on deploy).
|
||||||
|
Synthetic data only (guardrail #9).
|
||||||
|
Run: cd backend && python3 email_integration/test_sync_ready.py
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
from email_integration import db as edb # noqa: E402
|
||||||
|
|
||||||
|
FAILS = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(cond, msg):
|
||||||
|
print((" PASS " if cond else " FAIL ") + msg)
|
||||||
|
if not cond:
|
||||||
|
FAILS.append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def make_account(conn, email, status, *, enabled=1, last_synced_sql="NULL"):
|
||||||
|
aid = edb.upsert_account(conn, user_id="u-" + email,
|
||||||
|
email_address=email, auth_method="dwd")
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE email_accounts SET sync_status=?, sync_enabled=?, "
|
||||||
|
f"last_synced_at={last_synced_sql} WHERE id=?",
|
||||||
|
(status, enabled, aid),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return aid
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
conn = sqlite3.connect(":memory:")
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
edb.apply_migrations(conn.cursor())
|
||||||
|
|
||||||
|
make_account(conn, "active@t.xyz", "active", last_synced_sql="datetime('now','-5 minutes')")
|
||||||
|
make_account(conn, "retrying@t.xyz", "retrying", last_synced_sql="datetime('now','-5 minutes')")
|
||||||
|
make_account(conn, "pending@t.xyz", "pending", last_synced_sql="NULL")
|
||||||
|
make_account(conn, "error_stale@t.xyz", "error", last_synced_sql="datetime('now','-2 hours')")
|
||||||
|
make_account(conn, "error_recent@t.xyz", "error", last_synced_sql="datetime('now','-5 minutes')")
|
||||||
|
make_account(conn, "error_neversync@t.xyz", "error", last_synced_sql="NULL")
|
||||||
|
make_account(conn, "disabled@t.xyz", "active", enabled=0, last_synced_sql="datetime('now','-5 minutes')")
|
||||||
|
|
||||||
|
ready = {r["email_address"] for r in edb.list_sync_ready_accounts(conn)}
|
||||||
|
|
||||||
|
check("active@t.xyz" in ready, "healthy 'active' is ready")
|
||||||
|
check("retrying@t.xyz" in ready, "transient 'retrying' is ready (fast retry)")
|
||||||
|
check("pending@t.xyz" in ready, "'pending' is ready")
|
||||||
|
check("error_stale@t.xyz" in ready, "'error' last attempted >1h ago is ready (backoff elapsed → recovers stuck mailbox)")
|
||||||
|
check("error_neversync@t.xyz" in ready, "'error' never synced (NULL last attempt) is ready")
|
||||||
|
check("error_recent@t.xyz" not in ready, "'error' attempted <1h ago is NOT ready (gentle backoff, no hammering)")
|
||||||
|
check("disabled@t.xyz" not in ready, "sync_enabled=0 is never ready")
|
||||||
|
|
||||||
|
print()
|
||||||
|
if FAILS:
|
||||||
|
print(f"{len(FAILS)} FAILED")
|
||||||
|
sys.exit(1)
|
||||||
|
print("ALL PASS (sync ready filter)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -4,7 +4,7 @@ Maps each CRM record type to one or more chunks per docs/EMBEDDINGS.md:
|
|||||||
* one chunk per communications row (doc_type = the comm type)
|
* one chunk per communications row (doc_type = the comm type)
|
||||||
* one chunk per MATCHED email (doc_type = email; body only when matched)
|
* one chunk per MATCHED email (doc_type = email; body only when matched)
|
||||||
* one chunk per fundraising_investors notes LINE (the outreach log; split per line)
|
* one chunk per fundraising_investors notes LINE (the outreach log; split per line)
|
||||||
* one chunk each for free-text fields: contacts.notes, lp_profiles.notes,
|
* one chunk each for free-text fields: contacts.notes,
|
||||||
opportunities (description + next_step), organizations.description
|
opportunities (description + next_step), organizations.description
|
||||||
|
|
||||||
Each chunk carries a canonical `lp_id` (resolved via entity_links) and a `date_ts`
|
Each chunk carries a canonical `lp_id` (resolved via entity_links) and a `date_ts`
|
||||||
@@ -104,13 +104,6 @@ def build_chunks(conn):
|
|||||||
chunks.append(_mk(f"contacts.notes:{r['id']}", lp, lp_name, person,
|
chunks.append(_mk(f"contacts.notes:{r['id']}", lp, lp_name, person,
|
||||||
"contact_note", to_epoch(r["updated_at"]), r["notes"], "contacts", r["id"]))
|
"contact_note", to_epoch(r["updated_at"]), r["notes"], "contacts", r["id"]))
|
||||||
|
|
||||||
# lp_profiles.notes
|
|
||||||
for r in conn.execute("""SELECT lp.id, lp.contact_id, lp.notes, lp.updated_at
|
|
||||||
FROM lp_profiles lp WHERE lp.notes IS NOT NULL AND lp.notes <> '' AND lp.deleted_at IS NULL"""):
|
|
||||||
lp, lp_name, person = _contact_lp(r["contact_id"], person_canon, org_canon, name, contact_org)
|
|
||||||
chunks.append(_mk(f"lp_profiles.notes:{r['id']}", lp, lp_name, person,
|
|
||||||
"lp_note", to_epoch(r["updated_at"]), r["notes"], "lp_profiles", r["id"]))
|
|
||||||
|
|
||||||
# opportunities (description + next_step)
|
# opportunities (description + next_step)
|
||||||
for r in conn.execute("""SELECT id, contact_id, name, description, next_step, updated_at
|
for r in conn.execute("""SELECT id, contact_id, name, description, next_step, updated_at
|
||||||
FROM opportunities WHERE deleted_at IS NULL"""):
|
FROM opportunities WHERE deleted_at IS NULL"""):
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ layer created by migration 0001:
|
|||||||
fundraising_investors ─┴─► canonical_entities (entity_kind = lp | organization)
|
fundraising_investors ─┴─► canonical_entities (entity_kind = lp | organization)
|
||||||
contacts ─┐
|
contacts ─┐
|
||||||
fundraising_contacts ─┴─► canonical_entities (entity_kind = person)
|
fundraising_contacts ─┴─► canonical_entities (entity_kind = person)
|
||||||
lp_profiles ───► linked to its contact's person entity
|
|
||||||
|
|
||||||
Every source row is recorded in `entity_links` so any name variant resolves to
|
Every source row is recorded in `entity_links` so any name variant resolves to
|
||||||
one canonical id. This is the DETERMINISTIC tier — it merges only what we can
|
one canonical id. This is the DETERMINISTIC tier — it merges only what we can
|
||||||
@@ -184,7 +183,7 @@ def resolve_people(conn, org_canon_by_orgid, org_canon_by_fundinv, merge_map=Non
|
|||||||
people — each is matched to a contact-person and recorded only as a member_of
|
people — each is matched to a contact-person and recorded only as a member_of
|
||||||
edge to its investor entity (the grid's 'Contacts' column says who belongs to
|
edge to its investor entity (the grid's 'Contacts' column says who belongs to
|
||||||
which investor). This is what stops the double-count.
|
which investor). This is what stops the double-count.
|
||||||
Returns contact_id -> person canonical id (for lp_profiles)."""
|
Returns contact_id -> person canonical id."""
|
||||||
merge_map = merge_map or {}
|
merge_map = merge_map or {}
|
||||||
contact_to_person = {}
|
contact_to_person = {}
|
||||||
person_meta = {}
|
person_meta = {}
|
||||||
@@ -245,12 +244,6 @@ def resolve_people(conn, org_canon_by_orgid, org_canon_by_fundinv, merge_map=Non
|
|||||||
_link(conn, cid, "fundraising_contacts", r["id"], email or name_norm, mk, 0.95 if mk == "grid_link" else 0.9)
|
_link(conn, cid, "fundraising_contacts", r["id"], email or name_norm, mk, 0.95 if mk == "grid_link" else 0.9)
|
||||||
_member_of(conn, cid, inv_canon)
|
_member_of(conn, cid, inv_canon)
|
||||||
|
|
||||||
# lp_profiles -> the person entity of its contact
|
|
||||||
for r in conn.execute("SELECT id, contact_id FROM lp_profiles WHERE deleted_at IS NULL"):
|
|
||||||
cid = contact_to_person.get(r["contact_id"])
|
|
||||||
if cid:
|
|
||||||
_link(conn, cid, "lp_profiles", r["id"], r["contact_id"], "contact_fk", 1.0)
|
|
||||||
|
|
||||||
return person_meta
|
return person_meta
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import entity_resolution as er
|
|||||||
import qdrant_io
|
import qdrant_io
|
||||||
|
|
||||||
_CHANGE_TABLES = [("communications", "communications"), ("contacts", "contacts"),
|
_CHANGE_TABLES = [("communications", "communications"), ("contacts", "contacts"),
|
||||||
("lp_profiles", "lp_profiles"), ("opportunities", "opportunities"),
|
("opportunities", "opportunities"),
|
||||||
("organizations", "organizations"), ("fundraising_investors", "fundraising_investors")]
|
("organizations", "organizations"), ("fundraising_investors", "fundraising_investors")]
|
||||||
|
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ def _state_set(conn, key, value):
|
|||||||
def _deleted_source_ids(conn, since):
|
def _deleted_source_ids(conn, since):
|
||||||
"""CRM records soft-deleted since the watermark — their chunks get pruned."""
|
"""CRM records soft-deleted since the watermark — their chunks get pruned."""
|
||||||
ids = set()
|
ids = set()
|
||||||
for tbl in ("contacts", "organizations", "opportunities", "communications", "lp_profiles"):
|
for tbl in ("contacts", "organizations", "opportunities", "communications"):
|
||||||
try:
|
try:
|
||||||
for r in conn.execute(f"SELECT id FROM {tbl} WHERE deleted_at IS NOT NULL AND deleted_at > ?", (since,)):
|
for r in conn.execute(f"SELECT id FROM {tbl} WHERE deleted_at IS NOT NULL AND deleted_at > ?", (since,)):
|
||||||
ids.add(r["id"])
|
ids.add(r["id"])
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Asserts the SAFE fix:
|
|||||||
3. a grid contact that can't be PROVABLY matched mints NOTHING (no duplicate
|
3. a grid contact that can't be PROVABLY matched mints NOTHING (no duplicate
|
||||||
person, no cross-firm name guess) — the count stays correct,
|
person, no cross-firm name guess) — the count stays correct,
|
||||||
4. targeted cleanup soft-deletes a stale grid-only "twin" (person with no
|
4. targeted cleanup soft-deletes a stale grid-only "twin" (person with no
|
||||||
contacts link) and a superseded 'lp'/'organization' row, with no enrichment,
|
contacts link), with no enrichment,
|
||||||
5. cleanup PRESERVES a grid-only person that carries enrichment (guardrail #3),
|
5. cleanup PRESERVES a grid-only person that carries enrichment (guardrail #3),
|
||||||
6. a re-emitted id is UN-tombstoned (no permanent burial),
|
6. a re-emitted id is UN-tombstoned (no permanent burial),
|
||||||
7. re-running is idempotent.
|
7. re-running is idempotent.
|
||||||
@@ -58,10 +58,9 @@ CREATE TABLE contacts (
|
|||||||
CREATE TABLE organizations (id TEXT PRIMARY KEY, name TEXT, email TEXT);
|
CREATE TABLE organizations (id TEXT PRIMARY KEY, name TEXT, email TEXT);
|
||||||
CREATE TABLE fundraising_investors (id TEXT PRIMARY KEY, investor_name TEXT);
|
CREATE TABLE fundraising_investors (id TEXT PRIMARY KEY, investor_name TEXT);
|
||||||
CREATE TABLE fundraising_contacts (id TEXT PRIMARY KEY, full_name TEXT, email TEXT, investor_id TEXT, contact_id TEXT);
|
CREATE TABLE fundraising_contacts (id TEXT PRIMARY KEY, full_name TEXT, email TEXT, investor_id TEXT, contact_id TEXT);
|
||||||
CREATE TABLE lp_profiles (id TEXT PRIMARY KEY, contact_id TEXT, deleted_at TEXT);
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
SEEDED = ("per_TWIN", "per_ENR", "lp_OLD")
|
SEEDED = ("per_TWIN", "per_ENR")
|
||||||
|
|
||||||
|
|
||||||
def seed(db):
|
def seed(db):
|
||||||
@@ -94,16 +93,14 @@ def seed(db):
|
|||||||
"('per_ENR','person','Enriched Orphan','entity_resolution','warm')")
|
"('per_ENR','person','Enriched Orphan','entity_resolution','warm')")
|
||||||
c.execute("INSERT INTO entity_links (id, canonical_id, source_model, source_id, match_value, match_kind, confidence, created_at) "
|
c.execute("INSERT INTO entity_links (id, canonical_id, source_model, source_id, match_value, match_kind, confidence, created_at) "
|
||||||
"VALUES ('l_enr','per_ENR','fundraising_contacts','gy','enr','name_org',0.8,'t')")
|
"VALUES ('l_enr','per_ENR','fundraising_contacts','gy','enr','name_org',0.8,'t')")
|
||||||
# Superseded pre-:48 kind -> prune
|
|
||||||
c.execute("INSERT INTO canonical_entities (id, entity_kind, display_name, source) VALUES "
|
|
||||||
"('lp_OLD','lp','Old LP Row','entity_resolution')")
|
|
||||||
c.commit()
|
c.commit()
|
||||||
c.close()
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
def resolved_persons(db):
|
def resolved_persons(db):
|
||||||
c = sqlite3.connect(db)
|
c = sqlite3.connect(db)
|
||||||
q = "SELECT COUNT(*) FROM canonical_entities WHERE entity_kind='person' AND deleted_at IS NULL AND id NOT IN (?,?,?)"
|
ph = ",".join("?" * len(SEEDED))
|
||||||
|
q = f"SELECT COUNT(*) FROM canonical_entities WHERE entity_kind='person' AND deleted_at IS NULL AND id NOT IN ({ph})"
|
||||||
n = c.execute(q, SEEDED).fetchone()[0]
|
n = c.execute(q, SEEDED).fetchone()[0]
|
||||||
c.close()
|
c.close()
|
||||||
return n
|
return n
|
||||||
@@ -127,10 +124,11 @@ def grid_match_kinds(db):
|
|||||||
def minted_from_grid(db):
|
def minted_from_grid(db):
|
||||||
"""Persons minted directly from a grid row (the bug). Should be 0 after the fix."""
|
"""Persons minted directly from a grid row (the bug). Should be 0 after the fix."""
|
||||||
c = sqlite3.connect(db)
|
c = sqlite3.connect(db)
|
||||||
n = c.execute("""SELECT COUNT(DISTINCT l.canonical_id) FROM entity_links l
|
ph = ",".join("?" * len(SEEDED))
|
||||||
|
n = c.execute(f"""SELECT COUNT(DISTINCT l.canonical_id) FROM entity_links l
|
||||||
JOIN canonical_entities ce ON ce.id=l.canonical_id AND ce.deleted_at IS NULL
|
JOIN canonical_entities ce ON ce.id=l.canonical_id AND ce.deleted_at IS NULL
|
||||||
WHERE l.source_model='fundraising_contacts' AND l.match_kind IN ('name_org','exact_email')
|
WHERE l.source_model='fundraising_contacts' AND l.match_kind IN ('name_org','exact_email')
|
||||||
AND l.canonical_id NOT IN (?,?,?)""", SEEDED).fetchone()[0]
|
AND l.canonical_id NOT IN ({ph})""", SEEDED).fetchone()[0]
|
||||||
c.close()
|
c.close()
|
||||||
return n
|
return n
|
||||||
|
|
||||||
@@ -162,12 +160,11 @@ def main():
|
|||||||
check(mk.get("grid_assoc", 0) == 2, f"two grid contacts matched back via grid_assoc (got {mk.get('grid_assoc',0)})")
|
check(mk.get("grid_assoc", 0) == 2, f"two grid contacts matched back via grid_assoc (got {mk.get('grid_assoc',0)})")
|
||||||
check(mk.get("grid_link", 0) == 1, f"one grid contact linked via explicit contact_id (grid_link==1, got {mk.get('grid_link',0)})")
|
check(mk.get("grid_link", 0) == 1, f"one grid contact linked via explicit contact_id (grid_link==1, got {mk.get('grid_link',0)})")
|
||||||
|
|
||||||
# Targeted cleanup: stale grid-only twin + superseded 'lp' row tombstoned...
|
# Targeted cleanup: stale grid-only twin tombstoned...
|
||||||
check(deleted_at(db, "per_TWIN") is not None, "stale grid-only twin 'per_TWIN' tombstoned")
|
check(deleted_at(db, "per_TWIN") is not None, "stale grid-only twin 'per_TWIN' tombstoned")
|
||||||
check(deleted_at(db, "lp_OLD") is not None, "superseded 'lp' row 'lp_OLD' tombstoned")
|
|
||||||
# ...enriched grid-only person preserved.
|
# ...enriched grid-only person preserved.
|
||||||
check(deleted_at(db, "per_ENR") is None, "enriched grid-only person 'per_ENR' PRESERVED (has segment)")
|
check(deleted_at(db, "per_ENR") is None, "enriched grid-only person 'per_ENR' PRESERVED (has segment)")
|
||||||
check(counts1.get("pruned_stale", 0) == 2, f"exactly 2 stale rows pruned (got {counts1.get('pruned_stale')})")
|
check(counts1.get("pruned_stale", 0) == 1, f"exactly 1 stale row pruned (got {counts1.get('pruned_stale')})")
|
||||||
|
|
||||||
# Un-tombstone: soft-delete a real contact-person, then re-run -> it comes back.
|
# Un-tombstone: soft-delete a real contact-person, then re-run -> it comes back.
|
||||||
alice = er._eid("per", "e|alice@x.com")
|
alice = er._eid("per", "e|alice@x.com")
|
||||||
|
|||||||
@@ -113,4 +113,8 @@ ALTER TABLE contacts ADD COLUMN deleted_at TEXT;
|
|||||||
ALTER TABLE organizations ADD COLUMN deleted_at TEXT;
|
ALTER TABLE organizations ADD COLUMN deleted_at TEXT;
|
||||||
ALTER TABLE opportunities ADD COLUMN deleted_at TEXT;
|
ALTER TABLE opportunities ADD COLUMN deleted_at TEXT;
|
||||||
ALTER TABLE communications ADD COLUMN deleted_at TEXT;
|
ALTER TABLE communications ADD COLUMN deleted_at TEXT;
|
||||||
ALTER TABLE lp_profiles ADD COLUMN deleted_at TEXT;
|
-- lp_profiles ALTER removed (v0.1.0:104): the lp_profiles table is dropped in
|
||||||
|
-- 0008_drop_retired_tables.sql and is no longer created by init_db(), so this
|
||||||
|
-- ALTER would fail "no such table" on a fresh install. Live DBs already applied
|
||||||
|
-- this migration (with the original ALTER) before lp_profiles was dropped, so
|
||||||
|
-- removing the line here only affects fresh DBs — same end state either way.
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
-- 0008_drop_retired_tables.down.sql (manual rollback only — never auto-applied)
|
||||||
|
--
|
||||||
|
-- Recreates the two dropped tables as EMPTY shells, matching the schema that existed
|
||||||
|
-- immediately before 0008 (lp_profiles includes the deleted_at column that migration
|
||||||
|
-- 0001 had added). Data is not restored — both tables were empty when dropped.
|
||||||
|
CREATE TABLE IF NOT EXISTS lp_profiles (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
contact_id TEXT NOT NULL UNIQUE REFERENCES contacts(id) ON DELETE CASCADE,
|
||||||
|
commitment_amount REAL DEFAULT 0,
|
||||||
|
funded_amount REAL DEFAULT 0,
|
||||||
|
commitment_date TEXT,
|
||||||
|
fund_name TEXT,
|
||||||
|
investor_type TEXT,
|
||||||
|
accredited INTEGER DEFAULT 0,
|
||||||
|
legal_docs_signed INTEGER DEFAULT 0,
|
||||||
|
signed_date TEXT,
|
||||||
|
wire_received INTEGER DEFAULT 0,
|
||||||
|
wire_date TEXT,
|
||||||
|
k1_sent INTEGER DEFAULT 0,
|
||||||
|
preferred_communication TEXT DEFAULT 'email',
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lp_profiles_contact ON lp_profiles(contact_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS feature_requests (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
page TEXT,
|
||||||
|
category TEXT DEFAULT 'general',
|
||||||
|
priority TEXT DEFAULT 'medium',
|
||||||
|
status TEXT DEFAULT 'new',
|
||||||
|
requested_by TEXT,
|
||||||
|
requested_by_user_id TEXT REFERENCES users(id),
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_feature_requests_status ON feature_requests(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_feature_requests_created_at ON feature_requests(created_at);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- 0008_drop_retired_tables.sql (v0.1.0:104)
|
||||||
|
--
|
||||||
|
-- ONE-OFF DESTRUCTIVE EXCEPTION to the never-hard-delete rule, explicitly approved.
|
||||||
|
-- Both tables are EMPTY and fully removed from the application code:
|
||||||
|
-- * lp_profiles — the legacy single-fund LP model, retired v0.1.0:78; the
|
||||||
|
-- fundraising_* grid is the canonical commitment record now.
|
||||||
|
-- * feature_requests — backed the in-app Feedback page, which was removed.
|
||||||
|
--
|
||||||
|
-- The never-hard-delete policy STILL STANDS for all real CRM and thesis data — this
|
||||||
|
-- is a deliberate, documented exception for two empty, retired tables so they don't
|
||||||
|
-- linger as dead schema. init_db() no longer creates either table, and migration
|
||||||
|
-- 0001's lp_profiles ALTER was removed, so a fresh DB never creates them and this
|
||||||
|
-- DROP is a harmless no-op there; on the live box it removes the existing empties.
|
||||||
|
DROP TABLE IF EXISTS lp_profiles;
|
||||||
|
DROP TABLE IF EXISTS feature_requests;
|
||||||
@@ -11,8 +11,8 @@ What it builds (into a SEPARATE dev DB, never crm.db):
|
|||||||
core migration (backend/migrations/), so the canonical/interaction/graph
|
core migration (backend/migrations/), so the canonical/interaction/graph
|
||||||
tables exist.
|
tables exist.
|
||||||
* A classic-model dataset: organizations, contacts (investors + prospects),
|
* A classic-model dataset: organizations, contacts (investors + prospects),
|
||||||
opportunities across pipeline stages, communications with entity-rich prose
|
opportunities across pipeline stages, and communications with entity-rich
|
||||||
notes, and lp_profiles.
|
prose notes.
|
||||||
* A fundraising grid (fundraising_state.grid_json) populated via the real
|
* A fundraising grid (fundraising_state.grid_json) populated via the real
|
||||||
sync_fundraising_relational() code path, so the normalized mirror + the
|
sync_fundraising_relational() code path, so the normalized mirror + the
|
||||||
grid->classic bridge behave exactly as in production.
|
grid->classic bridge behave exactly as in production.
|
||||||
@@ -179,7 +179,7 @@ def main():
|
|||||||
f"Prospect sourced via {random.choice(['X DM', 'warm intro', 'podcast'])}.", uid, now()))
|
f"Prospect sourced via {random.choice(['X DM', 'warm intro', 'podcast'])}.", uid, now()))
|
||||||
contacts.append((cid, first, last, org_name, "prospect"))
|
contacts.append((cid, first, last, org_name, "prospect"))
|
||||||
|
|
||||||
# ── opportunities + lp_profiles + communications ──
|
# ── opportunities + communications ──
|
||||||
stages = server.PIPELINE_STAGES
|
stages = server.PIPELINE_STAGES
|
||||||
for idx, (cid, first, last, org_name, ctype) in enumerate(contacts):
|
for idx, (cid, first, last, org_name, ctype) in enumerate(contacts):
|
||||||
person = f"{first} {last}"
|
person = f"{first} {last}"
|
||||||
@@ -199,19 +199,6 @@ def main():
|
|||||||
random.choice(["Send deck", "Schedule call", "Await IC", "Send subdocs"]),
|
random.choice(["Send deck", "Schedule call", "Await IC", "Send subdocs"]),
|
||||||
uid, random.choice(["low", "medium", "high"]), now()))
|
uid, random.choice(["low", "medium", "high"]), now()))
|
||||||
|
|
||||||
# lp_profile for ~closed investors
|
|
||||||
if ctype == "investor" and idx % 2 == 0:
|
|
||||||
amt = random.choice(AMOUNTS)
|
|
||||||
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, k1_sent, notes, updated_at) "
|
|
||||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
||||||
(gen(), cid, amt, amt if idx % 4 == 0 else 0, past(120),
|
|
||||||
random.choice(list(FUND_LABELS.values())),
|
|
||||||
random.choice(["family_office", "institutional", "endowment", "individual"]),
|
|
||||||
1, 1 if idx % 3 else 0, 1 if idx % 4 == 0 else 0, 0,
|
|
||||||
f"Closed LP. Accreditation on file. Primary contact {person}.", now()))
|
|
||||||
|
|
||||||
# 2-4 communications each, entity-rich prose
|
# 2-4 communications each, entity-rich prose
|
||||||
for k in range(random.randint(2, 4)):
|
for k in range(random.randint(2, 4)):
|
||||||
ctype_comm, subj, body = random.choice(COMM_TEMPLATES)
|
ctype_comm, subj, body = random.choice(COMM_TEMPLATES)
|
||||||
@@ -275,7 +262,7 @@ def main():
|
|||||||
|
|
||||||
print(f"\nSynthetic dev DB written to: {db}")
|
print(f"\nSynthetic dev DB written to: {db}")
|
||||||
print(" Classic model:")
|
print(" Classic model:")
|
||||||
for t in ("organizations", "contacts", "opportunities", "communications", "lp_profiles"):
|
for t in ("organizations", "contacts", "opportunities", "communications"):
|
||||||
print(f" {t:<24} {count(t)}")
|
print(f" {t:<24} {count(t)}")
|
||||||
print(" Fundraising grid (after real sync):")
|
print(" Fundraising grid (after real sync):")
|
||||||
for t in ("fundraising_investors", "fundraising_contacts", "fundraising_funds",
|
for t in ("fundraising_investors", "fundraising_contacts", "fundraising_funds",
|
||||||
|
|||||||
+95
-137
@@ -215,26 +215,6 @@ def init_db():
|
|||||||
updated_at TEXT DEFAULT (datetime('now'))
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS lp_profiles (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
contact_id TEXT NOT NULL UNIQUE REFERENCES contacts(id) ON DELETE CASCADE,
|
|
||||||
commitment_amount REAL DEFAULT 0,
|
|
||||||
funded_amount REAL DEFAULT 0,
|
|
||||||
commitment_date TEXT,
|
|
||||||
fund_name TEXT,
|
|
||||||
investor_type TEXT,
|
|
||||||
accredited INTEGER DEFAULT 0,
|
|
||||||
legal_docs_signed INTEGER DEFAULT 0,
|
|
||||||
signed_date TEXT,
|
|
||||||
wire_received INTEGER DEFAULT 0,
|
|
||||||
wire_date TEXT,
|
|
||||||
k1_sent INTEGER DEFAULT 0,
|
|
||||||
preferred_communication TEXT DEFAULT 'email',
|
|
||||||
notes TEXT,
|
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS custom_fields (
|
CREATE TABLE IF NOT EXISTS custom_fields (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
@@ -273,20 +253,6 @@ def init_db():
|
|||||||
created_at TEXT DEFAULT (datetime('now'))
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS feature_requests (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
page TEXT,
|
|
||||||
category TEXT DEFAULT 'general',
|
|
||||||
priority TEXT DEFAULT 'medium',
|
|
||||||
status TEXT DEFAULT 'new',
|
|
||||||
requested_by TEXT,
|
|
||||||
requested_by_user_id TEXT REFERENCES users(id),
|
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS fundraising_state (
|
CREATE TABLE IF NOT EXISTS fundraising_state (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
grid_json TEXT NOT NULL,
|
grid_json TEXT NOT NULL,
|
||||||
@@ -422,9 +388,6 @@ def init_db():
|
|||||||
CREATE INDEX IF NOT EXISTS idx_communications_contact ON communications(contact_id);
|
CREATE INDEX IF NOT EXISTS idx_communications_contact ON communications(contact_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_communications_date ON communications(communication_date);
|
CREATE INDEX IF NOT EXISTS idx_communications_date ON communications(communication_date);
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log(entity_type, entity_id);
|
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log(entity_type, entity_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_lp_profiles_contact ON lp_profiles(contact_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_feature_requests_status ON feature_requests(status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_feature_requests_created_at ON feature_requests(created_at);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_fr_investor_name ON fundraising_investors(investor_name);
|
CREATE INDEX IF NOT EXISTS idx_fr_investor_name ON fundraising_investors(investor_name);
|
||||||
CREATE INDEX IF NOT EXISTS idx_fr_investor_lead ON fundraising_investors(lead);
|
CREATE INDEX IF NOT EXISTS idx_fr_investor_lead ON fundraising_investors(lead);
|
||||||
CREATE INDEX IF NOT EXISTS idx_fr_contacts_investor ON fundraising_contacts(investor_id);
|
CREATE INDEX IF NOT EXISTS idx_fr_contacts_investor ON fundraising_contacts(investor_id);
|
||||||
@@ -2381,10 +2344,6 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
if path == '/api/export/contacts':
|
if path == '/api/export/contacts':
|
||||||
return self.handle_export_contacts(user, params)
|
return self.handle_export_contacts(user, params)
|
||||||
|
|
||||||
# Feature requests
|
|
||||||
if path == '/api/feature-requests':
|
|
||||||
return self.handle_list_feature_requests(user, params)
|
|
||||||
|
|
||||||
# Fundraising grid state
|
# Fundraising grid state
|
||||||
if path == '/api/fundraising/state':
|
if path == '/api/fundraising/state':
|
||||||
return self.handle_get_fundraising_state(user)
|
return self.handle_get_fundraising_state(user)
|
||||||
@@ -2398,6 +2357,8 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
return self.handle_get_backup_policy(user)
|
return self.handle_get_backup_policy(user)
|
||||||
if path == '/api/admin/digest/policy':
|
if path == '/api/admin/digest/policy':
|
||||||
return self.handle_get_digest_policy(user)
|
return self.handle_get_digest_policy(user)
|
||||||
|
if path == '/api/admin/soft-deleted':
|
||||||
|
return self.handle_list_soft_deleted(user)
|
||||||
if path == '/api/fundraising/relational-summary':
|
if path == '/api/fundraising/relational-summary':
|
||||||
return self.handle_get_fundraising_relational_summary(user)
|
return self.handle_get_fundraising_relational_summary(user)
|
||||||
if path == '/api/fundraising/automations':
|
if path == '/api/fundraising/automations':
|
||||||
@@ -2503,8 +2464,6 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
return self.handle_create_communication(user, body)
|
return self.handle_create_communication(user, body)
|
||||||
if path == '/api/import/csv':
|
if path == '/api/import/csv':
|
||||||
return self.handle_import_csv(user, body)
|
return self.handle_import_csv(user, body)
|
||||||
if path == '/api/feature-requests':
|
|
||||||
return self.handle_create_feature_request(user, body)
|
|
||||||
if path == '/api/fundraising/log-communication':
|
if path == '/api/fundraising/log-communication':
|
||||||
return self.handle_log_fundraising_communication(user, body)
|
return self.handle_log_fundraising_communication(user, body)
|
||||||
if path == '/api/fundraising/update-row':
|
if path == '/api/fundraising/update-row':
|
||||||
@@ -2525,6 +2484,8 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
return self.handle_admin_create_user(user, body)
|
return self.handle_admin_create_user(user, body)
|
||||||
if path == '/api/admin/reset-all-data':
|
if path == '/api/admin/reset-all-data':
|
||||||
return self.handle_admin_reset_all_data(user, body)
|
return self.handle_admin_reset_all_data(user, body)
|
||||||
|
if path == '/api/admin/soft-deleted/purge':
|
||||||
|
return self.handle_purge_soft_deleted(user, body)
|
||||||
if path == '/api/admin/digest/test-email':
|
if path == '/api/admin/digest/test-email':
|
||||||
return self.handle_admin_send_test_email(user, body)
|
return self.handle_admin_send_test_email(user, body)
|
||||||
if path == '/api/admin/digest/send-now':
|
if path == '/api/admin/digest/send-now':
|
||||||
@@ -2623,9 +2584,6 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
if re.match(r'^/api/opportunities/[^/]+/stage$', path):
|
if re.match(r'^/api/opportunities/[^/]+/stage$', path):
|
||||||
opp_id = path.split('/')[-2]
|
opp_id = path.split('/')[-2]
|
||||||
return self.handle_update_stage(user, opp_id, body)
|
return self.handle_update_stage(user, opp_id, body)
|
||||||
if re.match(r'^/api/feature-requests/[^/]+$', path):
|
|
||||||
fr_id = path.split('/')[-1]
|
|
||||||
return self.handle_update_feature_request(user, fr_id, body)
|
|
||||||
if re.match(r'^/api/reminders/[^/]+$', path):
|
if re.match(r'^/api/reminders/[^/]+$', path):
|
||||||
return self.handle_update_reminder(user, path.split('/')[-1], body)
|
return self.handle_update_reminder(user, path.split('/')[-1], body)
|
||||||
if re.match(r'^/api/admin/users/[^/]+$', path):
|
if re.match(r'^/api/admin/users/[^/]+$', path):
|
||||||
@@ -2963,12 +2921,11 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
_sync_contact_to_fundraising_state(conn, row_to_dict(existing), actor_user_id=user['user_id'], remove=True)
|
_sync_contact_to_fundraising_state(conn, row_to_dict(existing), actor_user_id=user['user_id'], remove=True)
|
||||||
# Soft-delete (guardrail #3 — never hard-delete): mark deleted_at and
|
# Soft-delete (guardrail #3 — never hard-delete): mark deleted_at and
|
||||||
# cascade to the contact's opportunities, communications, and lp_profile.
|
# cascade to the contact's opportunities and communications.
|
||||||
_ts = now()
|
_ts = now()
|
||||||
conn.execute("UPDATE contacts SET deleted_at = ?, updated_at = ? WHERE id = ?", (_ts, _ts, contact_id))
|
conn.execute("UPDATE contacts SET deleted_at = ?, updated_at = ? WHERE id = ?", (_ts, _ts, contact_id))
|
||||||
conn.execute("UPDATE opportunities SET deleted_at = ? WHERE contact_id = ? AND deleted_at IS NULL", (_ts, contact_id))
|
conn.execute("UPDATE opportunities SET deleted_at = ? WHERE contact_id = ? AND deleted_at IS NULL", (_ts, contact_id))
|
||||||
conn.execute("UPDATE communications SET deleted_at = ? WHERE contact_id = ? AND deleted_at IS NULL", (_ts, contact_id))
|
conn.execute("UPDATE communications SET deleted_at = ? WHERE contact_id = ? AND deleted_at IS NULL", (_ts, contact_id))
|
||||||
conn.execute("UPDATE lp_profiles SET deleted_at = ? WHERE contact_id = ? AND deleted_at IS NULL", (_ts, contact_id))
|
|
||||||
log_audit(conn, user['user_id'], 'contact', contact_id, 'delete')
|
log_audit(conn, user['user_id'], 'contact', contact_id, 'delete')
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -4821,6 +4778,96 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
# ─── Soft-deleted purge (admin maintenance) ──────────────────────────────────
|
||||||
|
# Lists soft-deleted rows and HARD-deletes them — a deliberate, admin-only, type-to-confirm
|
||||||
|
# exception to the never-hard-delete rule, for clearing out dummy/test data. A purge can only
|
||||||
|
# ever touch a soft-deleted row, and refuses any contact/org whose delete would CASCADE or
|
||||||
|
# SET NULL onto LIVE data, so it can never remove or mutate a live record.
|
||||||
|
_PURGE_TABLES = ('contacts', 'organizations', 'opportunities', 'communications')
|
||||||
|
|
||||||
|
def handle_list_soft_deleted(self, user):
|
||||||
|
if not require_admin(user):
|
||||||
|
return self.send_error_json("Admin required", 403)
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
groups = {}
|
||||||
|
groups['contacts'] = [
|
||||||
|
{"id": r["id"],
|
||||||
|
"label": (f"{r['first_name'] or ''} {r['last_name'] or ''}".strip() or r["email"] or r["id"]),
|
||||||
|
"deleted_at": r["deleted_at"]}
|
||||||
|
for r in conn.execute(
|
||||||
|
"SELECT id, first_name, last_name, email, deleted_at FROM contacts "
|
||||||
|
"WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").fetchall()]
|
||||||
|
groups['organizations'] = [
|
||||||
|
{"id": r["id"], "label": (r["name"] or r["id"]), "deleted_at": r["deleted_at"]}
|
||||||
|
for r in conn.execute(
|
||||||
|
"SELECT id, name, deleted_at FROM organizations "
|
||||||
|
"WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").fetchall()]
|
||||||
|
groups['opportunities'] = [
|
||||||
|
{"id": r["id"], "label": (r["name"] or r["id"]), "deleted_at": r["deleted_at"]}
|
||||||
|
for r in conn.execute(
|
||||||
|
"SELECT id, name, deleted_at FROM opportunities "
|
||||||
|
"WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").fetchall()]
|
||||||
|
groups['communications'] = [
|
||||||
|
{"id": r["id"],
|
||||||
|
"label": ((r["subject"] or (r["type"] or "note"))
|
||||||
|
+ (f" · {(r['communication_date'] or '')[:10]}" if r["communication_date"] else "")),
|
||||||
|
"deleted_at": r["deleted_at"]}
|
||||||
|
for r in conn.execute(
|
||||||
|
"SELECT id, type, subject, communication_date, deleted_at FROM communications "
|
||||||
|
"WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").fetchall()]
|
||||||
|
return self.send_json({"groups": groups, "total": sum(len(v) for v in groups.values())})
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def handle_purge_soft_deleted(self, user, body):
|
||||||
|
if not require_admin(user):
|
||||||
|
return self.send_error_json("Admin required", 403)
|
||||||
|
body = body or {}
|
||||||
|
table = str(body.get('table') or '').strip()
|
||||||
|
row_id = str(body.get('id') or '').strip()
|
||||||
|
if table not in self._PURGE_TABLES: # validated -> safe to interpolate below
|
||||||
|
return self.send_error_json("Unknown table", 400)
|
||||||
|
if not row_id:
|
||||||
|
return self.send_error_json("id is required", 400)
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
row = conn.execute(f"SELECT id, deleted_at FROM {table} WHERE id = ?", (row_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return self.send_error_json("Not found", 404)
|
||||||
|
if not row["deleted_at"]:
|
||||||
|
return self.send_error_json("Only soft-deleted rows can be purged", 400)
|
||||||
|
# A contacts/organizations delete cascades / SET NULLs onto children. Refuse if any LIVE
|
||||||
|
# child exists so a purge can never touch live data (a soft-deleted parent's children were
|
||||||
|
# soft-deleted with it, so normally there are none).
|
||||||
|
if table == 'contacts':
|
||||||
|
live = conn.execute(
|
||||||
|
"SELECT (SELECT COUNT(*) FROM opportunities WHERE contact_id=? AND deleted_at IS NULL) + "
|
||||||
|
"(SELECT COUNT(*) FROM communications WHERE contact_id=? AND deleted_at IS NULL) AS n",
|
||||||
|
(row_id, row_id)).fetchone()["n"]
|
||||||
|
if live:
|
||||||
|
return self.send_error_json("This contact still has live communications or opportunities — cannot purge", 409)
|
||||||
|
# Drop the optional logical FKs that have no ON DELETE (so a purged contact leaves no
|
||||||
|
# dangling reference): the derived grid link (fundraising_contacts.contact_id, migration
|
||||||
|
# 0004) and any reminder's contact_id (migration 0006 — the reminder's real link is
|
||||||
|
# investor_id, which is unaffected). Both are bare TEXT columns, not declared FKs.
|
||||||
|
conn.execute("UPDATE fundraising_contacts SET contact_id = NULL WHERE contact_id = ?", (row_id,))
|
||||||
|
conn.execute("UPDATE reminders SET contact_id = NULL WHERE contact_id = ?", (row_id,))
|
||||||
|
elif table == 'organizations':
|
||||||
|
live = conn.execute(
|
||||||
|
"SELECT (SELECT COUNT(*) FROM contacts WHERE organization_id=? AND deleted_at IS NULL) + "
|
||||||
|
"(SELECT COUNT(*) FROM opportunities WHERE organization_id=? AND deleted_at IS NULL) AS n",
|
||||||
|
(row_id, row_id)).fetchone()["n"]
|
||||||
|
if live:
|
||||||
|
return self.send_error_json("This organization is still linked to live contacts or opportunities — cannot purge", 409)
|
||||||
|
# CASCADE removes the (soft-deleted) children for contacts; the rest are leaves.
|
||||||
|
conn.execute(f"DELETE FROM {table} WHERE id = ?", (row_id,))
|
||||||
|
log_audit(conn, user['user_id'], table, row_id, 'purge', {"table": table})
|
||||||
|
conn.commit()
|
||||||
|
return self.send_json({"data": {"purged": True, "table": table, "id": row_id}})
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def handle_decide_activity_proposal(self, user, proposal_id, decision, body):
|
def handle_decide_activity_proposal(self, user, proposal_id, decision, body):
|
||||||
if not require_admin(user):
|
if not require_admin(user):
|
||||||
return self.send_error_json("Admin required", 403)
|
return self.send_error_json("Admin required", 403)
|
||||||
@@ -5393,10 +5440,8 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
conn.execute("DELETE FROM communications")
|
conn.execute("DELETE FROM communications")
|
||||||
conn.execute("DELETE FROM opportunities")
|
conn.execute("DELETE FROM opportunities")
|
||||||
conn.execute("DELETE FROM lp_profiles")
|
|
||||||
conn.execute("DELETE FROM custom_field_values")
|
conn.execute("DELETE FROM custom_field_values")
|
||||||
conn.execute("DELETE FROM custom_fields")
|
conn.execute("DELETE FROM custom_fields")
|
||||||
conn.execute("DELETE FROM feature_requests")
|
|
||||||
conn.execute("DELETE FROM contacts")
|
conn.execute("DELETE FROM contacts")
|
||||||
conn.execute("DELETE FROM organizations")
|
conn.execute("DELETE FROM organizations")
|
||||||
|
|
||||||
@@ -5882,93 +5927,6 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
|
||||||
# FEATURE REQUESTS
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def handle_list_feature_requests(self, user, params):
|
|
||||||
conn = get_db()
|
|
||||||
query = """
|
|
||||||
SELECT fr.*, u.full_name as requested_by_name
|
|
||||||
FROM feature_requests fr
|
|
||||||
LEFT JOIN users u ON fr.requested_by_user_id = u.id
|
|
||||||
WHERE 1=1
|
|
||||||
"""
|
|
||||||
args = []
|
|
||||||
|
|
||||||
if params.get('status'):
|
|
||||||
query += " AND fr.status = ?"
|
|
||||||
args.append(params['status'])
|
|
||||||
|
|
||||||
if params.get('search'):
|
|
||||||
search = f"%{params['search']}%"
|
|
||||||
query += " AND (fr.title LIKE ? OR fr.description LIKE ? OR fr.requested_by LIKE ?)"
|
|
||||||
args.extend([search, search, search])
|
|
||||||
|
|
||||||
query += " ORDER BY fr.created_at DESC"
|
|
||||||
rows = rows_to_list(conn.execute(query, args).fetchall())
|
|
||||||
conn.close()
|
|
||||||
return self.send_json({"data": rows, "total": len(rows)})
|
|
||||||
|
|
||||||
def handle_create_feature_request(self, user, body):
|
|
||||||
title = str(body.get('title', '')).strip()
|
|
||||||
if not title:
|
|
||||||
return self.send_error_json("title is required")
|
|
||||||
|
|
||||||
req_id = generate_id()
|
|
||||||
requested_by = str(body.get('requested_by') or user.get('username') or '').strip()
|
|
||||||
conn = get_db()
|
|
||||||
conn.execute("""
|
|
||||||
INSERT INTO feature_requests (
|
|
||||||
id, title, description, page, category, priority, status,
|
|
||||||
requested_by, requested_by_user_id
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
""", (
|
|
||||||
req_id,
|
|
||||||
title,
|
|
||||||
body.get('description'),
|
|
||||||
body.get('page'),
|
|
||||||
body.get('category', 'general'),
|
|
||||||
body.get('priority', 'medium'),
|
|
||||||
body.get('status', 'new'),
|
|
||||||
requested_by,
|
|
||||||
user['user_id']
|
|
||||||
))
|
|
||||||
log_audit(conn, user['user_id'], 'feature_request', req_id, 'create', {"title": title})
|
|
||||||
conn.commit()
|
|
||||||
row = row_to_dict(conn.execute("SELECT * FROM feature_requests WHERE id = ?", (req_id,)).fetchone())
|
|
||||||
conn.close()
|
|
||||||
return self.send_json({"data": row}, 201)
|
|
||||||
|
|
||||||
def handle_update_feature_request(self, user, req_id, body):
|
|
||||||
conn = get_db()
|
|
||||||
existing = conn.execute("SELECT * FROM feature_requests WHERE id = ?", (req_id,)).fetchone()
|
|
||||||
if not existing:
|
|
||||||
conn.close()
|
|
||||||
return self.send_error_json("Feature request not found", 404)
|
|
||||||
|
|
||||||
updatable = ['title', 'description', 'page', 'category', 'priority', 'status', 'requested_by']
|
|
||||||
sets = []
|
|
||||||
args = []
|
|
||||||
for field in updatable:
|
|
||||||
if field in body:
|
|
||||||
sets.append(f"{field} = ?")
|
|
||||||
args.append(body[field])
|
|
||||||
|
|
||||||
if not sets:
|
|
||||||
conn.close()
|
|
||||||
return self.send_error_json("No fields to update")
|
|
||||||
|
|
||||||
sets.append("updated_at = ?")
|
|
||||||
args.append(now())
|
|
||||||
args.append(req_id)
|
|
||||||
conn.execute(f"UPDATE feature_requests SET {', '.join(sets)} WHERE id = ?", args)
|
|
||||||
log_audit(conn, user['user_id'], 'feature_request', req_id, 'update', body)
|
|
||||||
conn.commit()
|
|
||||||
row = row_to_dict(conn.execute("SELECT * FROM feature_requests WHERE id = ?", (req_id,)).fetchone())
|
|
||||||
conn.close()
|
|
||||||
return self.send_json({"data": row})
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# FUNDRAISING STATE (AIRTABLE-LIKE GRID)
|
# FUNDRAISING STATE (AIRTABLE-LIKE GRID)
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Test for the admin soft-deleted purge (v0.1.0:104).
|
||||||
|
|
||||||
|
The purge is a deliberate, admin-only, type-to-confirm exception to never-hard-delete, for
|
||||||
|
clearing dummy/test data. It must be SAFE: only ever touch a soft-deleted row, and never
|
||||||
|
remove or mutate LIVE data via a cascade/SET-NULL. This boots the real server, seeds live +
|
||||||
|
soft-deleted graphs, and drives /api/admin/soft-deleted[/purge] over HTTP. Synthetic only.
|
||||||
|
|
||||||
|
Run: cd backend && python3 test_purge_soft_deleted.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 = []
|
||||||
|
DEL = "2026-06-01T00:00:00"
|
||||||
|
|
||||||
|
|
||||||
|
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 _post(port, path, token, payload):
|
||||||
|
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
|
||||||
|
conn.request("POST", path, body=json.dumps(payload),
|
||||||
|
headers={"Authorization": "Bearer " + token, "Content-Type": "application/json"})
|
||||||
|
resp = conn.getresponse()
|
||||||
|
body = resp.read().decode("utf-8", "replace")
|
||||||
|
conn.close()
|
||||||
|
try:
|
||||||
|
return resp.status, (json.loads(body) if body else None)
|
||||||
|
except ValueError:
|
||||||
|
return resp.status, None
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
try:
|
||||||
|
return resp.status, (json.loads(body) if body else None)
|
||||||
|
except ValueError:
|
||||||
|
return resp.status, None
|
||||||
|
|
||||||
|
|
||||||
|
def exists(table, rid):
|
||||||
|
c = sqlite3.connect(os.environ["CRM_DB_PATH"])
|
||||||
|
n = c.execute(f"SELECT COUNT(*) FROM {table} WHERE id = ?", (rid,)).fetchone()[0]
|
||||||
|
c.close()
|
||||||
|
return n > 0
|
||||||
|
|
||||||
|
|
||||||
|
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)")
|
||||||
|
# Soft-deleted contact with ONLY soft-deleted children -> purgeable; cascade should remove them.
|
||||||
|
c.execute("INSERT INTO contacts (id,first_name,last_name,deleted_at) VALUES ('cClean','Dummy','Clean',?)", (DEL,))
|
||||||
|
c.execute("INSERT INTO opportunities (id,name,contact_id,owner_id,deleted_at) VALUES ('opC','Opp','cClean','u1',?)", (DEL,))
|
||||||
|
c.execute("INSERT INTO communications (id,contact_id,communication_date,created_by,subject,deleted_at) VALUES ('cmC','cClean','2026-05-01','u1','note',?)", (DEL,))
|
||||||
|
# A reminder pointing at the purge target (reminders.contact_id is a bare logical FK, no ON DELETE):
|
||||||
|
# the purge must NULL it, not leave it dangling and not delete the reminder.
|
||||||
|
c.execute("INSERT INTO reminders (id,contact_id,investor_id,title) VALUES ('remC','cClean','inv-x','Follow up dummy')")
|
||||||
|
# Soft-deleted contact WITH a live child -> must refuse (cascade would kill live data).
|
||||||
|
c.execute("INSERT INTO contacts (id,first_name,last_name,deleted_at) VALUES ('cLiveKid','Has','Livekid',?)", (DEL,))
|
||||||
|
c.execute("INSERT INTO communications (id,contact_id,communication_date,created_by,subject) VALUES ('cmLive','cLiveKid','2026-05-02','u1','live note')")
|
||||||
|
# A live contact -> must refuse (not soft-deleted).
|
||||||
|
c.execute("INSERT INTO contacts (id,first_name,last_name) VALUES ('cLive','Real','Person')")
|
||||||
|
# Soft-deleted org with no live refs -> purgeable.
|
||||||
|
c.execute("INSERT INTO organizations (id,name,deleted_at) VALUES ('orgClean','Dead Org',?)", (DEL,))
|
||||||
|
# Soft-deleted org referenced by a LIVE contact -> must refuse (SET NULL would mutate live data).
|
||||||
|
c.execute("INSERT INTO organizations (id,name,deleted_at) VALUES ('orgRef','Ref Org',?)", (DEL,))
|
||||||
|
c.execute("INSERT INTO contacts (id,first_name,last_name,organization_id) VALUES ('cRef','Org','Member','orgRef')")
|
||||||
|
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[list soft-deleted]")
|
||||||
|
st, body = _get(port, "/api/admin/soft-deleted", token)
|
||||||
|
groups = (body or {}).get("groups", {})
|
||||||
|
cids = {x["id"] for x in groups.get("contacts", [])}
|
||||||
|
oids = {x["id"] for x in groups.get("organizations", [])}
|
||||||
|
check(st == 200, f"GET soft-deleted -> 200 (got {st})")
|
||||||
|
check({"cClean", "cLiveKid"} <= cids and "cLive" not in cids, f"lists soft-deleted contacts only (got {cids})")
|
||||||
|
check({"orgClean", "orgRef"} <= oids, f"lists soft-deleted orgs (got {oids})")
|
||||||
|
check("opC" in {x["id"] for x in groups.get("opportunities", [])}, "lists the soft-deleted opportunity")
|
||||||
|
|
||||||
|
print("\n[purge guards]")
|
||||||
|
st, _ = _post(port, "/api/admin/soft-deleted/purge", token, {"table": "contacts", "id": "cLive"})
|
||||||
|
check(st == 400, f"purge a LIVE contact -> 400 (got {st})")
|
||||||
|
check(exists("contacts", "cLive"), "live contact still present after refused purge")
|
||||||
|
st, _ = _post(port, "/api/admin/soft-deleted/purge", token, {"table": "contacts", "id": "cLiveKid"})
|
||||||
|
check(st == 409, f"purge contact with a LIVE child -> 409 (got {st})")
|
||||||
|
check(exists("contacts", "cLiveKid") and exists("communications", "cmLive"), "contact + its live child preserved")
|
||||||
|
st, _ = _post(port, "/api/admin/soft-deleted/purge", token, {"table": "organizations", "id": "orgRef"})
|
||||||
|
check(st == 409, f"purge org referenced by a LIVE contact -> 409 (got {st})")
|
||||||
|
check(exists("organizations", "orgRef") and exists("contacts", "cRef"), "org + its live referencing contact preserved")
|
||||||
|
st, _ = _post(port, "/api/admin/soft-deleted/purge", token, {"table": "bogus", "id": "x"})
|
||||||
|
check(st == 400, f"unknown table -> 400 (got {st})")
|
||||||
|
st, _ = _post(port, "/api/admin/soft-deleted/purge", token, {"table": "contacts", "id": "nope"})
|
||||||
|
check(st == 404, f"missing id -> 404 (got {st})")
|
||||||
|
|
||||||
|
print("\n[purge happy path + cascade]")
|
||||||
|
st, _ = _post(port, "/api/admin/soft-deleted/purge", token, {"table": "contacts", "id": "cClean"})
|
||||||
|
check(st == 200, f"purge a clean soft-deleted contact -> 200 (got {st})")
|
||||||
|
check(not exists("contacts", "cClean"), "purged contact is gone")
|
||||||
|
check(not exists("opportunities", "opC") and not exists("communications", "cmC"),
|
||||||
|
"its soft-deleted children were cascade-removed")
|
||||||
|
_rc = sqlite3.connect(os.environ["CRM_DB_PATH"])
|
||||||
|
_rem = _rc.execute("SELECT contact_id FROM reminders WHERE id = 'remC'").fetchone()
|
||||||
|
_rc.close()
|
||||||
|
check(_rem is not None and _rem[0] is None,
|
||||||
|
"a reminder on the purged contact is KEPT but its contact_id is NULL'd (no dangling ref)")
|
||||||
|
st, _ = _post(port, "/api/admin/soft-deleted/purge", token, {"table": "organizations", "id": "orgClean"})
|
||||||
|
check(st == 200, f"purge a clean soft-deleted org -> 200 (got {st})")
|
||||||
|
check(not exists("organizations", "orgClean"), "purged org is gone")
|
||||||
|
finally:
|
||||||
|
httpd.shutdown()
|
||||||
|
|
||||||
|
print()
|
||||||
|
if FAILS:
|
||||||
|
print(f"{len(FAILS)} FAILED")
|
||||||
|
sys.exit(1)
|
||||||
|
print("ALL PASS (soft-deleted purge)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
- **One Python file, no framework.** The whole backend is `backend/server.py` (~4,530 lines): a stdlib `http.server.ThreadingHTTPServer` with a hand-written `CRMHandler(BaseHTTPRequestHandler)` and manual path dispatch. `requirements.txt` lists FastAPI/SQLAlchemy/Alembic/Pydantic but **none are imported** — they are vestigial.
|
- **One Python file, no framework.** The whole backend is `backend/server.py` (~4,530 lines): a stdlib `http.server.ThreadingHTTPServer` with a hand-written `CRMHandler(BaseHTTPRequestHandler)` and manual path dispatch. `requirements.txt` lists FastAPI/SQLAlchemy/Alembic/Pydantic but **none are imported** — they are vestigial.
|
||||||
- **Storage is one SQLite file** (`data/crm.db`), WAL mode, opened fresh per request. Schema is created idempotently in-code at boot. There is no Alembic; "migrations" are `CREATE TABLE IF NOT EXISTS` + best-effort `ALTER TABLE ADD COLUMN`.
|
- **Storage is one SQLite file** (`data/crm.db`), WAL mode, opened fresh per request. Schema is created idempotently in-code at boot. There is no Alembic; "migrations" are `CREATE TABLE IF NOT EXISTS` + best-effort `ALTER TABLE ADD COLUMN`.
|
||||||
- **Two parallel investor data models** coexist with no shared key: (1) the *classic* `contacts / organizations / opportunities / communications / lp_profiles` CRM, and (2) the *newer, actively-used* `fundraising_*` collaborative grid. They are bridged only by fuzzy name/email matching. **This duality is the central entity-resolution problem for Phase 0.**
|
- **Two parallel investor data models** coexist with no shared key: (1) the *classic* `contacts / organizations / opportunities / communications` CRM, and (2) the *newer, actively-used* `fundraising_*` collaborative grid. They are bridged only by fuzzy name/email matching. **This duality is the central entity-resolution problem for Phase 0.** *(Note: `lp_profiles`, formerly part of the classic model, was dropped in v0.1.0:104 — migration `0008`; the grid is now canonical for commitments. The `lp_profiles` mentions later in this doc are retained as historical context for the entity-resolution discussion.)*
|
||||||
- **A real Gmail subsystem** (`backend/email_integration/`) stores threaded correspondence in `crm.db` and matches emails to investors — but is **self-disabling** (off unless a service-account key is present).
|
- **A real Gmail subsystem** (`backend/email_integration/`) stores threaded correspondence in `crm.db` and matches emails to investors — but is **self-disabling** (off unless a service-account key is present).
|
||||||
- **Auth is a single scheme:** username/password → HS256 JWT (Bearer header), re-validated against the `users` table each request; two roles (`admin`/`member`). The `X_API_KEY` named in `CLAUDE.md`/`PHASE_0.md` **does not exist in the code** — it is aspirational.
|
- **Auth is a single scheme:** username/password → HS256 JWT (Bearer header), re-validated against the `users` table each request; two roles (`admin`/`member`). The `X_API_KEY` named in `CLAUDE.md`/`PHASE_0.md` **does not exist in the code** — it is aspirational.
|
||||||
- **Guardrail flags:** all deletes are **hard deletes** (violates guardrail #3 as written); a destructive `POST /api/admin/reset-all-data` exists; `audit_log` is mutation-only and is *not* the append-only interaction log Phase 0 wants.
|
- **Guardrail flags:** all deletes are **hard deletes** (violates guardrail #3 as written); a destructive `POST /api/admin/reset-all-data` exists; `audit_log` is mutation-only and is *not* the append-only interaction log Phase 0 wants.
|
||||||
@@ -73,7 +73,7 @@ PKs are **8-char truncated UUIDs** (`generate_id()` = `str(uuid.uuid4())[:8]`, `
|
|||||||
| `organizations` | weak parent of contacts/opps | `name` (not unique), `type` (free-text, default `other`), `tags` JSON, `description`. (`backend/server.py:104`) |
|
| `organizations` | weak parent of contacts/opps | `name` (not unique), `type` (free-text, default `other`), `tags` JSON, `description`. (`backend/server.py:104`) |
|
||||||
| `contacts` | **the hub** | `first_name`/`last_name` (req), `organization_id` (FK SET NULL), `contact_type` (free-text; load-bearing values `prospect`/`investor`), `status` (default `active`), `source`, `tags` JSON, `notes`, `linkedin_url`. (`backend/server.py:123`) |
|
| `contacts` | **the hub** | `first_name`/`last_name` (req), `organization_id` (FK SET NULL), `contact_type` (free-text; load-bearing values `prospect`/`investor`), `status` (default `active`), `source`, `tags` JSON, `notes`, `linkedin_url`. (`backend/server.py:123`) |
|
||||||
| `opportunities` | deal pipeline | `contact_id` (req, FK **CASCADE**), `stage` (allowlist `PIPELINE_STAGES` at `backend/server.py:1380`, enforced **only** on the stage endpoint), `commitment_amount`, `expected_amount`, `fund_name`, `owner_id`, `lost_reason`. (`backend/server.py:148`) |
|
| `opportunities` | deal pipeline | `contact_id` (req, FK **CASCADE**), `stage` (allowlist `PIPELINE_STAGES` at `backend/server.py:1380`, enforced **only** on the stage endpoint), `commitment_amount`, `expected_amount`, `fund_name`, `owner_id`, `lost_reason`. (`backend/server.py:148`) |
|
||||||
| `lp_profiles` | closed-LP extension | 1:1 with a contact (`contact_id` UNIQUE, FK CASCADE). Holds `commitment_amount`, `funded_amount`, `accredited` (bare 0/1), `legal_docs_signed`, `wire_received`, `k1_sent`, `investor_type` (free-text). (`backend/server.py:186`) |
|
| `lp_profiles` | **RETIRED** | Dropped in v0.1.0:104 (migration `0008_drop_retired_tables`) — the table was empty; the `fundraising_*` grid is the canonical commitment record. Formerly a 1:1 closed-LP extension of a contact holding commitment/funded/accreditation fields. |
|
||||||
| `custom_fields` / `custom_field_values` | EAV custom fields | **Dead**: schema exists but has **no routes/handlers**; only ever wiped by reset. Do not build on this. (`backend/server.py:206`) |
|
| `custom_fields` / `custom_field_values` | EAV custom fields | **Dead**: schema exists but has **no routes/handlers**; only ever wiped by reset. Do not build on this. (`backend/server.py:206`) |
|
||||||
| `tags` | global tag palette | `name` UNIQUE + `color`. Not FK-linked to the per-row `tags` JSON arrays; just an autocomplete source. (`backend/server.py:237`) |
|
| `tags` | global tag palette | `name` UNIQUE + `color`. Not FK-linked to the per-row `tags` JSON arrays; just an autocomplete source. (`backend/server.py:237`) |
|
||||||
| `audit_log` | mutation diff trail | `user_id`, `entity_type`, `entity_id`, `action`, `changes` JSON. **Mutation-only**, no reads, no actor/agent dimension. (`backend/server.py:227`) |
|
| `audit_log` | mutation diff trail | `user_id`, `entity_type`, `entity_id`, `action`, `changes` JSON. **Mutation-only**, no reads, no actor/agent dimension. (`backend/server.py:227`) |
|
||||||
@@ -140,11 +140,9 @@ Full REST verbs exist (mutations are **not** tunneled through POST): `do_GET` (1
|
|||||||
| GET/POST · GET/PUT/DELETE | `/api/opportunities[/{id}]` | Opp CRUD | Bearer |
|
| GET/POST · GET/PUT/DELETE | `/api/opportunities[/{id}]` | Opp CRUD | Bearer |
|
||||||
| PATCH | `/api/opportunities/{id}/stage` | Move pipeline stage (validated) | Bearer |
|
| PATCH | `/api/opportunities/{id}/stage` | Move pipeline stage (validated) | Bearer |
|
||||||
| GET/POST · GET/PUT/DELETE | `/api/communications[/{id}]` | Comms CRUD | Bearer |
|
| GET/POST · GET/PUT/DELETE | `/api/communications[/{id}]` | Comms CRUD | Bearer |
|
||||||
| GET/POST · GET/PUT | `/api/lp-profiles[/{id}]` | LP-profile CRUD (no delete route) | Bearer |
|
| GET | `/api/reports/{dashboard,pipeline,activity}` | Aggregates | Bearer |
|
||||||
| GET | `/api/reports/{dashboard,pipeline,lp-breakdown,activity}` | Aggregates | Bearer |
|
|
||||||
| GET | `/api/export/contacts` | Export **all** contacts (returns JSON, not CSV) | Bearer |
|
| GET | `/api/export/contacts` | Export **all** contacts (returns JSON, not CSV) | Bearer |
|
||||||
| POST | `/api/import/csv` | Bulk import from JSON rows | Bearer |
|
| POST | `/api/import/csv` | Bulk import from JSON rows | Bearer |
|
||||||
| GET/POST · PATCH | `/api/feature-requests[/{id}]` | Feature-request tracker | Bearer |
|
|
||||||
| GET | `/api/users` | List users (no hashes) | Bearer |
|
| GET | `/api/users` | List users (no hashes) | Bearer |
|
||||||
| POST · PATCH | `/api/admin/users[/{id}]` | Create / update user | **Admin** |
|
| POST · PATCH | `/api/admin/users[/{id}]` | Create / update user | **Admin** |
|
||||||
| POST | `/api/admin/reset-all-data` | ⚠️ Wipe CRM (confirm phrase `RESET ALL DATA`) | **Admin** |
|
| POST | `/api/admin/reset-all-data` | ⚠️ Wipe CRM (confirm phrase `RESET ALL DATA`) | **Admin** |
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ Read this before adding or editing a schema migration or a one-time seed/backfil
|
|||||||
- **Make migrations/seeders deployment-state-invariant.** Target rows **structurally**, not by transient text the same change mutates; capture prior state so a revert is exact.
|
- **Make migrations/seeders deployment-state-invariant.** Target rows **structurally**, not by transient text the same change mutates; capture prior state so a revert is exact.
|
||||||
- *Learned the hard way:* matching old nodes by a body string the same changeset deleted broke fresh DBs. A migration must produce the same end state whether the box is empty, mid-version, or fully seeded.
|
- *Learned the hard way:* matching old nodes by a body string the same changeset deleted broke fresh DBs. A migration must produce the same end state whether the box is empty, mid-version, or fully seeded.
|
||||||
- **Soft-delete only** — `deleted_at` and/or `status='retired'`; never hard-delete CRM records or thesis history.
|
- **Soft-delete only** — `deleted_at` and/or `status='retired'`; never hard-delete CRM records or thesis history.
|
||||||
|
- **Dropping a table is forbidden by default — it needs explicit sign-off** (never-hard-delete). `0008_drop_retired_tables` (lp_profiles + feature_requests, v0.1.0:104) is the one sanctioned exception, for **empty** retired tables only. To actually drop one: (1) **remove its `CREATE TABLE` from `init_db()`** — `init_db()` runs every boot, so leaving it there re-creates the table right after the drop migration runs; (2) add a `DROP TABLE IF EXISTS` forward migration + a `.down.sql` recreating the empty shell; (3) **remove any `ALTER TABLE <dropped_table>` line from an earlier historical migration** — once `init_db()` stops creating the table, that ALTER fails `no such table` on a *fresh* DB and aborts the whole chain (it was the actual bug here). Editing that old migration is safe and deployment-state-invariant: live DBs already applied it before the drop, so the edit only affects fresh DBs, which converge to the same end state. `DROP TABLE IF EXISTS` is a no-op on a fresh DB and removes the table on the live box.
|
||||||
|
|
||||||
## Verify before shipping
|
## Verify before shipping
|
||||||
|
|
||||||
|
|||||||
+154
-485
@@ -2612,6 +2612,13 @@
|
|||||||
.bell-summary { font-size: 13px; color: var(--text-secondary); line-height: 1.45; margin: 0 0 14px; padding: 10px 12px; background: var(--bg-input); border: 1px solid var(--border); border-radius: 8px; }
|
.bell-summary { font-size: 13px; color: var(--text-secondary); line-height: 1.45; margin: 0 0 14px; padding: 10px 12px; background: var(--bg-input); border: 1px solid var(--border); border-radius: 8px; }
|
||||||
.bell-back { width: 100%; margin-top: 10px; background: transparent; border: none; color: var(--accent-light); font-size: 14px; font-family: inherit; cursor: pointer; padding: 8px; }
|
.bell-back { width: 100%; margin-top: 10px; background: transparent; border: none; color: var(--accent-light); font-size: 14px; font-family: inherit; cursor: pointer; padding: 8px; }
|
||||||
.bell-back:disabled { opacity: 0.5; cursor: default; }
|
.bell-back:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
.bell-review-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
||||||
|
.bell-reminder-btn {
|
||||||
|
flex: none; width: 34px; height: 34px; border-radius: 50%; padding: 0; cursor: pointer;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--bg-input); border: 1px solid var(--border); color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.bell-reminder-btn:active { background: var(--bg-hover); }
|
||||||
.quicklog-hint { font-size: 13px; color: var(--text-subtle); line-height: 1.5; margin: 0 0 12px; }
|
.quicklog-hint { font-size: 13px; color: var(--text-subtle); line-height: 1.5; margin: 0 0 12px; }
|
||||||
.quicklog-pool { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
|
.quicklog-pool { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
|
||||||
.quicklog-empty { font-size: 13px; color: var(--text-subtle); padding: 16px 4px; }
|
.quicklog-empty { font-size: 13px; color: var(--text-subtle); padding: 16px 4px; }
|
||||||
@@ -2993,17 +3000,6 @@
|
|||||||
return '-';
|
return '-';
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadFeatureRequests = () => {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem('venture_crm_feature_requests');
|
|
||||||
if (!raw) return [];
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
return Array.isArray(parsed) ? parsed : [];
|
|
||||||
} catch (_) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const FUNDRAISING_GRID_STORAGE_KEY = 'venture_crm_fundraising_grid_v1';
|
const FUNDRAISING_GRID_STORAGE_KEY = 'venture_crm_fundraising_grid_v1';
|
||||||
const FUNDRAISING_VIEWS_STORAGE_KEY = 'venture_crm_fundraising_views_v1';
|
const FUNDRAISING_VIEWS_STORAGE_KEY = 'venture_crm_fundraising_views_v1';
|
||||||
const FUNDRAISING_VERSION_STORAGE_KEY = 'venture_crm_fundraising_version_v1';
|
const FUNDRAISING_VERSION_STORAGE_KEY = 'venture_crm_fundraising_version_v1';
|
||||||
@@ -3134,27 +3130,13 @@
|
|||||||
{ id: 'm-3002', contact_id: 'c-1003', contact_name: 'David Martinez', type: 'call', subject: 'Follow-up call', body: 'Requested portfolio details.', communication_date: '2026-02-10T10:00:00Z', outcome: 'positive', next_action: 'Send updated deck', next_action_date: '2026-02-20' },
|
{ id: 'm-3002', contact_id: 'c-1003', contact_name: 'David Martinez', type: 'call', subject: 'Follow-up call', body: 'Requested portfolio details.', communication_date: '2026-02-10T10:00:00Z', outcome: 'positive', next_action: 'Send updated deck', next_action_date: '2026-02-20' },
|
||||||
{ id: 'm-3003', contact_id: 'c-1004', contact_name: 'Jennifer Taylor', type: 'email', subject: 'DDQ package', body: 'Sent due diligence package.', communication_date: '2026-02-11T09:00:00Z', outcome: 'neutral', next_action: 'Schedule IC call', next_action_date: '2026-02-22' }
|
{ id: 'm-3003', contact_id: 'c-1004', contact_name: 'Jennifer Taylor', type: 'email', subject: 'DDQ package', body: 'Sent due diligence package.', communication_date: '2026-02-11T09:00:00Z', outcome: 'neutral', next_action: 'Schedule IC call', next_action_date: '2026-02-22' }
|
||||||
],
|
],
|
||||||
lp_profiles: [
|
|
||||||
{ id: 'lp-4001', contact_id: 'c-1001', contact_name: 'James Chen', organization: 'Sovereign Wealth Holdings', commitment_amount: 25000000, funded_amount: 25000000, fund_name: 'Fund I', legal_docs_signed: true, wire_received: true, k1_sent: true },
|
|
||||||
{ id: 'lp-4002', contact_id: 'c-1002', contact_name: 'Sarah Williams', organization: 'Pacific Capital Partners', commitment_amount: 15000000, funded_amount: 15000000, fund_name: 'Fund I', legal_docs_signed: true, wire_received: true, k1_sent: false }
|
|
||||||
],
|
|
||||||
tags: [
|
tags: [
|
||||||
{ id: 't-5001', name: 'High Priority', color: '#ef4444' },
|
{ id: 't-5001', name: 'High Priority', color: '#ef4444' },
|
||||||
{ id: 't-5002', name: 'Fund II Prospect', color: 'var(--accent)' }
|
{ id: 't-5002', name: 'Fund II Prospect', color: 'var(--accent)' }
|
||||||
],
|
],
|
||||||
feature_requests: loadFeatureRequests(),
|
|
||||||
audit_log: []
|
audit_log: []
|
||||||
};
|
};
|
||||||
|
|
||||||
const persistFeatureRequests = () => {
|
|
||||||
if (!MOCK_MODE) return;
|
|
||||||
try {
|
|
||||||
localStorage.setItem('venture_crm_feature_requests', JSON.stringify(mockDb.feature_requests || []));
|
|
||||||
} catch (_) {
|
|
||||||
// no-op
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadMockFundraisingGrid = () => {
|
const loadMockFundraisingGrid = () => {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(FUNDRAISING_GRID_STORAGE_KEY);
|
const raw = localStorage.getItem(FUNDRAISING_GRID_STORAGE_KEY);
|
||||||
@@ -3222,15 +3204,6 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
mockDb.lp_profiles = mockDb.lp_profiles.map((lp) => {
|
|
||||||
const c = mockDb.contacts.find((x) => x.id === lp.contact_id);
|
|
||||||
return {
|
|
||||||
...lp,
|
|
||||||
contact_name: contactName(c),
|
|
||||||
organization: c?.organization_name || c?.organization || lp.organization || ''
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
mockDb.communications = mockDb.communications.map((m) => {
|
mockDb.communications = mockDb.communications.map((m) => {
|
||||||
const c = mockDb.contacts.find((x) => x.id === m.contact_id);
|
const c = mockDb.contacts.find((x) => x.id === m.contact_id);
|
||||||
return { ...m, contact_name: contactName(c) };
|
return { ...m, contact_name: contactName(c) };
|
||||||
@@ -3260,7 +3233,6 @@
|
|||||||
const metrics = {
|
const metrics = {
|
||||||
total_lps: mockDb.contacts.filter((c) => c.contact_type === 'investor').length,
|
total_lps: mockDb.contacts.filter((c) => c.contact_type === 'investor').length,
|
||||||
total_prospects: mockDb.contacts.filter((c) => c.contact_type === 'prospect').length,
|
total_prospects: mockDb.contacts.filter((c) => c.contact_type === 'prospect').length,
|
||||||
total_committed: mockDb.lp_profiles.reduce((s, lp) => s + (lp.commitment_amount || 0), 0),
|
|
||||||
pipeline_value: mockDb.opportunities.filter((o) => o.stage !== 'commitment').reduce((s, o) => s + (o.expected_amount || 0), 0),
|
pipeline_value: mockDb.opportunities.filter((o) => o.stage !== 'commitment').reduce((s, o) => s + (o.expected_amount || 0), 0),
|
||||||
active_opportunities: mockDb.opportunities.filter((o) => o.stage !== 'commitment').length,
|
active_opportunities: mockDb.opportunities.filter((o) => o.stage !== 'commitment').length,
|
||||||
comms_this_month: mockDb.communications.length
|
comms_this_month: mockDb.communications.length
|
||||||
@@ -3314,8 +3286,7 @@
|
|||||||
if (!item) throw new Error('Contact not found');
|
if (!item) throw new Error('Contact not found');
|
||||||
const opportunities = mockDb.opportunities.filter((o) => o.contact_id === id);
|
const opportunities = mockDb.opportunities.filter((o) => o.contact_id === id);
|
||||||
const communications = mockDb.communications.filter((m) => m.contact_id === id);
|
const communications = mockDb.communications.filter((m) => m.contact_id === id);
|
||||||
const lp = mockDb.lp_profiles.find((lpRow) => lpRow.contact_id === id) || null;
|
return makeResult({ data: clone({ ...item, opportunities, communications }) });
|
||||||
return makeResult({ data: clone({ ...item, opportunities, communications, lp_profile: lp }) });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^\/api\/contacts\/[^/]+$/.test(path) && method === 'DELETE') {
|
if (/^\/api\/contacts\/[^/]+$/.test(path) && method === 'DELETE') {
|
||||||
@@ -3323,7 +3294,6 @@
|
|||||||
mockDb.contacts = mockDb.contacts.filter((c) => c.id !== id);
|
mockDb.contacts = mockDb.contacts.filter((c) => c.id !== id);
|
||||||
mockDb.opportunities = mockDb.opportunities.filter((o) => o.contact_id !== id);
|
mockDb.opportunities = mockDb.opportunities.filter((o) => o.contact_id !== id);
|
||||||
mockDb.communications = mockDb.communications.filter((m) => m.contact_id !== id);
|
mockDb.communications = mockDb.communications.filter((m) => m.contact_id !== id);
|
||||||
mockDb.lp_profiles = mockDb.lp_profiles.filter((lp) => lp.contact_id !== id);
|
|
||||||
return makeResult({ message: 'Contact deleted' });
|
return makeResult({ message: 'Contact deleted' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3468,38 +3438,6 @@
|
|||||||
return makeResult({ message: 'Communication deleted' });
|
return makeResult({ message: 'Communication deleted' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/lp-profiles' && method === 'GET') {
|
|
||||||
const search = (params.get('search') || '').toLowerCase();
|
|
||||||
let rows = [...mockDb.lp_profiles];
|
|
||||||
if (search) rows = rows.filter((r) => `${r.contact_name || ''} ${r.organization || ''}`.toLowerCase().includes(search));
|
|
||||||
return makeResult({ data: clone(rows), total: rows.length });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/lp-profiles' && method === 'POST') {
|
|
||||||
const c = mockDb.contacts.find((x) => x.id === body.contact_id);
|
|
||||||
if (!c) throw new Error('Valid contact is required');
|
|
||||||
const item = {
|
|
||||||
id: `lp-${Date.now()}`,
|
|
||||||
contact_id: body.contact_id,
|
|
||||||
contact_name: contactName(c),
|
|
||||||
organization: c.organization_name || c.organization || '',
|
|
||||||
commitment_amount: Number(body.commitment_amount) || 0,
|
|
||||||
funded_amount: Number(body.funded_amount) || 0,
|
|
||||||
fund_name: body.fund_name || '',
|
|
||||||
legal_docs_signed: !!body.legal_docs_signed,
|
|
||||||
wire_received: !!body.wire_received,
|
|
||||||
k1_sent: !!body.k1_sent
|
|
||||||
};
|
|
||||||
mockDb.lp_profiles.unshift(item);
|
|
||||||
return makeResult({ data: clone(item) }, 201);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/^\/api\/lp-profiles\/[^/]+$/.test(path) && method === 'DELETE') {
|
|
||||||
const id = path.split('/').pop();
|
|
||||||
mockDb.lp_profiles = mockDb.lp_profiles.filter((lp) => lp.id !== id);
|
|
||||||
return makeResult({ message: 'LP deleted' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/import/csv' && method === 'POST') {
|
if (path === '/api/import/csv' && method === 'POST') {
|
||||||
const rows = Array.isArray(body.data) ? body.data : [];
|
const rows = Array.isArray(body.data) ? body.data : [];
|
||||||
return makeResult({ data: { created: rows.length, updated: 0, skipped: 0, errors: [] }, dry_run: !!body.dry_run });
|
return makeResult({ data: { created: rows.length, updated: 0, skipped: 0, errors: [] }, dry_run: !!body.dry_run });
|
||||||
@@ -3547,54 +3485,6 @@
|
|||||||
return makeResult({ data: { presence: [], locks: [], lock_conflict: null } });
|
return makeResult({ data: { presence: [], locks: [], lock_conflict: null } });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/feature-requests' && method === 'GET') {
|
|
||||||
const status = params.get('status') || '';
|
|
||||||
const search = (params.get('search') || '').toLowerCase();
|
|
||||||
let rows = [...mockDb.feature_requests];
|
|
||||||
if (status) rows = rows.filter((r) => r.status === status);
|
|
||||||
if (search) {
|
|
||||||
rows = rows.filter((r) => `${r.title || ''} ${r.description || ''} ${r.requested_by || ''} ${r.category || ''}`.toLowerCase().includes(search));
|
|
||||||
}
|
|
||||||
rows.sort((a, b) => (a.created_at > b.created_at ? -1 : 1));
|
|
||||||
return makeResult({ data: clone(rows), total: rows.length });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/feature-requests' && method === 'POST') {
|
|
||||||
if (!body.title || !body.title.trim()) throw new Error('Title is required');
|
|
||||||
const item = {
|
|
||||||
id: `fr-${Date.now()}`,
|
|
||||||
title: body.title.trim(),
|
|
||||||
description: (body.description || '').trim(),
|
|
||||||
category: body.category || 'other',
|
|
||||||
priority: body.priority || 'medium',
|
|
||||||
status: 'new',
|
|
||||||
requested_by: (body.requested_by || 'Unknown').trim(),
|
|
||||||
page: body.page || '',
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
updated_at: new Date().toISOString()
|
|
||||||
};
|
|
||||||
mockDb.feature_requests.unshift(item);
|
|
||||||
persistFeatureRequests();
|
|
||||||
return makeResult({ data: clone(item) }, 201);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/^\/api\/feature-requests\/[^/]+$/.test(path) && method === 'PATCH') {
|
|
||||||
const id = path.split('/').pop();
|
|
||||||
const allowed = ['status', 'priority', 'category', 'page'];
|
|
||||||
mockDb.feature_requests = mockDb.feature_requests.map((r) => {
|
|
||||||
if (r.id !== id) return r;
|
|
||||||
const next = { ...r, updated_at: new Date().toISOString() };
|
|
||||||
allowed.forEach((field) => {
|
|
||||||
if (Object.prototype.hasOwnProperty.call(body, field)) next[field] = body[field];
|
|
||||||
});
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
persistFeatureRequests();
|
|
||||||
const item = mockDb.feature_requests.find((r) => r.id === id);
|
|
||||||
if (!item) throw new Error('Feature request not found');
|
|
||||||
return makeResult({ data: clone(item) });
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Mock endpoint not implemented: ${method} ${path}`);
|
throw new Error(`Mock endpoint not implemented: ${method} ${path}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -5945,10 +5835,23 @@
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
// One fetch of the full directory (server cap 500); tab + search + sort are
|
// Page through the WHOLE directory (the server caps each page at 500 — a single
|
||||||
// applied client-side so switching is instant and needs no refetch.
|
// fetch silently truncated at 720 contacts, hiding everyone past ~"Pol" from the
|
||||||
const r = await api('/api/contacts?sort=last_name&order=asc&limit=500', {}, token);
|
// list AND from client-side search). Accumulate pages until the full set is in.
|
||||||
if (!cancelled) { setContacts(r.data || []); setError(''); }
|
// tab + search + sort stay client-side so switching needs no refetch.
|
||||||
|
const PAGE = 500;
|
||||||
|
let all = [];
|
||||||
|
let offset = 0;
|
||||||
|
for (;;) {
|
||||||
|
const r = await api(`/api/contacts?sort=last_name&order=asc&limit=${PAGE}&offset=${offset}`, {}, token);
|
||||||
|
if (cancelled) return;
|
||||||
|
const batch = r.data || [];
|
||||||
|
all = all.concat(batch);
|
||||||
|
offset += PAGE;
|
||||||
|
// Stop on a short/empty page or once we've gathered the reported total.
|
||||||
|
if (batch.length < PAGE || all.length >= Number(r.total || 0)) break;
|
||||||
|
}
|
||||||
|
if (!cancelled) { setContacts(all); setError(''); }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) setError(getErrorMessage(err, 'Failed to load contacts'));
|
if (!cancelled) setError(getErrorMessage(err, 'Failed to load contacts'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -7294,284 +7197,6 @@
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const FeatureRequestsPage = ({ token, onShowToast, user }) => {
|
|
||||||
const [requests, setRequests] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
|
||||||
const [showForm, setShowForm] = useState(false);
|
|
||||||
const [formError, setFormError] = useState('');
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
title: '',
|
|
||||||
description: '',
|
|
||||||
category: 'ui_ux',
|
|
||||||
priority: 'medium',
|
|
||||||
page: '',
|
|
||||||
requested_by: user?.full_name || user?.username || ''
|
|
||||||
});
|
|
||||||
|
|
||||||
const fetchRequests = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const result = await api(`/api/feature-requests?status=${statusFilter}&search=${encodeURIComponent(search)}`, {}, token);
|
|
||||||
setRequests(result.data || []);
|
|
||||||
} catch (err) {
|
|
||||||
onShowToast(getErrorMessage(err, 'Failed to load feature requests'), 'error');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [statusFilter, search, token, onShowToast]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchRequests();
|
|
||||||
}, [fetchRequests]);
|
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setFormError('');
|
|
||||||
try {
|
|
||||||
await api('/api/feature-requests', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(formData)
|
|
||||||
}, token);
|
|
||||||
setShowForm(false);
|
|
||||||
setFormData({
|
|
||||||
title: '',
|
|
||||||
description: '',
|
|
||||||
category: 'ui_ux',
|
|
||||||
priority: 'medium',
|
|
||||||
page: '',
|
|
||||||
requested_by: user?.full_name || user?.username || ''
|
|
||||||
});
|
|
||||||
await fetchRequests();
|
|
||||||
onShowToast('Feature request submitted', 'success');
|
|
||||||
} catch (err) {
|
|
||||||
setFormError(getErrorMessage(err, 'Failed to submit request'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStatusChange = async (id, status) => {
|
|
||||||
try {
|
|
||||||
await api(`/api/feature-requests/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify({ status })
|
|
||||||
}, token);
|
|
||||||
setRequests((prev) => prev.map((r) => (r.id === id ? { ...r, status } : r)));
|
|
||||||
} catch (err) {
|
|
||||||
onShowToast(getErrorMessage(err, 'Failed to update status'), 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePriorityChange = async (id, priority) => {
|
|
||||||
try {
|
|
||||||
await api(`/api/feature-requests/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify({ priority })
|
|
||||||
}, token);
|
|
||||||
setRequests((prev) => prev.map((r) => (r.id === id ? { ...r, priority } : r)));
|
|
||||||
} catch (err) {
|
|
||||||
onShowToast(getErrorMessage(err, 'Failed to update priority'), 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const counts = useMemo(() => ({
|
|
||||||
total: requests.length,
|
|
||||||
newCount: requests.filter((r) => r.status === 'new').length,
|
|
||||||
planned: requests.filter((r) => r.status === 'planned').length,
|
|
||||||
done: requests.filter((r) => r.status === 'done').length
|
|
||||||
}), [requests]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page-container">
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
|
||||||
<h2 className="section-title">Feature Requests</h2>
|
|
||||||
<button onClick={() => setShowForm(true)}>+ Submit Feedback</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="kpi-grid">
|
|
||||||
<div className="kpi-card">
|
|
||||||
<div className="kpi-label">Total Requests</div>
|
|
||||||
<div className="kpi-value">{counts.total}</div>
|
|
||||||
</div>
|
|
||||||
<div className="kpi-card">
|
|
||||||
<div className="kpi-label">New</div>
|
|
||||||
<div className="kpi-value">{counts.newCount}</div>
|
|
||||||
</div>
|
|
||||||
<div className="kpi-card">
|
|
||||||
<div className="kpi-label">Planned</div>
|
|
||||||
<div className="kpi-value">{counts.planned}</div>
|
|
||||||
</div>
|
|
||||||
<div className="kpi-card">
|
|
||||||
<div className="kpi-label">Done</div>
|
|
||||||
<div className="kpi-value">{counts.done}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="section">
|
|
||||||
<div className="controls">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="search-input"
|
|
||||||
placeholder="Search requests..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
className="select-input"
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">All Statuses</option>
|
|
||||||
<option value="new">New</option>
|
|
||||||
<option value="planned">Planned</option>
|
|
||||||
<option value="in_progress">In Progress</option>
|
|
||||||
<option value="done">Done</option>
|
|
||||||
<option value="wont_do">Won't Do</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<SkeletonBlock lines={6} />
|
|
||||||
) : requests.length === 0 ? (
|
|
||||||
<div className="empty-state">No feature requests yet</div>
|
|
||||||
) : (
|
|
||||||
<table className="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Title</th>
|
|
||||||
<th>Requested By</th>
|
|
||||||
<th>Category</th>
|
|
||||||
<th>Priority</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Submitted</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{requests.map((r) => (
|
|
||||||
<tr key={r.id}>
|
|
||||||
<td>
|
|
||||||
<div style={{ fontWeight: 600 }}>{r.title}</div>
|
|
||||||
{r.description && <div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '4px' }}>{r.description}</div>}
|
|
||||||
</td>
|
|
||||||
<td>{r.requested_by || '-'}</td>
|
|
||||||
<td>{(r.category || 'other').replace('_', ' ')}</td>
|
|
||||||
<td>
|
|
||||||
<select
|
|
||||||
className="select-input"
|
|
||||||
style={{ minWidth: '120px' }}
|
|
||||||
value={r.priority || 'medium'}
|
|
||||||
onChange={(e) => handlePriorityChange(r.id, e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="low">Low</option>
|
|
||||||
<option value="medium">Medium</option>
|
|
||||||
<option value="high">High</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<select
|
|
||||||
className="select-input"
|
|
||||||
style={{ minWidth: '140px' }}
|
|
||||||
value={r.status || 'new'}
|
|
||||||
onChange={(e) => handleStatusChange(r.id, e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="new">New</option>
|
|
||||||
<option value="planned">Planned</option>
|
|
||||||
<option value="in_progress">In Progress</option>
|
|
||||||
<option value="done">Done</option>
|
|
||||||
<option value="wont_do">Won't Do</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td>{formatDateLong(r.created_at)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showForm && (
|
|
||||||
<div className="modal-overlay">
|
|
||||||
<div className="modal">
|
|
||||||
<div className="modal-header">Submit Feature Request</div>
|
|
||||||
{formError && <div className="toast error" style={{ position: 'static', marginBottom: '16px' }}>{formError}</div>}
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">Title *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="text-input"
|
|
||||||
value={formData.title}
|
|
||||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">Description</label>
|
|
||||||
<textarea
|
|
||||||
className="text-input"
|
|
||||||
rows="4"
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">Category</label>
|
|
||||||
<select
|
|
||||||
className="select-input"
|
|
||||||
value={formData.category}
|
|
||||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="ui_ux">UI / UX</option>
|
|
||||||
<option value="workflow">Workflow</option>
|
|
||||||
<option value="reporting">Reporting</option>
|
|
||||||
<option value="integrations">Integrations</option>
|
|
||||||
<option value="bugs">Bug</option>
|
|
||||||
<option value="other">Other</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">Priority</label>
|
|
||||||
<select
|
|
||||||
className="select-input"
|
|
||||||
value={formData.priority}
|
|
||||||
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="low">Low</option>
|
|
||||||
<option value="medium">Medium</option>
|
|
||||||
<option value="high">High</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">Page / Area</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="text-input"
|
|
||||||
placeholder="e.g., Pipeline, LP Tracker"
|
|
||||||
value={formData.page}
|
|
||||||
onChange={(e) => setFormData({ ...formData, page: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">Requested By</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="text-input"
|
|
||||||
value={formData.requested_by}
|
|
||||||
onChange={(e) => setFormData({ ...formData, requested_by: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-actions">
|
|
||||||
<button type="button" className="button-secondary" onClick={() => setShowForm(false)}>Cancel</button>
|
|
||||||
<button type="submit">Submit</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Desktop Fundraising Grid (the spreadsheet + autosave). Unchanged; rendered on >768px via
|
// Desktop Fundraising Grid (the spreadsheet + autosave). Unchanged; rendered on >768px via
|
||||||
// the FundraisingGridPage switch at the end of this component. Mobile (<768px) renders the
|
// the FundraisingGridPage switch at the end of this component. Mobile (<768px) renders the
|
||||||
// lean MobileFundraisingGrid instead — which never whole-grid PUTs (BRIEF §3a).
|
// lean MobileFundraisingGrid instead — which never whole-grid PUTs (BRIEF §3a).
|
||||||
@@ -10999,79 +10624,77 @@
|
|||||||
return isMobile ? <MobileFundraisingGrid {...props} /> : <DesktopFundraisingGridPage {...props} />;
|
return isMobile ? <MobileFundraisingGrid {...props} /> : <DesktopFundraisingGridPage {...props} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const InstructionsPage = () => {
|
// Admin maintenance: permanently delete soft-deleted rows (dummy/test data). Deliberate,
|
||||||
|
// type-to-confirm exception to never-hard-delete; the server refuses any row that still
|
||||||
|
// links to live data, so this can only ever remove already-deleted records.
|
||||||
|
const PurgeDeletedData = ({ token, onShowToast }) => {
|
||||||
|
const [groups, setGroups] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [confirmKey, setConfirmKey] = useState(''); // `${table}:${id}` currently confirming
|
||||||
|
const [confirmText, setConfirmText] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const r = await api('/api/admin/soft-deleted', {}, token);
|
||||||
|
setGroups(r.groups || {});
|
||||||
|
} catch (err) {
|
||||||
|
onShowToast(getErrorMessage(err, 'Failed to load deleted data'), 'error');
|
||||||
|
} finally { setLoading(false); }
|
||||||
|
}, [token, onShowToast]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const purge = async (table, id) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api('/api/admin/soft-deleted/purge', { method: 'POST', body: JSON.stringify({ table, id }) }, token);
|
||||||
|
onShowToast('Permanently deleted', 'success');
|
||||||
|
setConfirmKey(''); setConfirmText('');
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
onShowToast(getErrorMessage(err, 'Could not purge'), 'error');
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const LABELS = { contacts: 'Contacts', organizations: 'Organizations', opportunities: 'Opportunities', communications: 'Communications' };
|
||||||
|
const total = groups ? Object.values(groups).reduce((s, a) => s + (a ? a.length : 0), 0) : 0;
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div style={{ marginBottom: '20px', borderBottom: '1px solid var(--border)', paddingBottom: '16px' }}>
|
||||||
<h2 className="section-title">Instructions</h2>
|
<div style={{ fontWeight: 600, marginBottom: '8px', color: 'var(--danger-text)' }}>Purge Deleted Data</div>
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginBottom: '10px' }}>
|
||||||
<div className="section">
|
Permanently remove soft-deleted rows (e.g. dummy/test records). This cannot be undone. A purge only ever touches already-deleted rows and refuses any that still link to live data.
|
||||||
<div className="section-title">Purpose</div>
|
|
||||||
<div style={{ color: 'var(--text-secondary)', fontSize: '14px', lineHeight: 1.6 }}>
|
|
||||||
Use Fundraising Grid as the master list of investor relationships, then use Contacts, Communications, and Pipeline as deeper operating layers when a relationship becomes active.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="section">
|
|
||||||
<div className="section-title">Daily Workflow</div>
|
|
||||||
<ol style={{ marginLeft: '20px', color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
|
||||||
<li>Capture new leads in Fundraising Grid first.</li>
|
|
||||||
<li>Add or verify contacts on that row (name, email, title, location).</li>
|
|
||||||
<li>Set Lead owner and relevant flags (Priority, Follow up).</li>
|
|
||||||
<li>Log communications after each meaningful touchpoint.</li>
|
|
||||||
<li>Use Next Action and Next Action Date for commitments and reminders.</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="section">
|
|
||||||
<div className="section-title">How To Add New Leads</div>
|
|
||||||
<ol style={{ marginLeft: '20px', color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
|
||||||
<li>Create a new row in Fundraising Grid.</li>
|
|
||||||
<li>Fill Investor Name and at least one contact.</li>
|
|
||||||
<li>Add context in Notes / Communication / Outreach.</li>
|
|
||||||
<li>Assign Lead and mark Priority only if truly high-attention.</li>
|
|
||||||
<li>Use Follow up for active near-term tracking views.</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="section">
|
|
||||||
<div className="section-title">Communication Logging Best Practices</div>
|
|
||||||
<ol style={{ marginLeft: '20px', color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
|
||||||
<li>Log communication from Fundraising Grid via contact chip or row right-click.</li>
|
|
||||||
<li>Always set type and a concise subject/body.</li>
|
|
||||||
<li>Use Outcome for what happened; use Next Action for what will happen.</li>
|
|
||||||
<li>If you want timeline text in grid notes, keep “Append note” checked.</li>
|
|
||||||
<li>Use Communications page to audit and manage all logged interactions.</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="section">
|
|
||||||
<div className="section-title">Priority vs Pipeline</div>
|
|
||||||
<div style={{ color: 'var(--text-secondary)', fontSize: '14px', lineHeight: 1.7 }}>
|
|
||||||
Priority is a relationship-level attention flag in the Fundraising Grid. Pipeline is for specific active opportunities with stage/probability/amount tracking. Keep Priority broad and Pipeline selective.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="section">
|
|
||||||
<div className="section-title">When An Opportunity Is Concrete</div>
|
|
||||||
<ol style={{ marginLeft: '20px', color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
|
||||||
<li>Right-click the investor row in Fundraising Grid.</li>
|
|
||||||
<li>Select <strong>Create Pipeline Opportunity</strong>.</li>
|
|
||||||
<li>Pick contact, stage, expected amount, and probability.</li>
|
|
||||||
<li>Track progress in Pipeline while keeping relationship notes in Fundraising Grid.</li>
|
|
||||||
<li>Continue logging communications so follow-ups and timelines stay current.</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="section">
|
|
||||||
<div className="section-title">Data Flow</div>
|
|
||||||
<ol style={{ marginLeft: '20px', color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
|
||||||
<li>Fundraising Grid saves to a master fundraising state and relational fundraising tables.</li>
|
|
||||||
<li>Contacts in Fundraising Grid sync bi-directionally with the Contacts database.</li>
|
|
||||||
<li>Logging communication creates a communications record and updates fundraising row dates.</li>
|
|
||||||
<li>Notes Last Modified and Last Communication Date update automatically from activity.</li>
|
|
||||||
<li>Saved Views filter the same shared master dataset, they do not duplicate records.</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" className="button-secondary" onClick={load} disabled={loading} style={{ marginBottom: '12px' }}>
|
||||||
|
{loading ? <Spinner /> : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
{groups && total === 0 && <div style={{ fontSize: '13px', color: 'var(--text-muted)' }}>No soft-deleted rows.</div>}
|
||||||
|
{groups && Object.keys(LABELS).map((tbl) => (
|
||||||
|
(groups[tbl] && groups[tbl].length > 0) ? (
|
||||||
|
<div key={tbl} style={{ marginBottom: '14px' }}>
|
||||||
|
<div style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: '6px' }}>{LABELS[tbl]} ({groups[tbl].length})</div>
|
||||||
|
{groups[tbl].map((row) => {
|
||||||
|
const key = `${tbl}:${row.id}`;
|
||||||
|
const confirming = confirmKey === key;
|
||||||
|
return (
|
||||||
|
<div key={row.id} style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 0', borderTop: '1px solid var(--border)', flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ flex: 1, minWidth: '140px', fontSize: '13px', color: 'var(--text-primary)' }}>{row.label}</span>
|
||||||
|
{confirming ? (
|
||||||
|
<>
|
||||||
|
<input type="text" className="text-input" placeholder="Type DELETE" value={confirmText} onChange={(e) => setConfirmText(e.target.value)} style={{ width: '110px' }} />
|
||||||
|
<button type="button" className="button-danger" disabled={busy || confirmText.trim().toUpperCase() !== 'DELETE'} onClick={() => purge(tbl, row.id)}>{busy ? '…' : 'Delete'}</button>
|
||||||
|
<button type="button" className="button-secondary" disabled={busy} onClick={() => { setConfirmKey(''); setConfirmText(''); }}>Cancel</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="button-secondary" onClick={() => { setConfirmKey(key); setConfirmText(''); }}>Delete permanently</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -11573,7 +11196,7 @@
|
|||||||
onShowToast(`Type exactly "${phrase}" to continue`, 'error');
|
onShowToast(`Type exactly "${phrase}" to continue`, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const confirmed = window.confirm('This will permanently clear contacts, organizations, pipeline, communications, feature requests, and reset the fundraising grid. Continue?');
|
const confirmed = window.confirm('This will permanently clear contacts, organizations, pipeline, communications, and reset the fundraising grid. Continue?');
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
setResetAllDataLoading(true);
|
setResetAllDataLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -12058,7 +11681,7 @@
|
|||||||
<div style={{ marginBottom: '20px', borderBottom: '1px solid var(--border)', paddingBottom: '16px' }}>
|
<div style={{ marginBottom: '20px', borderBottom: '1px solid var(--border)', paddingBottom: '16px' }}>
|
||||||
<div style={{ fontWeight: 600, marginBottom: '8px', color: 'var(--danger-text)' }}>Danger Zone: Reset All Data</div>
|
<div style={{ fontWeight: 600, marginBottom: '8px', color: 'var(--danger-text)' }}>Danger Zone: Reset All Data</div>
|
||||||
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginBottom: '10px' }}>
|
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginBottom: '10px' }}>
|
||||||
Clears all CRM records (contacts, organizations, opportunities, communications, feature requests) and resets fundraising grid to empty defaults.
|
Clears all CRM records (contacts, organizations, opportunities, communications) and resets fundraising grid to empty defaults.
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -12073,6 +11696,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PurgeDeletedData token={token} onShowToast={onShowToast} />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 600, marginBottom: '10px' }}>Fundraising State Ops</div>
|
<div style={{ fontWeight: 600, marginBottom: '10px' }}>Fundraising State Ops</div>
|
||||||
<div style={{ marginBottom: '12px', padding: '10px', border: '1px solid var(--border)', borderRadius: '8px' }}>
|
<div style={{ marginBottom: '12px', padding: '10px', border: '1px solid var(--border)', borderRadius: '8px' }}>
|
||||||
@@ -14469,6 +14094,9 @@
|
|||||||
const [noteDraft, setNoteDraft] = useState('');
|
const [noteDraft, setNoteDraft] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const busyRef = useRef(false); // synchronous in-flight guard (setBusy is async — a fast double-tap could double-POST)
|
const busyRef = useRef(false); // synchronous in-flight guard (setBusy is async — a fast double-tap could double-POST)
|
||||||
|
const [reminderOpen, setReminderOpen] = useState(false); // #C — inline reminder from a review log
|
||||||
|
const [reminderForm, setReminderForm] = useState({ title: '', due_date: '' });
|
||||||
|
const [reminderBusy, setReminderBusy] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -14488,7 +14116,7 @@
|
|||||||
|
|
||||||
const count = proposals ? proposals.length : 0;
|
const count = proposals ? proposals.length : 0;
|
||||||
const openSheet = () => { setSelected(null); setOpen(true); load(); };
|
const openSheet = () => { setSelected(null); setOpen(true); load(); };
|
||||||
const closeSheet = () => { setOpen(false); setSelected(null); };
|
const closeSheet = () => { setOpen(false); setSelected(null); setReminderOpen(false); };
|
||||||
const openReview = (p) => { setSelected(p); setNoteDraft(p.proposed_note || ''); };
|
const openReview = (p) => { setSelected(p); setNoteDraft(p.proposed_note || ''); };
|
||||||
|
|
||||||
const decide = async (decision) => {
|
const decide = async (decision) => {
|
||||||
@@ -14506,6 +14134,30 @@
|
|||||||
finally { setBusy(false); busyRef.current = false; }
|
finally { setBusy(false); busyRef.current = false; }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// #C — set a reminder inline from a review log (only when the proposal matched a real
|
||||||
|
// investor; the reminders POST resolves investor_id → name/grid-row server-side).
|
||||||
|
const openReminder = () => {
|
||||||
|
const subj = (selected && selected.email_subject) || '';
|
||||||
|
setReminderForm({ title: subj ? `Follow up: ${subj}` : '', due_date: reminderDefaultDue() });
|
||||||
|
setReminderOpen(true);
|
||||||
|
};
|
||||||
|
const submitReminder = async () => {
|
||||||
|
const p = selected; if (!p || !p.investor_id || reminderBusy) return;
|
||||||
|
const title = (reminderForm.title || '').trim();
|
||||||
|
if (!title) { onShowToast('A reminder needs a title', 'error'); return; }
|
||||||
|
if (!reminderForm.due_date) { onShowToast('A reminder needs a due date', 'error'); return; }
|
||||||
|
setReminderBusy(true);
|
||||||
|
try {
|
||||||
|
await api('/api/reminders', { method: 'POST', body: JSON.stringify({
|
||||||
|
investor_id: p.investor_id, investor_name: p.investor_name || '',
|
||||||
|
title, due_date: reminderForm.due_date, details: '',
|
||||||
|
}) }, token);
|
||||||
|
onShowToast('Reminder set', 'success');
|
||||||
|
setReminderOpen(false);
|
||||||
|
} catch (err) { onShowToast(getErrorMessage(err, 'Failed to set reminder'), 'error'); }
|
||||||
|
finally { setReminderBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
const dirLabel = (d) => (d === 'sent' ? 'Sent' : 'Received');
|
const dirLabel = (d) => (d === 'sent' ? 'Sent' : 'Received');
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -14519,7 +14171,16 @@
|
|||||||
<BottomSheet open={open} onClose={closeSheet} title={selected ? 'Review log' : 'Email approvals'}>
|
<BottomSheet open={open} onClose={closeSheet} title={selected ? 'Review log' : 'Email approvals'}>
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<>
|
<>
|
||||||
<div className="sheet-subcaption">{selected.investor_name || 'Unmatched investor'}</div>
|
<div className="bell-review-head">
|
||||||
|
<div className="sheet-subcaption">{selected.investor_name || 'Unmatched investor'}</div>
|
||||||
|
{selected.investor_id && (
|
||||||
|
<button className="bell-reminder-btn" type="button" onClick={openReminder} aria-label="Set a reminder" title="Set a reminder">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="13" r="8" /><path d="M12 10v3l1.5 1.5" /><path d="M5 3 2 6" /><path d="m22 6-3-3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="bell-meta">{dirLabel(selected.direction)}{selected.email_date ? ` · ${formatDateLong(selected.email_date)}` : ''}</div>
|
<div className="bell-meta">{dirLabel(selected.direction)}{selected.email_date ? ` · ${formatDateLong(selected.email_date)}` : ''}</div>
|
||||||
{selected.email_subject && <div className="bell-card-subject" style={{ marginBottom: '10px' }}>{selected.email_subject}</div>}
|
{selected.email_subject && <div className="bell-card-subject" style={{ marginBottom: '10px' }}>{selected.email_subject}</div>}
|
||||||
{selected.summary && <div className="bell-summary">{selected.summary}</div>}
|
{selected.summary && <div className="bell-summary">{selected.summary}</div>}
|
||||||
@@ -14544,6 +14205,7 @@
|
|||||||
<span className="bell-card-name">{p.investor_name || 'Unmatched investor'}</span>
|
<span className="bell-card-name">{p.investor_name || 'Unmatched investor'}</span>
|
||||||
<span className="bell-card-dir">{dirLabel(p.direction)}</span>
|
<span className="bell-card-dir">{dirLabel(p.direction)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{p.email_date && <div className="bell-meta">{formatDateLong(p.email_date)}</div>}
|
||||||
<div className="bell-card-subject">{p.email_subject || '(no subject)'}</div>
|
<div className="bell-card-subject">{p.email_subject || '(no subject)'}</div>
|
||||||
<div className="bell-card-note">{p.summary || p.proposed_note || ''}</div>
|
<div className="bell-card-note">{p.summary || p.proposed_note || ''}</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -14551,6 +14213,23 @@
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</BottomSheet>
|
</BottomSheet>
|
||||||
|
<BottomSheet open={reminderOpen} onClose={() => setReminderOpen(false)} title="Set a reminder" stacked>
|
||||||
|
{selected && (
|
||||||
|
<>
|
||||||
|
<div className="sheet-subcaption">{selected.investor_name || ''}</div>
|
||||||
|
<div className="bell-meta">Reminder for this investor</div>
|
||||||
|
<div className="sheet-field">
|
||||||
|
<label className="sheet-field-label">Reminder</label>
|
||||||
|
<input className="sheet-input" value={reminderForm.title} onChange={(e) => setReminderForm((f) => ({ ...f, title: e.target.value }))} placeholder="What to follow up on" />
|
||||||
|
</div>
|
||||||
|
<div className="sheet-field">
|
||||||
|
<label className="sheet-field-label">Due date</label>
|
||||||
|
<input className="sheet-input" type="date" value={reminderForm.due_date} onChange={(e) => setReminderForm((f) => ({ ...f, due_date: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<button className="sheet-submit" disabled={reminderBusy || !reminderForm.title.trim() || !reminderForm.due_date} onClick={submitReminder}>{reminderBusy ? 'Saving…' : 'Set reminder'}</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</BottomSheet>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -15116,12 +14795,6 @@
|
|||||||
<span className="nav-item-icon">✉</span> Email Capture
|
<span className="nav-item-icon">✉</span> Email Capture
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button className={`nav-item ${page === 'feature-requests' ? 'active' : ''}`} onClick={() => setPage('feature-requests')}>
|
|
||||||
<span className="nav-item-icon">✦</span> Feedback
|
|
||||||
</button>
|
|
||||||
<button className={`nav-item ${page === 'instructions' ? 'active' : ''}`} onClick={() => setPage('instructions')}>
|
|
||||||
<span className="nav-item-icon">ⓘ</span> Instructions
|
|
||||||
</button>
|
|
||||||
<button className={`nav-item ${page === 'settings' ? 'active' : ''}`} onClick={() => setPage('settings')}>
|
<button className={`nav-item ${page === 'settings' ? 'active' : ''}`} onClick={() => setPage('settings')}>
|
||||||
<span className="nav-item-icon">◍</span> Settings
|
<span className="nav-item-icon">◍</span> Settings
|
||||||
</button>
|
</button>
|
||||||
@@ -15149,8 +14822,6 @@
|
|||||||
{page === 'outreach' && 'Outreach'}
|
{page === 'outreach' && 'Outreach'}
|
||||||
{page === 'system-status' && 'System Status'}
|
{page === 'system-status' && 'System Status'}
|
||||||
{page === 'email-capture' && 'Email Capture'}
|
{page === 'email-capture' && 'Email Capture'}
|
||||||
{page === 'feature-requests' && 'Feature Requests'}
|
|
||||||
{page === 'instructions' && 'Instructions'}
|
|
||||||
{page === 'settings' && 'Settings'}
|
{page === 'settings' && 'Settings'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -15206,8 +14877,6 @@
|
|||||||
{page === 'outreach' && <OutreachPage token={token} user={user} onShowToast={showToast} />}
|
{page === 'outreach' && <OutreachPage token={token} user={user} onShowToast={showToast} />}
|
||||||
{page === 'system-status' && <SystemStatusPage token={token} user={user} onShowToast={showToast} />}
|
{page === 'system-status' && <SystemStatusPage token={token} user={user} onShowToast={showToast} />}
|
||||||
{page === 'email-capture' && <EmailCapturePage token={token} user={user} onShowToast={showToast} />}
|
{page === 'email-capture' && <EmailCapturePage token={token} user={user} onShowToast={showToast} />}
|
||||||
{page === 'feature-requests' && <FeatureRequestsPage token={token} onShowToast={showToast} user={user} />}
|
|
||||||
{page === 'instructions' && <InstructionsPage />}
|
|
||||||
{page === 'settings' && (
|
{page === 'settings' && (
|
||||||
<SettingsPage
|
<SettingsPage
|
||||||
token={token}
|
token={token}
|
||||||
|
|||||||
@@ -68,8 +68,9 @@ export const PACKAGE_TITLE = 'Ten31 Database'
|
|||||||
// * 0.1.0:100 (In-app business-card intake [#7]: a mobile camera button [left of the quick-log pencil] takes/picks a card photo, downscales it client-side via <canvas> to JPEG, and POSTs to the new POST /api/intake/card — vision-transcribe + parse + fuzzy-match on the box [local VL via Spark Control, nothing to Claude], reusing the Matrix card flow's nio-free parse/spark core. An editable review sheet [proposal fields + existing-investor picker] writes via log-communication tagged source="app_card"; a human approves every write. No schema change; no migration; no new dependency)
|
// * 0.1.0:100 (In-app business-card intake [#7]: a mobile camera button [left of the quick-log pencil] takes/picks a card photo, downscales it client-side via <canvas> to JPEG, and POSTs to the new POST /api/intake/card — vision-transcribe + parse + fuzzy-match on the box [local VL via Spark Control, nothing to Claude], reusing the Matrix card flow's nio-free parse/spark core. An editable review sheet [proposal fields + existing-investor picker] writes via log-communication tagged source="app_card"; a human approves every write. No schema change; no migration; no new dependency)
|
||||||
// * 0.1.0:101 (Mobile UX batch 1 [Grant device feedback]: [1] inline ✕ clear button on the Grid/Contacts search + reminder/quick-log investor pickers [ClearableInput]; [2] Grid investor-detail contact pills are tappable — name deep-links to the Contacts detail [new Grid→Contacts one-shot action], email opens mailto; [4a] mobile Pipeline is a full-height flex column so the whole area above the now bottom-pinned dots is the swipe target, each stage page scrolling its cards; [4b] expected-amount entry — optional amount when adding to the pipeline from the Grid detail [feeds pipeline/link], editable amount on the Pipeline card detail [PUT /api/opportunities/{id}]; [5] bottom sheets lift above the on-screen keyboard [visualViewport] so the reminder investor-picker results stay visible. Grid contact-name search [#3] already worked. CSS+React only; no schema change; no migration; no new dependency)
|
// * 0.1.0:101 (Mobile UX batch 1 [Grant device feedback]: [1] inline ✕ clear button on the Grid/Contacts search + reminder/quick-log investor pickers [ClearableInput]; [2] Grid investor-detail contact pills are tappable — name deep-links to the Contacts detail [new Grid→Contacts one-shot action], email opens mailto; [4a] mobile Pipeline is a full-height flex column so the whole area above the now bottom-pinned dots is the swipe target, each stage page scrolling its cards; [4b] expected-amount entry — optional amount when adding to the pipeline from the Grid detail [feeds pipeline/link], editable amount on the Pipeline card detail [PUT /api/opportunities/{id}]; [5] bottom sheets lift above the on-screen keyboard [visualViewport] so the reminder investor-picker results stay visible. Grid contact-name search [#3] already worked. CSS+React only; no schema change; no migration; no new dependency)
|
||||||
// * 0.1.0:102 (Mobile email-approval bell [#6]: an admin-only bell in the mobile top bar [left of the camera] with an iPhone-style count badge surfaces the SAME pending email-capture proposals the web "Email Capture" panel + the Matrix review room decide. Tap → card list of proposals → tap one → review screen [investor name + subject + summary + editable proposed note] → Approve & log to grid / Reject. Reuses the existing GET /api/activity/proposals + POST .../{id}/approve|dismiss [require_admin]; bidirectional sync is automatic — an app decision flips the proposal status and the bot's poll redacts the Matrix thread, while a Matrix/web decision drops the proposal from the pending list the bell polls [45s], clearing the badge. No LLM round-trip [edit-then-approve like the web panel]; mobile-gated so the hidden desktop top bar doesn't poll. Frontend-only; no schema change; no migration; no new dependency)
|
// * 0.1.0:102 (Mobile email-approval bell [#6]: an admin-only bell in the mobile top bar [left of the camera] with an iPhone-style count badge surfaces the SAME pending email-capture proposals the web "Email Capture" panel + the Matrix review room decide. Tap → card list of proposals → tap one → review screen [investor name + subject + summary + editable proposed note] → Approve & log to grid / Reject. Reuses the existing GET /api/activity/proposals + POST .../{id}/approve|dismiss [require_admin]; bidirectional sync is automatic — an app decision flips the proposal status and the bot's poll redacts the Matrix thread, while a Matrix/web decision drops the proposal from the pending list the bell polls [45s], clearing the badge. No LLM round-trip [edit-then-approve like the web panel]; mobile-gated so the hidden desktop top bar doesn't poll. Frontend-only; no schema change; no migration; no new dependency)
|
||||||
// * Current: 0.1.0:103 (Reminders require a due date [Grant feedback]: every reminder-create flow now pre-fills the due date to +1 week [editable] and blocks an empty save — a date-less reminder has no urgency [it falls to the "Later"/"No date" bucket, out of the overdue/today/this-week rollups + daily digest]. Applies to ALL create surfaces via a shared `reminderDefaultDue()` helper — mobile: the add-investor sheet [date auto-fills when you start the optional reminder], the standalone Reminders "New reminder" sheet, the Grid-detail "Set a reminder" card; desktop: the Reminders page "+ New reminder" + the grid reminder modal. Edit paths also pre-fill the default for legacy date-less reminders. Frontend-only; no schema/migration/dependency change)
|
// * 0.1.0:103 (Reminders require a due date [Grant feedback]: every reminder-create flow now pre-fills the due date to +1 week [editable] and blocks an empty save — a date-less reminder has no urgency [it falls to the "Later"/"No date" bucket, out of the overdue/today/this-week rollups + daily digest]. Applies to ALL create surfaces via a shared `reminderDefaultDue()` helper — mobile: the add-investor sheet [date auto-fills when you start the optional reminder], the standalone Reminders "New reminder" sheet, the Grid-detail "Set a reminder" card; desktop: the Reminders page "+ New reminder" + the grid reminder modal. Edit paths also pre-fill the default for legacy date-less reminders. Frontend-only; no schema/migration/dependency change)
|
||||||
export const PACKAGE_VERSION = '0.1.0:103'
|
// * Current: 0.1.0:104 (Remove the Instructions + Feedback [feature_requests] pages + backend, and retire the empty lp_profiles table + investor_type — a one-off sanctioned exception to never-hard-delete; in-app migration 0008 drops lp_profiles + feature_requests, and 0001's lp_profiles ALTER was removed so a fresh DB doesn't break the migration chain. Fixes: email sync no longer terminally parks a mailbox on a transient timeout [auto-retry + hourly backoff → stuck mailboxes self-heal]; mobile Contacts pages through ALL contacts [a single 500-row fetch truncated at 720, hiding people from the list + search]; a clock icon on the mobile email Review-log sets a reminder inline; email-approval cards show date/time. New: admin-only purge of soft-deleted rows [type-to-confirm; refuses any row still linked to live data])
|
||||||
|
export const PACKAGE_VERSION = '0.1.0:104'
|
||||||
|
|
||||||
export const DATA_MOUNT_PATH = '/data'
|
export const DATA_MOUNT_PATH = '/data'
|
||||||
export const WEB_PORT = 8080
|
export const WEB_PORT = 8080
|
||||||
|
|||||||
@@ -64,8 +64,9 @@ import { v_0_1_0_100 } from './v0.1.0.100'
|
|||||||
import { v_0_1_0_101 } from './v0.1.0.101'
|
import { v_0_1_0_101 } from './v0.1.0.101'
|
||||||
import { v_0_1_0_102 } from './v0.1.0.102'
|
import { v_0_1_0_102 } from './v0.1.0.102'
|
||||||
import { v_0_1_0_103 } from './v0.1.0.103'
|
import { v_0_1_0_103 } from './v0.1.0.103'
|
||||||
|
import { v_0_1_0_104 } from './v0.1.0.104'
|
||||||
|
|
||||||
export const versionGraph = VersionGraph.of({
|
export const versionGraph = VersionGraph.of({
|
||||||
current: v_0_1_0_103,
|
current: v_0_1_0_104,
|
||||||
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73, v_0_1_0_74, v_0_1_0_75, v_0_1_0_76, v_0_1_0_77, v_0_1_0_78, v_0_1_0_79, v_0_1_0_80, v_0_1_0_81, v_0_1_0_82, v_0_1_0_83, v_0_1_0_84, v_0_1_0_85, v_0_1_0_86, v_0_1_0_87, v_0_1_0_88, v_0_1_0_89, v_0_1_0_90, v_0_1_0_91, v_0_1_0_92, v_0_1_0_93, v_0_1_0_94, v_0_1_0_95, v_0_1_0_96, v_0_1_0_97, v_0_1_0_98, v_0_1_0_99, v_0_1_0_100, v_0_1_0_101, v_0_1_0_102],
|
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73, v_0_1_0_74, v_0_1_0_75, v_0_1_0_76, v_0_1_0_77, v_0_1_0_78, v_0_1_0_79, v_0_1_0_80, v_0_1_0_81, v_0_1_0_82, v_0_1_0_83, v_0_1_0_84, v_0_1_0_85, v_0_1_0_86, v_0_1_0_87, v_0_1_0_88, v_0_1_0_89, v_0_1_0_90, v_0_1_0_91, v_0_1_0_92, v_0_1_0_93, v_0_1_0_94, v_0_1_0_95, v_0_1_0_96, v_0_1_0_97, v_0_1_0_98, v_0_1_0_99, v_0_1_0_100, v_0_1_0_101, v_0_1_0_102, v_0_1_0_103],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
// v0.1.0:104 — Remove the Instructions + Feedback pages (and their backend) and retire the empty
|
||||||
|
// lp_profiles table + investor_type (a deliberate, one-off exception to never-hard-delete; in-app
|
||||||
|
// migration 0008 drops lp_profiles + feature_requests, and 0001's lp_profiles ALTER was removed so a
|
||||||
|
// fresh DB doesn't break the migration chain). Fixes: email sync no longer terminally parks a mailbox
|
||||||
|
// on a transient timeout (auto-retry with an hourly backoff, so stuck mailboxes self-heal); the mobile
|
||||||
|
// Contacts directory pages through ALL contacts (one fetch silently truncated at 720, hiding people
|
||||||
|
// from the list + search); a clock icon on the mobile email Review-log sets a reminder inline; email-
|
||||||
|
// approval cards show date/time. New: an admin-only purge of soft-deleted rows (type-to-confirm,
|
||||||
|
// refuses any row still linked to live data). The table drop is an in-app SQL migration — no
|
||||||
|
// StartOS-level migration needed.
|
||||||
|
export const v_0_1_0_104 = VersionInfo.of({
|
||||||
|
version: '0.1.0:104',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US: [
|
||||||
|
'Removed the Instructions and Feedback pages and the retired lp_profiles table. Email sync now',
|
||||||
|
'auto-recovers from transient timeouts; the mobile Contacts list shows everyone (was capped at',
|
||||||
|
'500); set a reminder from a mobile email review; approval cards show date/time; and admins can',
|
||||||
|
'permanently purge soft-deleted rows.',
|
||||||
|
].join(' '),
|
||||||
|
},
|
||||||
|
migrations: { up: async () => {}, down: async () => {} },
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user