cleanup: deprecate /nav-i + /deleted-contacts; remove contacts_bp + lib/contacts.py

Probe found recon's /deleted-contacts dashboard reads /opt/recon/data/contacts.db
— frozen since extraction #3 moved write ownership to navi-contacts
(/var/lib/navi-backend/contacts.db). The page has been silently rendering ~25-day
stale data, and its restore/restore-as/purge XHRs hit recon's contacts_bp (the
recon.echo6.co Caddy block proxies straight to recon:8420 — no navi-contacts
routing there). Per Matt's decision, deprecate the pages entirely; they'll be
re-surfaced later as a proper admin page consuming navi-contacts via API.

Removed:
- contacts_bp (lib/contacts_api.py, all 10 /api/contacts* routes) + its
  registration in lib/api.py — edge-shadowed by navi-contacts :8423 since #3,
  and now free of recon-product consumers once the dashboard goes.
- /nav-i (navi_landing_page) + /deleted-contacts (deleted_contacts_page) route
  handlers; templates/navi/landing.html + templates/navi/deleted_contacts.html.
- lib/contacts.py (ContactsDB) — the dashboard was its only non-contacts_bp
  consumer; both gone.
- The two dead NAVI_SUBNAV entries (Overview→/nav-i, Deleted Contacts→
  /deleted-contacts).

Kept / adapted:
- /nav-i/api-keys page (recon-product key management) stays. NAVI_SUBNAV reduced
  to just its API Keys entry; the base.html top-nav "Nav-I" link repointed
  /nav-i -> /nav-i/api-keys so the surviving section page stays reachable
  (minimal href change, not a nav restructure — flagged in PR).
- lib/address_book.py — geocode.py + netsyms_api.py still consume it (untouched).

Out-of-band follow-up after merge: delete the stale /opt/recon/data/contacts.db
(frozen 2026-04-28; data, not code).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
zvx-echo6 2026-05-23 01:24:32 -06:00
commit e0fc8d0cff
6 changed files with 1 additions and 531 deletions

View file

@ -59,10 +59,6 @@ class _LargeZimRequest(_FlaskRequest):
return super()._get_file_stream(total_content_length, content_type, filename, content_length)
app.request_class = _LargeZimRequest
# ── Contacts Blueprint ──
from .contacts_api import contacts_bp
app.register_blueprint(contacts_bp)
# ── Netsyms + Geocode Blueprints ──
from .netsyms_api import netsyms_bp, geocode_bp
app.register_blueprint(netsyms_bp)
@ -106,8 +102,6 @@ SETTINGS_SUBNAV = [
]
NAVI_SUBNAV = [
{'href': '/nav-i', 'label': 'Overview'},
{'href': '/deleted-contacts', 'label': 'Deleted Contacts'},
{'href': '/nav-i/api-keys', 'label': 'API Keys'},
]
@ -337,30 +331,6 @@ def failures_page():
failures=failures)
@app.route("/deleted-contacts")
def deleted_contacts_page():
from .auth import get_user_id
from .contacts import ContactsDB
user_id = get_user_id() or "anonymous"
db = ContactsDB()
contacts = db.list_deleted(user_id)
return render_template("navi/deleted_contacts.html",
domain="navi", subnav=NAVI_SUBNAV, active_page="/deleted-contacts",
contacts=contacts)
@app.route("/nav-i")
def navi_landing_page():
from .auth import get_user_id
from .contacts import ContactsDB
user_id = get_user_id() or "anonymous"
db = ContactsDB()
deleted_count = len(db.list_deleted(user_id))
return render_template("navi/landing.html",
domain="navi", subnav=NAVI_SUBNAV, active_page="/nav-i",
deleted_count=deleted_count)
@app.route("/nav-i/api-keys")
def navi_api_keys_page():
return render_template("navi/api_keys.html",

View file

@ -1,230 +0,0 @@
"""
RECON Contacts Database per-user phone book with soft delete and proximity queries.
Separate DB at data/contacts.db. Thread-local connections with WAL mode (StatusDB pattern).
"""
import math
import os
import sqlite3
import threading
from datetime import datetime, timezone
_local = threading.local()
_SCHEMA = """
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
label TEXT NOT NULL,
name TEXT,
call_sign TEXT,
phone TEXT,
email TEXT,
category TEXT,
notes TEXT,
lat REAL,
lon REAL,
osm_type TEXT,
osm_id INTEGER,
address TEXT,
show_proximity INTEGER DEFAULT 0,
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
deleted_at TEXT,
deleted_by TEXT
);
CREATE INDEX IF NOT EXISTS idx_contacts_user ON contacts(user_id);
CREATE INDEX IF NOT EXISTS idx_contacts_user_category ON contacts(user_id, category);
CREATE INDEX IF NOT EXISTS idx_contacts_user_deleted ON contacts(user_id, deleted_at);
CREATE INDEX IF NOT EXISTS idx_contacts_geo ON contacts(lat, lon);
CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_home_work
ON contacts(user_id, label)
WHERE label IN ('Home', 'Work') AND deleted_at IS NULL;
"""
def _haversine_m(lat1, lon1, lat2, lon2):
"""Haversine distance in meters."""
R = 6_371_000
rlat1, rlat2 = math.radians(lat1), math.radians(lat2)
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = math.sin(dlat / 2) ** 2 + math.cos(rlat1) * math.cos(rlat2) * math.sin(dlon / 2) ** 2
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
def _row_to_dict(row):
"""Convert sqlite3.Row to dict, casting show_proximity to bool."""
d = dict(row)
d['show_proximity'] = bool(d.get('show_proximity', 0))
return d
class ContactsDB:
def __init__(self, db_path=None):
if db_path is None:
db_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'data', 'contacts.db')
self.db_path = db_path
os.makedirs(os.path.dirname(db_path), exist_ok=True)
self._init_db()
def _get_conn(self):
if not hasattr(_local, 'contacts_conn') or _local.contacts_conn is None:
_local.contacts_conn = sqlite3.connect(self.db_path, timeout=30)
_local.contacts_conn.row_factory = sqlite3.Row
_local.contacts_conn.execute("PRAGMA journal_mode=WAL")
_local.contacts_conn.execute("PRAGMA busy_timeout=5000")
return _local.contacts_conn
def _init_db(self):
conn = self._get_conn()
conn.executescript(_SCHEMA)
conn.commit()
def list_all(self, user_id, category=None, search=None):
conn = self._get_conn()
sql = "SELECT * FROM contacts WHERE user_id = ? AND deleted_at IS NULL"
params = [user_id]
if category:
sql += " AND category = ?"
params.append(category)
if search:
sql += " AND (label LIKE ? OR name LIKE ? OR call_sign LIKE ? OR phone LIKE ?)"
like = f"%{search}%"
params.extend([like, like, like, like])
sql += " ORDER BY label"
return [_row_to_dict(r) for r in conn.execute(sql, params).fetchall()]
def list_deleted(self, user_id):
conn = self._get_conn()
rows = conn.execute(
"SELECT * FROM contacts WHERE user_id = ? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC",
(user_id,)
).fetchall()
return [_row_to_dict(r) for r in rows]
def get(self, user_id, contact_id, include_deleted=False):
conn = self._get_conn()
sql = "SELECT * FROM contacts WHERE id = ? AND user_id = ?"
if not include_deleted:
sql += " AND deleted_at IS NULL"
row = conn.execute(sql, (contact_id, user_id)).fetchone()
return _row_to_dict(row) if row else None
def create(self, user_id, **fields):
conn = self._get_conn()
fields.pop('id', None)
fields.pop('user_id', None)
fields.pop('created_at', None)
fields.pop('updated_at', None)
fields.pop('deleted_at', None)
fields.pop('deleted_by', None)
if 'show_proximity' in fields:
fields['show_proximity'] = 1 if fields['show_proximity'] else 0
columns = ['user_id'] + list(fields.keys())
placeholders = ', '.join(['?'] * len(columns))
col_str = ', '.join(columns)
values = [user_id] + list(fields.values())
try:
cur = conn.execute(f"INSERT INTO contacts ({col_str}) VALUES ({placeholders})", values)
conn.commit()
return self.get(user_id, cur.lastrowid), None
except sqlite3.IntegrityError:
return None, 'conflict'
def update(self, user_id, contact_id, **fields):
conn = self._get_conn()
fields.pop('id', None)
fields.pop('user_id', None)
fields.pop('created_at', None)
fields.pop('deleted_at', None)
fields.pop('deleted_by', None)
if 'show_proximity' in fields:
fields['show_proximity'] = 1 if fields['show_proximity'] else 0
fields['updated_at'] = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%fZ')
sets = ', '.join(f"{k} = ?" for k in fields)
values = list(fields.values()) + [contact_id, user_id]
conn.execute(f"UPDATE contacts SET {sets} WHERE id = ? AND user_id = ? AND deleted_at IS NULL", values)
conn.commit()
return self.get(user_id, contact_id)
def soft_delete(self, user_id, contact_id):
conn = self._get_conn()
now = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%fZ')
conn.execute(
"UPDATE contacts SET deleted_at = ?, deleted_by = ? WHERE id = ? AND user_id = ? AND deleted_at IS NULL",
(now, user_id, contact_id, user_id)
)
conn.commit()
return self.get(user_id, contact_id, include_deleted=True)
def restore(self, user_id, contact_id):
conn = self._get_conn()
row = self.get(user_id, contact_id, include_deleted=True)
if not row or not row.get('deleted_at'):
return None, 'not_found'
if row.get('label') in ('Home', 'Work'):
existing = conn.execute(
"SELECT id FROM contacts WHERE user_id = ? AND label = ? AND deleted_at IS NULL AND id != ?",
(user_id, row['label'], contact_id)
).fetchone()
if existing:
return None, 'conflict'
conn.execute(
"UPDATE contacts SET deleted_at = NULL, deleted_by = NULL WHERE id = ? AND user_id = ?",
(contact_id, user_id)
)
conn.commit()
return self.get(user_id, contact_id), None
def restore_as(self, user_id, contact_id, new_label):
"""Restore a soft-deleted contact with a new label (for Home/Work conflict resolution)."""
conn = self._get_conn()
row = self.get(user_id, contact_id, include_deleted=True)
if not row or not row.get('deleted_at'):
return None, 'not_found'
if not new_label or not new_label.strip():
return None, 'invalid_label'
now = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%fZ')
try:
conn.execute(
"UPDATE contacts SET deleted_at = NULL, deleted_by = NULL, label = ?, updated_at = ? WHERE id = ? AND user_id = ?",
(new_label.strip(), now, contact_id, user_id)
)
conn.commit()
except sqlite3.IntegrityError:
return None, 'conflict'
return self.get(user_id, contact_id), None
def purge(self, user_id, contact_id):
conn = self._get_conn()
row = self.get(user_id, contact_id, include_deleted=True)
if not row:
return False, 'not_found'
if not row.get('deleted_at'):
return False, 'not_deleted'
conn.execute("DELETE FROM contacts WHERE id = ? AND user_id = ?", (contact_id, user_id))
conn.commit()
return True, None
def find_nearby(self, user_id, lat, lon, radius_m=75):
conn = self._get_conn()
# Bounding box pre-filter (~111km per degree lat)
dlat = radius_m / 111_000
dlon = radius_m / (111_000 * math.cos(math.radians(lat)))
rows = conn.execute(
"""SELECT * FROM contacts
WHERE user_id = ? AND deleted_at IS NULL AND show_proximity = 1
AND lat BETWEEN ? AND ? AND lon BETWEEN ? AND ?""",
(user_id, lat - dlat, lat + dlat, lon - dlon, lon + dlon)
).fetchall()
results = []
for r in rows:
dist = _haversine_m(lat, lon, r['lat'], r['lon'])
if dist <= radius_m:
d = _row_to_dict(r)
d['distance_m'] = round(dist, 1)
results.append(d)
results.sort(key=lambda x: x['distance_m'])
return results

View file

@ -1,132 +0,0 @@
"""
RECON Contacts API Flask Blueprint.
Per-user phone book with soft delete, restore, purge, and proximity queries.
All endpoints require Authentik forward-auth (X-Authentik-Username header).
"""
from flask import Blueprint, request, jsonify
from .auth import require_auth
from .contacts import ContactsDB
contacts_bp = Blueprint('contacts', __name__)
_db = None
def _get_db():
global _db
if _db is None:
_db = ContactsDB()
return _db
@contacts_bp.route('/api/contacts', methods=['GET'])
@require_auth
def list_contacts():
db = _get_db()
category = request.args.get('category')
search = request.args.get('search')
return jsonify(db.list_all(request.user_id, category=category, search=search))
@contacts_bp.route('/api/contacts', methods=['POST'])
@require_auth
def create_contact():
db = _get_db()
data = request.get_json(force=True)
contact, err = db.create(request.user_id, **data)
if err == 'conflict':
return jsonify({'error': 'You already have a Home/Work contact'}), 409
return jsonify(contact), 201
@contacts_bp.route('/api/contacts/nearby', methods=['GET'])
@require_auth
def nearby_contacts():
db = _get_db()
lat = request.args.get('lat', type=float)
lon = request.args.get('lon', type=float)
radius_m = request.args.get('radius_m', 75, type=float)
if lat is None or lon is None:
return jsonify({'error': 'lat and lon required'}), 400
return jsonify(db.find_nearby(request.user_id, lat, lon, radius_m))
@contacts_bp.route('/api/contacts/deleted', methods=['GET'])
@require_auth
def list_deleted():
db = _get_db()
return jsonify(db.list_deleted(request.user_id))
@contacts_bp.route('/api/contacts/<int:contact_id>', methods=['GET'])
@require_auth
def get_contact(contact_id):
db = _get_db()
contact = db.get(request.user_id, contact_id)
if not contact:
return jsonify({'error': 'Not found'}), 404
return jsonify(contact)
@contacts_bp.route('/api/contacts/<int:contact_id>', methods=['PATCH'])
@require_auth
def update_contact(contact_id):
db = _get_db()
data = request.get_json(force=True)
contact = db.update(request.user_id, contact_id, **data)
if not contact:
return jsonify({'error': 'Not found'}), 404
return jsonify(contact)
@contacts_bp.route('/api/contacts/<int:contact_id>', methods=['DELETE'])
@require_auth
def delete_contact(contact_id):
db = _get_db()
contact = db.soft_delete(request.user_id, contact_id)
if not contact:
return jsonify({'error': 'Not found'}), 404
return jsonify(contact)
@contacts_bp.route('/api/contacts/<int:contact_id>/restore', methods=['POST'])
@require_auth
def restore_contact(contact_id):
db = _get_db()
contact, err = db.restore(request.user_id, contact_id)
if err == 'not_found':
return jsonify({'error': 'Not found'}), 404
if err == 'conflict':
return jsonify({'error': 'You already have a Home/Work contact'}), 409
return jsonify(contact)
@contacts_bp.route('/api/contacts/<int:contact_id>/restore-as', methods=['POST'])
@require_auth
def restore_as_contact(contact_id):
db = _get_db()
data = request.get_json(force=True)
new_label = data.get('label', '').strip()
if not new_label:
return jsonify({'error': 'label is required'}), 400
contact, err = db.restore_as(request.user_id, contact_id, new_label)
if err == 'not_found':
return jsonify({'error': 'Not found'}), 404
if err == 'invalid_label':
return jsonify({'error': 'Invalid label'}), 400
if err == 'conflict':
return jsonify({'error': 'Label conflict'}), 409
return jsonify(contact)
@contacts_bp.route('/api/contacts/<int:contact_id>/purge', methods=['DELETE'])
@require_auth
def purge_contact(contact_id):
db = _get_db()
ok, err = db.purge(request.user_id, contact_id)
if err == 'not_found':
return jsonify({'error': 'Not found'}), 404
if err == 'not_deleted':
return jsonify({'error': 'Contact must be deleted before purging'}), 400
return jsonify({'ok': True})

View file

@ -21,7 +21,7 @@
<a href="/peertube"{% if domain == 'peertube' %} class="active"{% endif %}>PeerTube</a>
<a href="/kiwix"{% if domain == 'kiwix' %} class="active"{% endif %}>Kiwix</a>
<a href="/search"{% if domain == 'search' %} class="active"{% endif %}>Search</a>
<a href="/nav-i"{% if domain == 'navi' %} class="active"{% endif %}>Nav-I</a>
<a href="/nav-i/api-keys"{% if domain == 'navi' %} class="active"{% endif %}>Nav-I</a>
<a href="/settings/keys"{% if domain == 'settings' %} class="active"{% endif %}>Settings</a>
</div>
{% if subnav %}

View file

@ -1,116 +0,0 @@
{% extends "base.html" %}
{% block content %}
<h3 style="color:var(--orange);margin-bottom:16px;">Deleted Contacts</h3>
{% if not contacts %}
<p class="text-dim">No deleted contacts.</p>
{% else %}
<table>
<tr><th>Label</th><th>Name</th><th>Category</th><th>Phone</th><th>Deleted At</th><th>Actions</th></tr>
{% for c in contacts %}
<tr id="row-{{ c.id }}">
<td>{{ c.label }}</td>
<td>{{ c.name or '' }}</td>
<td class="text-dim">{{ c.category or '' }}</td>
<td class="text-dim text-xs">{{ c.phone or '' }}</td>
<td class="text-dim text-xs">{{ c.deleted_at or '' }}</td>
<td>
<button class="btn" onclick="restoreContact({{ c.id }}, '{{ c.label }}')">Restore</button>
<button class="btn" style="margin-left:4px;color:#ff4444;" onclick="purgeContact({{ c.id }})">Purge</button>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
<!-- Conflict resolution modal -->
<div id="conflict-modal" style="display:none;position:fixed;inset:0;z-index:50;background:rgba(0,0,0,0.6);align-items:center;justify-content:center;">
<div style="background:var(--bg-secondary);border:1px solid var(--border-light);padding:24px;max-width:400px;width:90%;">
<h4 style="color:var(--orange);margin-bottom:12px;">Label Conflict</h4>
<p class="text-dim" style="margin-bottom:16px;">An active contact with the label "<span id="conflict-label" style="color:var(--text-primary);"></span>" already exists. Choose a new label to restore this contact:</p>
<input id="conflict-new-label" type="text" placeholder="New label..." style="width:100%;padding:6px 10px;background:var(--bg-tertiary);border:1px solid var(--border-light);color:var(--text-primary);font-family:var(--font-mono);font-size:13px;margin-bottom:16px;">
<div style="display:flex;gap:8px;justify-content:flex-end;">
<button class="btn" onclick="closeConflictModal()">Cancel</button>
<button class="btn" id="conflict-submit" onclick="submitRestoreAs()" style="border-color:var(--green);color:var(--green);">Restore As</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
var pendingRestoreId = null;
async function restoreContact(id, label) {
try {
var resp = await fetch('/api/contacts/' + id + '/restore', {method: 'POST'});
if (resp.ok) {
location.reload();
} else if (resp.status === 409) {
// Home/Work conflict — show modal
pendingRestoreId = id;
document.getElementById('conflict-label').textContent = label;
document.getElementById('conflict-new-label').value = '';
var modal = document.getElementById('conflict-modal');
modal.style.display = 'flex';
document.getElementById('conflict-new-label').focus();
} else {
var data = await resp.json();
alert(data.error || 'Restore failed');
}
} catch(e) {
alert('Error: ' + e.message);
}
}
function closeConflictModal() {
document.getElementById('conflict-modal').style.display = 'none';
pendingRestoreId = null;
}
async function submitRestoreAs() {
var newLabel = document.getElementById('conflict-new-label').value.trim();
if (!newLabel) {
document.getElementById('conflict-new-label').style.borderColor = 'var(--red)';
return;
}
try {
var resp = await fetch('/api/contacts/' + pendingRestoreId + '/restore-as', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({label: newLabel})
});
if (resp.ok) {
location.reload();
} else {
var data = await resp.json();
alert(data.error || 'Restore failed');
}
} catch(e) {
alert('Error: ' + e.message);
}
}
async function purgeContact(id) {
if (!confirm('Permanently delete this contact? This cannot be undone.')) return;
try {
var resp = await fetch('/api/contacts/' + id + '/purge', {method: 'DELETE'});
if (resp.ok) {
location.reload();
} else {
var data = await resp.json();
alert(data.error || 'Purge failed');
}
} catch(e) {
alert('Error: ' + e.message);
}
}
// Close modal on Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeConflictModal();
});
// Close modal on backdrop click
document.getElementById('conflict-modal').addEventListener('click', function(e) {
if (e.target === this) closeConflictModal();
});
</script>
{% endblock %}

View file

@ -1,22 +0,0 @@
{% extends "base.html" %}
{% block content %}
<h3 style="color:var(--green);margin-bottom:16px;">Nav-I</h3>
<p class="text-dim" style="margin-bottom:24px;">Navi frontend management — contacts, API keys, and configuration.</p>
<div class="stat-grid">
<a href="/deleted-contacts" style="text-decoration:none;">
<div class="stat-card" style="cursor:pointer;transition:border-color 0.15s;">
<div class="label">Deleted Contacts</div>
<div class="value">{{ deleted_count }}</div>
<div class="sublabel">awaiting restore or purge</div>
</div>
</a>
<a href="/nav-i/api-keys" style="text-decoration:none;">
<div class="stat-card" style="cursor:pointer;transition:border-color 0.15s;">
<div class="label">API Keys</div>
<div class="value" style="font-size:14px;color:var(--text-dim);margin-top:12px;">Coming soon</div>
<div class="sublabel">per-user key management</div>
</div>
</a>
</div>
{% endblock %}