Files
ten31-database/backend/migrations/0001_phase0_foundation.sql
T
Keysat 1564c087bf 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.
2026-06-20 20:06:11 -05:00

121 lines
7.2 KiB
SQL

-- Phase 0 — Workstream A2: foundation schema for the agentic system.
--
-- ADDITIVE AND REVERSIBLE ONLY (CLAUDE.md guardrail #3): this migration adds
-- new tables and new nullable columns alongside the existing CRM. It never
-- drops, renames, or rewrites existing data. Its reversal is 0001_phase0_foundation.down.sql.
--
-- Applied once at startup by backend/core_migrations.py, tracked in the
-- schema_migrations ledger. Safe to leave in place; the canonical layer it
-- creates starts EMPTY and is populated later by entity resolution (A4/B3).
-- ============================================================================
-- 1. canonical_entities — the single, model-agnostic identity for an LP /
-- organization / person. Both the classic contacts/lp_profiles model and the
-- fundraising_* grid map INTO this; neither existing model is demoted.
-- IDs are full-length (e.g. 'lp_' + uuid4 hex), NOT the 8-char truncated
-- UUIDs used elsewhere in the CRM, so they are safe as the index/payload key.
-- ============================================================================
CREATE TABLE IF NOT EXISTS canonical_entities (
id TEXT PRIMARY KEY,
entity_kind TEXT NOT NULL, -- 'lp' | 'organization' | 'person'
display_name TEXT NOT NULL,
primary_email TEXT,
-- Phase-0 LP/prospect fields (model-agnostic home):
thesis_fit TEXT,
segment TEXT,
accreditation_status TEXT, -- free-text until counsel defines the vocabulary (guardrail #6)
qp_status TEXT,
warmth_score REAL,
source TEXT,
owner_id TEXT REFERENCES users(id),
last_touch_at TEXT,
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
deleted_at TEXT -- soft-delete (never hard-delete; guardrail #3)
);
CREATE INDEX IF NOT EXISTS idx_canonical_kind ON canonical_entities(entity_kind);
CREATE INDEX IF NOT EXISTS idx_canonical_email ON canonical_entities(primary_email);
CREATE INDEX IF NOT EXISTS idx_canonical_owner ON canonical_entities(owner_id);
-- ============================================================================
-- 2. entity_links — resolution map. Every source row (a contacts row, a
-- fundraising_investors row, etc.) and every email/name variant points at the
-- canonical entity it resolves to. This is how name variants collapse to one id.
-- ============================================================================
CREATE TABLE IF NOT EXISTS entity_links (
id TEXT PRIMARY KEY,
canonical_id TEXT NOT NULL REFERENCES canonical_entities(id) ON DELETE CASCADE,
source_model TEXT NOT NULL, -- contacts|organizations|lp_profiles|fundraising_investors|fundraising_contacts|email_address|alias
source_id TEXT, -- the local PK in that model (NULL for a bare email/name alias)
match_value TEXT, -- normalized email or name variant
match_kind TEXT NOT NULL, -- exact_email|name_variant|domain|manual
confidence REAL DEFAULT 1.0,
created_at TEXT DEFAULT (datetime('now')),
UNIQUE(source_model, source_id, match_value)
);
CREATE INDEX IF NOT EXISTS idx_entity_links_canonical ON entity_links(canonical_id);
CREATE INDEX IF NOT EXISTS idx_entity_links_match ON entity_links(match_value);
CREATE INDEX IF NOT EXISTS idx_entity_links_source ON entity_links(source_model, source_id);
-- ============================================================================
-- 3. interaction_log — APPEND-ONLY record of every agent action and every human
-- touch (guardrail #5). Distinct from audit_log (which is mutation-diff-only
-- and has no actor/agent dimension). Nothing in this table is ever updated or
-- deleted by convention.
-- ============================================================================
CREATE TABLE IF NOT EXISTS interaction_log (
id TEXT PRIMARY KEY,
ts TEXT NOT NULL DEFAULT (datetime('now')), -- event time
actor_type TEXT NOT NULL, -- human | agent | system
actor_id TEXT, -- users.id, or an agent name (Scout/Analyst/...)
action TEXT NOT NULL, -- e.g. note.created | email.matched | enrichment.written | search.run
target_type TEXT, -- canonical_entity | contact | communication | opportunity | ...
target_id TEXT, -- canonical_entities.id where possible
payload TEXT, -- JSON blob with the action detail
source TEXT, -- crm_ui | mcp | ingest | scout | ...
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_interaction_target ON interaction_log(target_type, target_id);
CREATE INDEX IF NOT EXISTS idx_interaction_ts ON interaction_log(ts);
CREATE INDEX IF NOT EXISTS idx_interaction_actor ON interaction_log(actor_type, actor_id);
-- ============================================================================
-- 4. relationship_edges — derived graph of who-knows-whom between canonical
-- entities. Starts EMPTY; seeded later from email_investor_links + calendar +
-- X follower overlap (Analyst, Phase 2).
-- ============================================================================
CREATE TABLE IF NOT EXISTS relationship_edges (
id TEXT PRIMARY KEY,
src_id TEXT NOT NULL REFERENCES canonical_entities(id) ON DELETE CASCADE,
dst_id TEXT NOT NULL REFERENCES canonical_entities(id) ON DELETE CASCADE,
edge_type TEXT NOT NULL, -- email_corr | calendar | x_follow | intro | colleague
source TEXT NOT NULL, -- provenance of this edge
strength REAL DEFAULT 0,
directed INTEGER DEFAULT 0,
evidence TEXT, -- JSON supporting detail
first_seen_at TEXT,
last_seen_at TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(src_id, dst_id, edge_type, source)
);
CREATE INDEX IF NOT EXISTS idx_rel_src ON relationship_edges(src_id);
CREATE INDEX IF NOT EXISTS idx_rel_dst ON relationship_edges(dst_id);
-- ============================================================================
-- 5. Soft-delete columns on existing tables. Additive nullable columns; the CRM
-- currently HARD-deletes everywhere (guardrail #3 gap). Adding the column is
-- safe now; switching the DELETE handlers to set it instead of hard-deleting
-- is a separate, reviewed code change.
-- ============================================================================
ALTER TABLE contacts ADD COLUMN deleted_at TEXT;
ALTER TABLE organizations ADD COLUMN deleted_at TEXT;
ALTER TABLE opportunities ADD COLUMN deleted_at TEXT;
ALTER TABLE communications 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.