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:
+95
-137
@@ -215,26 +215,6 @@ def init_db():
|
||||
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 (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
@@ -273,20 +253,6 @@ def init_db():
|
||||
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 (
|
||||
id TEXT PRIMARY KEY,
|
||||
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_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_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_lead ON fundraising_investors(lead);
|
||||
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':
|
||||
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
|
||||
if path == '/api/fundraising/state':
|
||||
return self.handle_get_fundraising_state(user)
|
||||
@@ -2398,6 +2357,8 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
return self.handle_get_backup_policy(user)
|
||||
if path == '/api/admin/digest/policy':
|
||||
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':
|
||||
return self.handle_get_fundraising_relational_summary(user)
|
||||
if path == '/api/fundraising/automations':
|
||||
@@ -2503,8 +2464,6 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
return self.handle_create_communication(user, body)
|
||||
if path == '/api/import/csv':
|
||||
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':
|
||||
return self.handle_log_fundraising_communication(user, body)
|
||||
if path == '/api/fundraising/update-row':
|
||||
@@ -2525,6 +2484,8 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
return self.handle_admin_create_user(user, body)
|
||||
if path == '/api/admin/reset-all-data':
|
||||
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':
|
||||
return self.handle_admin_send_test_email(user, body)
|
||||
if path == '/api/admin/digest/send-now':
|
||||
@@ -2623,9 +2584,6 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
if re.match(r'^/api/opportunities/[^/]+/stage$', path):
|
||||
opp_id = path.split('/')[-2]
|
||||
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):
|
||||
return self.handle_update_reminder(user, path.split('/')[-1], body)
|
||||
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)
|
||||
# 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()
|
||||
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 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')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -4821,6 +4778,96 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
finally:
|
||||
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):
|
||||
if not require_admin(user):
|
||||
return self.send_error_json("Admin required", 403)
|
||||
@@ -5393,10 +5440,8 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
|
||||
conn.execute("DELETE FROM communications")
|
||||
conn.execute("DELETE FROM opportunities")
|
||||
conn.execute("DELETE FROM lp_profiles")
|
||||
conn.execute("DELETE FROM custom_field_values")
|
||||
conn.execute("DELETE FROM custom_fields")
|
||||
conn.execute("DELETE FROM feature_requests")
|
||||
conn.execute("DELETE FROM contacts")
|
||||
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)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user