Retire contacts.contact_type; derive Contacts status from the grid (v0.1.0:106)

The Investors/Prospects distinction is now derived live from the canonical
grid (contact_grid_signals -> committed/pipeline_stage), not the mechanically
set contact_type column:

- Desktop Contacts: drop the Investors/Prospects tabs + TYPE badge; show a
  derived Status (existing-LP badge + pipeline stage chip).
- Dashboard: repoint Total LPs / Prospects onto fundraising_investors entities
  (committed>0 vs $0, graveyard + blank-row placeholder excluded); fix a
  total_contacts soft-delete leak.
- Stop reading/writing contact_type across the create/update/import/sync paths.
  The column is left inert in place; a physical drop is deferred to a later
  signed-off table-rebuild migration (SQLite no-drop-column; contacts is
  FK-referenced) -- same retire-then-drop path lp_profiles took.
This commit is contained in:
Keysat
2026-06-20 22:09:02 -05:00
parent b23c48bf7a
commit 05f15b9197
6 changed files with 105 additions and 81 deletions
+34 -23
View File
@@ -161,6 +161,11 @@ def init_db():
mobile TEXT,
title TEXT,
organization_id TEXT REFERENCES organizations(id) ON DELETE SET NULL,
-- RETIRED (v0.1.0:106), inert: the Investors/Prospects distinction is now derived live
-- from the grid (contact_grid_signals committed/pipeline_stage), not this column. No
-- code reads or writes it; the DEFAULT keeps NOT NULL satisfied for inserts that omit it.
-- Physical drop deferred to a signed-off table-rebuild migration (SQLite has no DROP COLUMN
-- here; contacts is FK-referenced) same retire-then-drop path lp_profiles took (v78v104).
contact_type TEXT NOT NULL DEFAULT 'prospect',
status TEXT NOT NULL DEFAULT 'active',
source TEXT,
@@ -840,7 +845,7 @@ def _upsert_contact_from_fundraising(conn, investor_name, contact, actor_user_id
conn.execute("""
UPDATE contacts
SET first_name = ?, last_name = ?, email = ?, title = ?,
organization_id = ?, source = ?, contact_type = 'investor', city = ?, state = ?, country = ?, location_query = ?, linkedin_url = ?, phone = ?, mobile = ?, updated_at = ?
organization_id = ?, source = ?, city = ?, state = ?, country = ?, location_query = ?, linkedin_url = ?, phone = ?, mobile = ?, updated_at = ?
WHERE id = ?
""", (next_first, next_last, next_email, next_title, next_org, next_source, next_city, next_state, next_country, next_location_query, next_linkedin, next_phone, next_mobile, now(), existing['id']))
return existing['id']
@@ -848,8 +853,8 @@ def _upsert_contact_from_fundraising(conn, investor_name, contact, actor_user_id
contact_id = generate_id()
conn.execute("""
INSERT INTO contacts (
id, first_name, last_name, email, title, organization_id, source, contact_type, status, city, state, country, location_query, linkedin_url, phone, mobile, created_by, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 'investor', 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?)
id, first_name, last_name, email, title, organization_id, source, status, city, state, country, location_query, linkedin_url, phone, mobile, created_by, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
contact_id,
first_name or 'Unknown',
@@ -2724,9 +2729,6 @@ class CRMHandler(BaseHTTPRequestHandler):
"""
args = []
if params.get('type'):
query += " AND c.contact_type = ?"
args.append(params['type'])
if params.get('status'):
query += " AND c.status = ?"
args.append(params['status'])
@@ -2743,7 +2745,7 @@ class CRMHandler(BaseHTTPRequestHandler):
sort = params.get('sort', 'updated_at')
order = 'DESC' if params.get('order', 'desc').lower() == 'desc' else 'ASC'
allowed_sorts = ['first_name', 'last_name', 'email', 'created_at', 'updated_at', 'contact_type', 'source']
allowed_sorts = ['first_name', 'last_name', 'email', 'created_at', 'updated_at', 'source']
if sort in allowed_sorts:
query += f" ORDER BY c.{sort} {order}"
else:
@@ -2832,14 +2834,14 @@ class CRMHandler(BaseHTTPRequestHandler):
tags = json.dumps(body.get('tags', []))
conn.execute("""
INSERT INTO contacts (id, first_name, last_name, email, phone, mobile, title,
organization_id, contact_type, status, source, tags, notes, linkedin_url,
organization_id, status, source, tags, notes, linkedin_url,
city, state, country, location_query, preferred_contact, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
contact_id, body['first_name'], body['last_name'],
body.get('email'), body.get('phone'), body.get('mobile'),
body.get('title'), organization_id,
body.get('contact_type', 'prospect'), body.get('status', 'active'),
body.get('status', 'active'),
body.get('source'), tags, body.get('notes'),
body.get('linkedin_url'), body.get('city'), body.get('state'),
body.get('country'), body.get('location_query'),
@@ -2871,7 +2873,7 @@ class CRMHandler(BaseHTTPRequestHandler):
previous_contact = row_to_dict(existing)
updatable = ['first_name', 'last_name', 'email', 'phone', 'mobile', 'title',
'organization_id', 'contact_type', 'status', 'source', 'notes',
'organization_id', 'status', 'source', 'notes',
'linkedin_url', 'city', 'state', 'country', 'location_query', 'preferred_contact']
sets = []
args = []
@@ -4196,10 +4198,21 @@ class CRMHandler(BaseHTTPRequestHandler):
def handle_dashboard_report(self, user):
conn = get_db()
# Key metrics
total_lps = conn.execute("SELECT COUNT(*) as c FROM contacts WHERE contact_type = 'investor'").fetchone()['c']
total_prospects = conn.execute("SELECT COUNT(*) as c FROM contacts WHERE contact_type = 'prospect'").fetchone()['c']
total_contacts = conn.execute("SELECT COUNT(*) as c FROM contacts").fetchone()['c']
# Key metrics. "Total LPs" / "Prospects" are derived from the canonical grid
# (investor entities), not the retired contacts.contact_type: an LP is a grid
# investor with committed capital (total_invested > 0); a prospect is a live
# grid row with nothing committed yet. Graveyarded rows are excluded (same basis
# as Total Committed below); blank grid rows (synced as the 'Untitled Investor'
# placeholder, see sync_fundraising_relational) are excluded from the prospect
# count so empties don't inflate it.
total_lps = conn.execute(
"SELECT COUNT(*) as c FROM fundraising_investors WHERE total_invested > 0 AND graveyard = 0"
).fetchone()['c']
total_prospects = conn.execute(
"SELECT COUNT(*) as c FROM fundraising_investors "
"WHERE COALESCE(total_invested, 0) = 0 AND graveyard = 0 AND investor_name != 'Untitled Investor'"
).fetchone()['c']
total_contacts = conn.execute("SELECT COUNT(*) as c FROM contacts WHERE deleted_at IS NULL").fetchone()['c']
# Committed capital comes from the canonical fundraising grid (per-investor
# rollup of per-fund commitments). Graveyarded (written-off) investors are
@@ -4495,7 +4508,6 @@ class CRMHandler(BaseHTTPRequestHandler):
conn.execute("""
UPDATE contacts SET first_name=?, last_name=?, phone=?, title=?,
organization_id=COALESCE(?, organization_id),
contact_type=COALESCE(?, contact_type),
linkedin_url=COALESCE(?, linkedin_url),
city=COALESCE(?, city),
state=COALESCE(?, state),
@@ -4505,7 +4517,6 @@ class CRMHandler(BaseHTTPRequestHandler):
WHERE id=?
""", (first_name, last_name, data.get('phone'),
data.get('title'), org_id,
data.get('contact_type'),
linkedin_url if linkedin_url else None,
city if city else None,
state if state else None,
@@ -4530,12 +4541,12 @@ class CRMHandler(BaseHTTPRequestHandler):
contact_id = generate_id()
conn.execute("""
INSERT INTO contacts (id, first_name, last_name, email, phone,
title, organization_id, contact_type, status, source,
title, organization_id, status, source,
linkedin_url, city, state, country, location_query, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', 'import', ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, 'active', 'import', ?, ?, ?, ?, ?, ?)
""", (contact_id, first_name, last_name, email,
data.get('phone'), data.get('title'), org_id,
data.get('contact_type', 'prospect'), linkedin_url,
linkedin_url,
city, state, country, location_query, user['user_id']))
if email:
batch_email_matches[email_key] = {
@@ -4558,12 +4569,12 @@ class CRMHandler(BaseHTTPRequestHandler):
contact_id = generate_id()
conn.execute("""
INSERT INTO contacts (id, first_name, last_name, email, phone,
title, organization_id, contact_type, status, source,
title, organization_id, status, source,
linkedin_url, city, state, country, location_query, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', 'import', ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, 'active', 'import', ?, ?, ?, ?, ?, ?)
""", (contact_id, first_name, last_name, email,
data.get('phone'), data.get('title'), org_id,
data.get('contact_type', 'prospect'), linkedin_url,
linkedin_url,
city, state, country, location_query, user['user_id']))
if email:
batch_email_matches[email_key] = {
+28 -3
View File
@@ -8,9 +8,15 @@ rollup) with graveyarded (written-off) investors excluded, "Total Funded" is dro
(the grid has no funded-vs-committed concept), and the /api/lp-profiles* + lp-breakdown
endpoints are gone.
This boots the REAL server against a temp DB, seeds two grid investors (one live, one
graveyarded), and asserts: total_committed reflects the live grid rollup only, the
metrics no longer carry a total_funded key, and the retired routes 404. Synthetic only.
v0.1.0:106 repointed "Total LPs" / "Prospects" off the retired contacts.contact_type onto
the canonical grid (investor entities): an LP = a grid investor with total_invested > 0
(graveyard excluded); a prospect = a live grid row with $0 committed (graveyard + the
'Untitled Investor' blank-row placeholder excluded).
This boots the REAL server against a temp DB, seeds grid investors (live LP, graveyarded,
live prospect, blank placeholder), and asserts: total_committed reflects the live grid
rollup only, total_lps / total_prospects use the grid-entity definitions, the metrics no
longer carry a total_funded key, and the retired routes 404. Synthetic only.
Run: cd backend && python3 test_dashboard_report.py
"""
@@ -68,6 +74,17 @@ def seed():
"VALUES ('fiLive','Harbor LP','rowLive',3000000,0)")
c.execute("INSERT INTO fundraising_investors (id,investor_name,source_row_id,total_invested,graveyard) "
"VALUES ('fiDead','Passed LP','rowDead',500000,1)")
# a live prospect (in the grid, $0 committed) and a blank placeholder row — the prospect
# count includes the former and excludes the latter ('Untitled Investor' = a blank grid row)
c.execute("INSERT INTO fundraising_investors (id,investor_name,source_row_id,total_invested,graveyard) "
"VALUES ('fiProspect','Prospect Co','rowProspect',0,0)")
c.execute("INSERT INTO fundraising_investors (id,investor_name,source_row_id,total_invested,graveyard) "
"VALUES ('fiBlank','Untitled Investor','rowBlank',0,0)")
# one live + one soft-deleted contact: total_contacts must count only the live one
# (guards the deleted_at filter added alongside the contact_type repoint)
c.execute("INSERT INTO contacts (id,first_name,last_name) VALUES ('ctLive','Ann','Live')")
c.execute("INSERT INTO contacts (id,first_name,last_name,deleted_at) "
"VALUES ('ctGone','Bob','Gone','2026-06-01T00:00:00Z')")
c.commit()
c.close()
@@ -90,6 +107,14 @@ def main():
check("total_funded" not in metrics,
f"total_funded key dropped from metrics (got keys {sorted(metrics)})")
print("\n[Total LPs / Prospects derived from the grid, not the retired contacts.contact_type]")
check(metrics.get("total_lps") == 1,
f"total_lps = grid investors committed>0, graveyard excluded (1; got {metrics.get('total_lps')})")
check(metrics.get("total_prospects") == 1,
f"total_prospects = grid rows with $0 committed; graveyard + 'Untitled Investor' excluded (1; got {metrics.get('total_prospects')})")
check(metrics.get("total_contacts") == 1,
f"total_contacts excludes soft-deleted contacts (1; got {metrics.get('total_contacts')})")
print("\n[retired lp_profiles endpoints 404]")
for path in ("/api/lp-profiles", "/api/lp-profiles/anything", "/api/reports/lp-breakdown"):
st, _ = _get(port, path, token)
+24 -52
View File
@@ -4373,6 +4373,22 @@
);
};
// Derived contact status from the live grid signals the API injects (committed / pipeline_stage) —
// replaces the retired contact_type. An existing LP (committed > 0) shows an LP badge; an
// in-pipeline contact shows its stage chip (both can show at once — a committed LP still in an
// active deal); a contact with neither is a Prospect. Used by the desktop Contacts list + detail.
const renderContactStatus = (c) => {
const existing = Number((c && c.committed) || 0) > 0;
const stage = c && c.pipeline_stage;
if (!existing && !stage) return <span className="badge badge-prospect">Prospect</span>;
return (
<span style={{ display: 'inline-flex', gap: '6px', alignItems: 'center', flexWrap: 'wrap' }}>
{existing && <span className="badge badge-investor">LP</span>}
{stage && <StageChip stage={stage} sm />}
</span>
);
};
// Existing-LP earmark — a quiet accent corner-triangle (top-left), the locked existing-investor
// signal on cards (dc GridApp:89-91). Reusable: the Grid card now, the Pipeline card (8f). Render
// only when the investor has committed capital (existing_investor / committed > 0).
@@ -5507,7 +5523,6 @@
const [contactsPage, setContactsPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [tab, setTab] = useState('all');
const [search, setSearch] = useState('');
const [sort, setSort] = useState('last_name');
const [order, setOrder] = useState('asc');
@@ -5515,19 +5530,18 @@
const [deleting, setDeleting] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
const tabFilter = tab === 'all' ? '' : tab === 'investors' ? 'investor' : 'prospect';
const contactsOffset = (contactsPage - 1) * CONTACTS_PAGE_SIZE;
const contactsMaxPage = Math.max(1, Math.ceil((Number(contactsTotal) || 0) / CONTACTS_PAGE_SIZE));
useEffect(() => {
setContactsPage(1);
}, [tab, search, sort, order]);
}, [search, sort, order]);
useEffect(() => {
const fetchContacts = async () => {
try {
setLoading(true);
const result = await api(`/api/contacts?search=${encodeURIComponent(search)}&type=${tabFilter}&sort=${sort}&order=${order}&limit=${CONTACTS_PAGE_SIZE}&offset=${contactsOffset}`, {}, token);
const result = await api(`/api/contacts?search=${encodeURIComponent(search)}&sort=${sort}&order=${order}&limit=${CONTACTS_PAGE_SIZE}&offset=${contactsOffset}`, {}, token);
setContacts(result.data || []);
setContactsTotal(Number(result.total) || 0);
} catch (err) {
@@ -5538,7 +5552,7 @@
};
fetchContacts();
}, [token, tab, search, sort, order, contactsOffset]);
}, [token, search, sort, order, contactsOffset]);
const handleDeleteContact = async (id) => {
setDeleting(id);
@@ -5555,16 +5569,6 @@
}
};
const contactTypeTooltip = (type) => {
const badges = {
'investor': 'badge-investor',
'prospect': 'badge-prospect',
'advisor': 'badge-advisor',
'other': 'badge-other'
};
return badges[type] || 'badge-other';
};
const handleSort = (column) => {
if (sort === column) {
setOrder(order === 'asc' ? 'desc' : 'asc');
@@ -5579,18 +5583,6 @@
<h2 className="section-title">Contacts</h2>
<div className="section">
<div className="tabs">
{['all', 'investors', 'prospects'].map(t => (
<button
key={t}
className={`tab ${tab === t ? 'active' : ''}`}
onClick={() => setTab(t)}
>
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
<div className="controls">
<input
type="text"
@@ -5617,7 +5609,7 @@
<th onClick={() => handleSort('source')} style={{ cursor: 'pointer' }}>
Lead Source {sort === 'source' && (order === 'asc' ? '▲' : '▼')}
</th>
<th>Type</th>
<th>Status</th>
<th>Email</th>
<th>Last Contact</th>
<th>Communications</th>
@@ -5629,11 +5621,7 @@
<td>{contact.first_name} {contact.last_name}</td>
<td>{contact.organization || contact.organization_name || '-'}</td>
<td>{contact.source || '-'}</td>
<td>
<span className={`badge ${contactTypeTooltip(contact.contact_type)}`}>
{contact.contact_type}
</span>
</td>
<td>{renderContactStatus(contact)}</td>
<td>{contact.email || '-'}</td>
<td>{formatDate(contact.last_contact_date)}</td>
<td>{contact.communication_count ?? contact.comm_count ?? 0}</td>
@@ -5677,7 +5665,7 @@
onDelete={() => setConfirmDelete(selectedContact.id)}
token={token}
onRefresh={() => {
const result = api(`/api/contacts?search=${encodeURIComponent(search)}&type=${tabFilter}&sort=${sort}&order=${order}&limit=${CONTACTS_PAGE_SIZE}&offset=${contactsOffset}`, {}, token);
const result = api(`/api/contacts?search=${encodeURIComponent(search)}&sort=${sort}&order=${order}&limit=${CONTACTS_PAGE_SIZE}&offset=${contactsOffset}`, {}, token);
result.then(r => {
setContacts(r.data || []);
setContactsTotal(Number(r.total) || 0);
@@ -6017,7 +6005,6 @@
city: '',
state: '',
country: '',
contact_type: 'prospect',
status: 'active'
});
@@ -6050,7 +6037,6 @@
city: details.city || '',
state: details.state || '',
country: details.country || '',
contact_type: details.contact_type || 'prospect',
status: details.status || 'active'
});
}, [details]);
@@ -6076,7 +6062,6 @@
city: editDraft.city.trim(),
state: editDraft.state.trim(),
country: editDraft.country.trim(),
contact_type: editDraft.contact_type,
status: editDraft.status
})
}, token);
@@ -6156,15 +6141,6 @@
<label className="form-label">Country</label>
<input className="text-input" value={editDraft.country} onChange={(e) => setEditDraft((d) => ({ ...d, country: e.target.value }))} />
</div>
<div className="form-group">
<label className="form-label">Type</label>
<select className="select-input" value={editDraft.contact_type} onChange={(e) => setEditDraft((d) => ({ ...d, contact_type: e.target.value }))}>
<option value="investor">Investor</option>
<option value="prospect">Prospect</option>
<option value="advisor">Advisor</option>
<option value="other">Other</option>
</select>
</div>
<div className="form-group">
<label className="form-label">Status</label>
<select className="select-input" value={editDraft.status} onChange={(e) => setEditDraft((d) => ({ ...d, status: e.target.value }))}>
@@ -6207,12 +6183,8 @@
</span>
</div>
<div className="detail-row">
<span className="detail-label">Type</span>
<span className="detail-value">
<span className={`badge ${details.contact_type === 'investor' ? 'badge-investor' : 'badge-prospect'}`}>
{details.contact_type}
</span>
</span>
<span className="detail-label">Status</span>
<span className="detail-value">{renderContactStatus(details)}</span>
</div>
</>
)}
+1 -1
View File
@@ -71,7 +71,7 @@ export const PACKAGE_TITLE = 'Ten31 Database'
// * 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: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])
// * Current: 0.1.0:105 (TEMPORARY diagnostic — admin contacts census [GET /api/admin/contacts-census + a Settings → Admin "Run census" button] reporting the A/B/C populations [counts only, no PII] for the deferred contacts<->fundraising_contacts consolidation; mirrors backend/scripts/contacts_census.sql. DELETE the endpoint + route + button after the numbers are captured — all tagged TEMPORARY in code. No schema change)
export const PACKAGE_VERSION = '0.1.0:105'
export const PACKAGE_VERSION = '0.1.0:106'
export const DATA_MOUNT_PATH = '/data'
export const WEB_PORT = 8080
+3 -2
View File
@@ -66,8 +66,9 @@ 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_104 } from './v0.1.0.104'
import { v_0_1_0_105 } from './v0.1.0.105'
import { v_0_1_0_106 } from './v0.1.0.106'
export const versionGraph = VersionGraph.of({
current: v_0_1_0_105,
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, v_0_1_0_104],
current: v_0_1_0_106,
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, v_0_1_0_104, v_0_1_0_105],
})
+15
View File
@@ -0,0 +1,15 @@
import { VersionInfo } from '@start9labs/start-sdk'
// v0.1.0:106 — Retire contacts.contact_type (logical). The Investors/Prospects distinction is now
// derived live from the canonical grid (contact_grid_signals → committed/pipeline_stage): the desktop
// Contacts tabs + TYPE badge are replaced by a derived Status (existing-LP + pipeline stage), and the
// dashboard "Total LPs"/"Prospects" counts are repointed onto fundraising_investors entities. The
// column is left physically in place but inert (no reader/writer); a physical DROP is deferred to a
// later signed-off table-rebuild migration. No schema change in this release.
export const v_0_1_0_106 = VersionInfo.of({
version: '0.1.0:106',
releaseNotes: {
en_US: 'Retire the legacy contact type: Contacts now shows grid-derived status (existing LP + pipeline stage) and the dashboard LP/prospect counts come from the fundraising grid.',
},
migrations: { up: async () => {}, down: async () => {} },
})