feat: admin observability — platform overview, audit log, purge, notifications
- GET /api/admin/overview: platform totals (users, orgs, links, clicks), a 30-day click series, top links, and per-user quota consumption with near-limit flags (org members show pooled usage). - Audit logging wired into all admin mutations: user create/update/delete, password resets, org create/update/delete, purges. Browsable via GET /api/admin/audit and a new Audit Log section in the admin UI. - POST /api/admin/purge hard-deletes soft-deleted links older than N days, freeing their short codes and click history. - Contact-form submissions now fire a WEBHOOK_URL notification. - Fix: deleting a user who owned links or tags failed with a foreign-key 500 — their links/tags are now deliberately orphaned (kept in the DB, unowned) and their API keys removed, matching the UI's wording. - Admin UI: Platform Overview stat cards, quota-usage table with ⚠ NEAR LIMIT badges, audit table, and a purge action. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1906,6 +1906,8 @@ def admin_create_user():
|
||||
)
|
||||
user = conn.execute('SELECT id, username, is_admin, plan, created_at FROM users WHERE username=?',
|
||||
(username,)).fetchone()
|
||||
log_audit(conn, 'user.create', target=username,
|
||||
details=f'plan={plan} admin={bool(is_admin)}')
|
||||
return jsonify(dict(user)), 201
|
||||
|
||||
|
||||
@@ -1942,6 +1944,8 @@ def admin_update_user(user_id):
|
||||
'o.name as org_name FROM users u LEFT JOIN organizations o ON u.org_id=o.id '
|
||||
'WHERE u.id=?', (user_id,)
|
||||
).fetchone()
|
||||
log_audit(conn, 'user.update', target=user['username'],
|
||||
details=', '.join(f'{k}={v}' for k, v in updates.items()))
|
||||
return jsonify(dict(user))
|
||||
|
||||
|
||||
@@ -1954,7 +1958,13 @@ def admin_delete_user(user_id):
|
||||
user = conn.execute('SELECT * FROM users WHERE id=?', (user_id,)).fetchone()
|
||||
if not user:
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
# Orphan the user's links/tags (they stay in the DB, unowned) and drop
|
||||
# their API keys — otherwise the FK constraints reject the delete.
|
||||
conn.execute('UPDATE links SET user_id=NULL WHERE user_id=?', (user_id,))
|
||||
conn.execute('UPDATE tags SET user_id=NULL WHERE user_id=?', (user_id,))
|
||||
conn.execute('DELETE FROM api_keys WHERE user_id=?', (user_id,))
|
||||
conn.execute('DELETE FROM users WHERE id=?', (user_id,))
|
||||
log_audit(conn, 'user.delete', target=user['username'])
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@@ -1966,10 +1976,12 @@ def admin_change_password(user_id):
|
||||
if not password:
|
||||
return jsonify({'error': 'Password required'}), 400
|
||||
with get_db() as conn:
|
||||
if not conn.execute('SELECT 1 FROM users WHERE id=?', (user_id,)).fetchone():
|
||||
user = conn.execute('SELECT username FROM users WHERE id=?', (user_id,)).fetchone()
|
||||
if not user:
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
conn.execute('UPDATE users SET password_hash=? WHERE id=?',
|
||||
(generate_password_hash(password), user_id))
|
||||
log_audit(conn, 'user.password_reset', target=user['username'])
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@@ -1992,6 +2004,7 @@ def submit_contact():
|
||||
'INSERT INTO messages (name, email, subject, body, created_at) VALUES (?,?,?,?,?)',
|
||||
(name, email, subject, body, now)
|
||||
)
|
||||
notify_webhook(f'📨 New {APP_NAME} contact message from {name} <{email}>: {subject}')
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@@ -2059,6 +2072,7 @@ def admin_create_organization():
|
||||
org = conn.execute(
|
||||
'SELECT id, name, plan, created_at FROM organizations WHERE name=?', (name,)
|
||||
).fetchone()
|
||||
log_audit(conn, 'org.create', target=name, details=f'plan={plan}')
|
||||
return jsonify(dict(org)), 201
|
||||
|
||||
|
||||
@@ -2089,6 +2103,8 @@ def admin_update_organization(org_id):
|
||||
'FROM organizations o LEFT JOIN users u ON u.org_id=o.id WHERE o.id=?',
|
||||
(org_id,)
|
||||
).fetchone()
|
||||
log_audit(conn, 'org.update', target=org['name'],
|
||||
details=', '.join(f'{k}={v}' for k, v in updates.items()))
|
||||
return jsonify(dict(org))
|
||||
|
||||
|
||||
@@ -2096,14 +2112,118 @@ def admin_update_organization(org_id):
|
||||
@admin_required
|
||||
def admin_delete_organization(org_id):
|
||||
with get_db() as conn:
|
||||
if not conn.execute('SELECT 1 FROM organizations WHERE id=?', (org_id,)).fetchone():
|
||||
org = conn.execute('SELECT name FROM organizations WHERE id=?', (org_id,)).fetchone()
|
||||
if not org:
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
# Remove org_id from all members before deleting
|
||||
conn.execute('UPDATE users SET org_id=NULL WHERE org_id=?', (org_id,))
|
||||
conn.execute('DELETE FROM organizations WHERE id=?', (org_id,))
|
||||
log_audit(conn, 'org.delete', target=org['name'])
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Admin — Platform overview, audit log, data lifecycle
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
@app.route('/api/admin/overview')
|
||||
@admin_required
|
||||
def admin_overview():
|
||||
"""Bird's-eye platform view: totals, 30-day click series, per-user quota
|
||||
consumption with near-limit flags, and top links platform-wide."""
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
since_30d = (now - timedelta(days=30)).isoformat()
|
||||
since_7d = (now - timedelta(days=7)).isoformat()
|
||||
today = now.strftime('%Y-%m-%d')
|
||||
|
||||
with get_db() as conn:
|
||||
totals = {
|
||||
'users': conn.execute('SELECT COUNT(*) FROM users').fetchone()[0],
|
||||
'organizations':conn.execute('SELECT COUNT(*) FROM organizations').fetchone()[0],
|
||||
'active_links': conn.execute('SELECT COUNT(*) FROM links WHERE is_active=1').fetchone()[0],
|
||||
'deleted_links':conn.execute('SELECT COUNT(*) FROM links WHERE is_active=0').fetchone()[0],
|
||||
'total_clicks': conn.execute('SELECT COALESCE(SUM(clicks),0) FROM links').fetchone()[0],
|
||||
'clicks_today': conn.execute('SELECT COUNT(*) FROM clicks WHERE clicked_at>=?', (today,)).fetchone()[0],
|
||||
'clicks_7d': conn.execute('SELECT COUNT(*) FROM clicks WHERE clicked_at>=?', (since_7d,)).fetchone()[0],
|
||||
'clicks_30d': conn.execute('SELECT COUNT(*) FROM clicks WHERE clicked_at>=?', (since_30d,)).fetchone()[0],
|
||||
}
|
||||
|
||||
daily_rows = conn.execute("""
|
||||
SELECT substr(clicked_at,1,10) as day, COUNT(*) as count
|
||||
FROM clicks WHERE clicked_at>=? GROUP BY day ORDER BY day
|
||||
""", (since_30d,)).fetchall()
|
||||
daily_map = {r['day']: r['count'] for r in daily_rows}
|
||||
daily = []
|
||||
for i in range(30):
|
||||
d = (now - timedelta(days=29-i)).strftime('%Y-%m-%d')
|
||||
daily.append({'date': d, 'clicks': daily_map.get(d, 0)})
|
||||
|
||||
top_links = [dict(r) for r in conn.execute("""
|
||||
SELECT l.code, l.title, l.long_url, l.clicks, u.username as created_by
|
||||
FROM links l LEFT JOIN users u ON l.user_id=u.id
|
||||
WHERE l.is_active=1 ORDER BY l.clicks DESC LIMIT 10
|
||||
""").fetchall()]
|
||||
|
||||
# Per-user quota consumption (org members show pooled usage)
|
||||
users = conn.execute(
|
||||
'SELECT u.id, u.username, u.is_admin, o.name as org_name '
|
||||
'FROM users u LEFT JOIN organizations o ON u.org_id=o.id ORDER BY u.username'
|
||||
).fetchall()
|
||||
usage = []
|
||||
for u in users:
|
||||
plan = get_user_plan(conn, u['id'])
|
||||
limits = PLAN_LIMITS[plan]
|
||||
links_used = get_user_active_link_count(conn, u['id'])
|
||||
clicks_used = get_user_monthly_clicks(conn, u['id'])
|
||||
def _pct(used, limit):
|
||||
return round(used / limit * 100, 1) if limit else None
|
||||
links_pct = _pct(links_used, limits['max_links'])
|
||||
clicks_pct = _pct(clicks_used, limits['monthly_clicks'])
|
||||
usage.append({
|
||||
'id': u['id'], 'username': u['username'], 'is_admin': bool(u['is_admin']),
|
||||
'org_name': u['org_name'], 'plan': plan,
|
||||
'links_used': links_used, 'links_limit': limits['max_links'], 'links_pct': links_pct,
|
||||
'clicks_used': clicks_used,'clicks_limit': limits['monthly_clicks'], 'clicks_pct': clicks_pct,
|
||||
'near_limit': bool((links_pct and links_pct >= 80) or (clicks_pct and clicks_pct >= 80)),
|
||||
})
|
||||
|
||||
return jsonify({'totals': totals, 'daily': daily, 'top_links': top_links, 'usage': usage})
|
||||
|
||||
|
||||
@app.route('/api/admin/audit')
|
||||
@admin_required
|
||||
def admin_audit_log():
|
||||
limit = min(int(request.args.get('limit', 200)), 1000)
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
'SELECT * FROM audit_log ORDER BY id DESC LIMIT ?', (limit,)
|
||||
).fetchall()
|
||||
return jsonify({'entries': [dict(r) for r in rows]})
|
||||
|
||||
|
||||
@app.route('/api/admin/purge', methods=['POST'])
|
||||
@admin_required
|
||||
def admin_purge_links():
|
||||
"""Hard-delete soft-deleted links (and their clicks/tags) older than N days.
|
||||
Frees their short codes for reuse and reclaims click storage."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
days = max(0, int(data.get('days', 30)))
|
||||
cutoff = (datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)).isoformat()
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
'SELECT id FROM links WHERE is_active=0 AND (deleted_at IS NULL OR deleted_at < ?)',
|
||||
(cutoff,)
|
||||
).fetchall()
|
||||
ids = [r['id'] for r in rows]
|
||||
if ids:
|
||||
ph = ','.join('?' * len(ids))
|
||||
conn.execute(f'DELETE FROM clicks WHERE link_id IN ({ph})', ids)
|
||||
conn.execute(f'DELETE FROM link_tags WHERE link_id IN ({ph})', ids)
|
||||
conn.execute(f'DELETE FROM links WHERE id IN ({ph})', ids)
|
||||
log_audit(conn, 'links.purge', details=f'{len(ids)} soft-deleted links purged (deleted >{days}d ago)')
|
||||
return jsonify({'purged': len(ids)})
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# SEO — robots.txt & sitemap.xml
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user