1435 lines
54 KiB
Python
1435 lines
54 KiB
Python
"""
|
|
GreySec Security — Flask REST API
|
|
Replaces PostgREST with business logic, auth, and relationship traversal.
|
|
Run: python3 greysec_api.py
|
|
"""
|
|
|
|
import os
|
|
import json
|
|
import psycopg2
|
|
from flask import Flask, request, jsonify, g
|
|
from flask_cors import CORS
|
|
|
|
app = Flask(__name__)
|
|
|
|
# ── Database ──────────────────────────────────────────────────────────────────
|
|
|
|
def get_db():
|
|
if 'db' not in g:
|
|
g.db = psycopg2.connect(
|
|
host=os.environ.get('DB_HOST', 'localhost'),
|
|
port=int(os.environ.get('DB_PORT', 5432)),
|
|
database=os.environ.get('DB_NAME', 'greysecthreat'),
|
|
user=os.environ.get('DB_USER', 'greysec'),
|
|
password=os.environ.get('DB_PASS', 'GreySecDBPass2026!')
|
|
)
|
|
return g.db
|
|
|
|
@app.teardown_appcontext
|
|
def close_db(e=None):
|
|
db = g.pop('db', None)
|
|
if db is not None:
|
|
db.close()
|
|
|
|
# ── Auth ───────────────────────────────────────────────────────────────────────
|
|
|
|
API_KEY = os.environ.get('GREYSEC_API_KEY', 'greysec-dev-key-2026')
|
|
# Accept both legacy key and new key for backwards compat
|
|
VALID_KEYS = {API_KEY, 'dev-key-change-in-prod'}
|
|
|
|
def require_auth(f):
|
|
from functools import wraps
|
|
@wraps(f)
|
|
def authed(*args, **kwargs):
|
|
key = request.headers.get('Authorization', '').replace('Bearer ', '')
|
|
if key not in VALID_KEYS:
|
|
return jsonify({'error': 'unauthorized'}), 401
|
|
return f(*args, **kwargs)
|
|
return authed
|
|
|
|
# ── Routes ────────────────────────────────────────────────────────────────────
|
|
|
|
@app.route('/health')
|
|
def health():
|
|
return jsonify({'status': 'ok', 'service': 'greysec-api'})
|
|
|
|
# ── Companies ────────────────────────────────────────────────────────────────
|
|
|
|
@app.route('/rest/v1/companies', methods=['GET'])
|
|
@app.route('/rest/v1/companies/<uuid:company_id>', methods=['GET'])
|
|
@require_auth
|
|
def list_companies(company_id=None):
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
if company_id:
|
|
cur.execute('SELECT * FROM companies WHERE id = %s', (str(company_id),))
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return jsonify({'error': 'not found'}), 404
|
|
cols = [d[0] for d in cur.description]
|
|
return jsonify(dict(zip(cols, row)))
|
|
# Filtering
|
|
status = request.args.get('status')
|
|
tier = request.args.get('tier')
|
|
limit = min(int(request.args.get('limit', 50)), 200)
|
|
offset = int(request.args.get('offset', 0))
|
|
query = 'SELECT * FROM companies WHERE 1=1'
|
|
params = []
|
|
if status:
|
|
query += ' AND status = %s'
|
|
params.append(status)
|
|
if tier:
|
|
query += ' AND tier = %s'
|
|
params.append(tier)
|
|
query += f' ORDER BY created_at DESC LIMIT {limit} OFFSET {offset}'
|
|
cur.execute(query, params)
|
|
cols = [d[0] for d in cur.description]
|
|
rows = [dict(zip(cols, r)) for r in cur.fetchall()]
|
|
return jsonify(rows)
|
|
|
|
@app.route('/rest/v1/companies', methods=['POST'])
|
|
@require_auth
|
|
def create_company():
|
|
db = get_db()
|
|
data = request.json
|
|
cur = db.cursor()
|
|
cols = ['name','domain','industry','employee_count','annual_revenue','sources','tier','status','notes']
|
|
vals = [data.get(c) for c in cols]
|
|
placeholders = ','.join(['%s'] * len(cols))
|
|
cur.execute(
|
|
f"INSERT INTO companies ({','.join(cols)}) VALUES ({placeholders}) RETURNING *",
|
|
vals
|
|
)
|
|
row = cur.fetchone()
|
|
db.commit()
|
|
result_cols = [d[0] for d in cur.description]
|
|
return jsonify(dict(zip(result_cols, row))), 201
|
|
|
|
@app.route('/rest/v1/companies/<uuid:company_id>', methods=['PATCH'])
|
|
@require_auth
|
|
def update_company(company_id):
|
|
db = get_db()
|
|
data = request.json
|
|
cur = db.cursor()
|
|
set_clauses = [f"{k} = %s" for k in data.keys()]
|
|
cur.execute(
|
|
f"UPDATE companies SET {','.join(set_clauses)} WHERE id = %s RETURNING *",
|
|
list(data.values()) + [str(company_id)]
|
|
)
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return jsonify({'error': 'not found'}), 404
|
|
db.commit()
|
|
result_cols = [d[0] for d in cur.description]
|
|
return jsonify(dict(zip(result_cols, row)))
|
|
|
|
@app.route('/rest/v1/companies/<uuid:company_id>', methods=['DELETE'])
|
|
@require_auth
|
|
def delete_company(company_id):
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
cur.execute('DELETE FROM companies WHERE id = %s RETURNING id', (str(company_id),))
|
|
if not cur.fetchone():
|
|
return jsonify({'error': 'not found'}), 404
|
|
db.commit()
|
|
return '', 204
|
|
|
|
# ── Contacts ──────────────────────────────────────────────────────────────────
|
|
|
|
@app.route('/rest/v1/contacts', methods=['GET'])
|
|
@app.route('/rest/v1/companies/<uuid:company_id>/contacts', methods=['GET'])
|
|
@require_auth
|
|
def list_contacts(company_id=None):
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
if company_id:
|
|
cur.execute('SELECT * FROM contacts WHERE company_id = %s ORDER BY created_at', (str(company_id),))
|
|
else:
|
|
cur.execute('SELECT * FROM contacts ORDER BY created_at DESC LIMIT 50')
|
|
cols = [d[0] for d in cur.description]
|
|
return jsonify([dict(zip(cols, r)) for r in cur.fetchall()])
|
|
|
|
@app.route('/rest/v1/contacts', methods=['POST'])
|
|
@require_auth
|
|
def create_contact():
|
|
db = get_db()
|
|
data = request.json
|
|
cur = db.cursor()
|
|
cols = ['company_id','name','title','email','linkedin_url','phone','is_decision_maker']
|
|
vals = [data.get(c) for c in cols]
|
|
placeholders = ','.join(['%s'] * len(cols))
|
|
cur.execute(
|
|
f"INSERT INTO contacts ({','.join(cols)}) VALUES ({placeholders}) RETURNING *",
|
|
vals
|
|
)
|
|
row = cur.fetchone()
|
|
db.commit()
|
|
result_cols = [d[0] for d in cur.description]
|
|
return jsonify(dict(zip(result_cols, row))), 201
|
|
|
|
# ── Engagements ───────────────────────────────────────────────────────────────
|
|
|
|
@app.route('/rest/v1/engagements', methods=['GET'])
|
|
@app.route('/rest/v1/companies/<uuid:company_id>/engagements', methods=['GET'])
|
|
@require_auth
|
|
def list_engagements(company_id=None):
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
if company_id:
|
|
cur.execute('SELECT * FROM engagements WHERE company_id = %s ORDER BY created_at', (str(company_id),))
|
|
else:
|
|
cur.execute('SELECT * FROM engagements ORDER BY created_at DESC LIMIT 50')
|
|
cols = [d[0] for d in cur.description]
|
|
return jsonify([dict(zip(cols, r)) for r in cur.fetchall()])
|
|
|
|
@app.route('/rest/v1/engagements', methods=['POST'])
|
|
@require_auth
|
|
def create_engagement():
|
|
db = get_db()
|
|
data = request.json
|
|
cur = db.cursor()
|
|
cols = ['company_id','type','scope','status','start_date','end_date','price_cents','notes']
|
|
vals = [data.get(c) for c in cols]
|
|
placeholders = ','.join(['%s'] * len(cols))
|
|
cur.execute(
|
|
f"INSERT INTO engagements ({','.join(cols)}) VALUES ({placeholders}) RETURNING *",
|
|
vals
|
|
)
|
|
row = cur.fetchone()
|
|
db.commit()
|
|
result_cols = [d[0] for d in cur.description]
|
|
return jsonify(dict(zip(result_cols, row))), 201
|
|
|
|
@app.route('/rest/v1/engagements/<uuid:engagement_id>', methods=['PATCH'])
|
|
@require_auth
|
|
def update_engagement(engagement_id):
|
|
db = get_db()
|
|
data = request.json
|
|
cur = db.cursor()
|
|
set_clauses = [f"{k} = %s" for k in data.keys()]
|
|
cur.execute(
|
|
f"UPDATE engagements SET {','.join(set_clauses)} WHERE id = %s RETURNING *",
|
|
list(data.values()) + [str(engagement_id)]
|
|
)
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return jsonify({'error': 'not found'}), 404
|
|
db.commit()
|
|
result_cols = [d[0] for d in cur.description]
|
|
return jsonify(dict(zip(result_cols, row)))
|
|
|
|
# ── OSINT Scans & Findings ────────────────────────────────────────────────────
|
|
|
|
@app.route('/rest/v1/osint_scans', methods=['GET'])
|
|
@app.route('/rest/v1/companies/<uuid:company_id>/osint_scans', methods=['GET'])
|
|
@require_auth
|
|
def list_osint_scans(company_id=None):
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
limit = min(int(request.args.get('limit', 50)), 200)
|
|
if company_id:
|
|
cur.execute(
|
|
'SELECT * FROM osint_scans WHERE company_id = %s ORDER BY scan_date DESC LIMIT %s',
|
|
(str(company_id), limit)
|
|
)
|
|
else:
|
|
cur.execute(f'SELECT * FROM osint_scans ORDER BY scan_date DESC LIMIT {limit}')
|
|
cols = [d[0] for d in cur.description]
|
|
return jsonify([dict(zip(cols, r)) for r in cur.fetchall()])
|
|
|
|
@app.route('/rest/v1/osint_scans', methods=['POST'])
|
|
@require_auth
|
|
def create_osint_scan():
|
|
db = get_db()
|
|
data = request.json
|
|
cur = db.cursor()
|
|
cols = ['company_id','scan_type','scan_date','total_findings','exposed_service_count',
|
|
'subdomain_count','email_count','github_repo_count','s3_bucket_count',
|
|
'spiderfoot_module','raw_json_path','summary_md_path','gbrain_page_slug']
|
|
vals = [data.get(c) for c in cols]
|
|
placeholders = ','.join(['%s'] * len(cols))
|
|
cur.execute(
|
|
f"INSERT INTO osint_scans ({','.join(cols)}) VALUES ({placeholders}) RETURNING *",
|
|
vals
|
|
)
|
|
row = cur.fetchone()
|
|
db.commit()
|
|
result_cols = [d[0] for d in cur.description]
|
|
return jsonify(dict(zip(result_cols, row))), 201
|
|
|
|
@app.route('/rest/v1/osint_findings', methods=['GET'])
|
|
@app.route('/rest/v1/companies/<uuid:company_id>/osint_findings', methods=['GET'])
|
|
@require_auth
|
|
def list_osint_findings(company_id=None):
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
limit = min(int(request.args.get('limit', 200)), 1000)
|
|
if company_id:
|
|
cur.execute(
|
|
'SELECT * FROM osint_findings WHERE company_id = %s ORDER BY created_at DESC LIMIT %s',
|
|
(str(company_id), limit)
|
|
)
|
|
else:
|
|
cur.execute(f'SELECT * FROM osint_findings ORDER BY created_at DESC LIMIT {limit}')
|
|
cols = [d[0] for d in cur.description]
|
|
return jsonify([dict(zip(cols, r)) for r in cur.fetchall()])
|
|
|
|
@app.route('/rest/v1/osint_findings', methods=['POST'])
|
|
@require_auth
|
|
def create_osint_finding():
|
|
db = get_db()
|
|
data = request.json
|
|
cur = db.cursor()
|
|
cols = ['scan_id','company_id','finding_type','value','source_module','risk_score','tags']
|
|
vals = [data.get(c) for c in cols]
|
|
placeholders = ','.join(['%s'] * len(cols))
|
|
cur.execute(
|
|
f"INSERT INTO osint_findings ({','.join(cols)}) VALUES ({placeholders}) RETURNING *",
|
|
vals
|
|
)
|
|
row = cur.fetchone()
|
|
db.commit()
|
|
result_cols = [d[0] for d in cur.description]
|
|
return jsonify(dict(zip(result_cols, row))), 201
|
|
|
|
# ── Prowler Findings ──────────────────────────────────────────────────────────
|
|
|
|
@app.route('/rest/v1/prowler_findings', methods=['GET'])
|
|
@app.route('/rest/v1/companies/<uuid:company_id>/prowler_findings', methods=['GET'])
|
|
@require_auth
|
|
def list_prowler_findings(company_id=None):
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
limit = min(int(request.args.get('limit', 200)), 1000)
|
|
severity = request.args.get('severity')
|
|
if company_id:
|
|
query = 'SELECT * FROM prowler_findings WHERE company_id = %s'
|
|
params = [str(company_id)]
|
|
if severity:
|
|
query += ' AND severity = %s'
|
|
params.append(severity)
|
|
query += f' ORDER BY created_at DESC LIMIT {limit}'
|
|
cur.execute(query, params)
|
|
else:
|
|
cur.execute(f'SELECT * FROM prowler_findings ORDER BY created_at DESC LIMIT {limit}')
|
|
cols = [d[0] for d in cur.description]
|
|
return jsonify([dict(zip(cols, r)) for r in cur.fetchall()])
|
|
|
|
@app.route('/rest/v1/prowler_findings', methods=['POST'])
|
|
@require_auth
|
|
def create_prowler_finding():
|
|
db = get_db()
|
|
data = request.json
|
|
cur = db.cursor()
|
|
cols = ['engagement_id','company_id','provider','region','finding_id','check_result',
|
|
'severity','title','description','compliance_framework','risk_score','raw_output_path']
|
|
vals = [data.get(c) for c in cols]
|
|
placeholders = ','.join(['%s'] * len(cols))
|
|
cur.execute(
|
|
f"INSERT INTO prowler_findings ({','.join(cols)}) VALUES ({placeholders}) RETURNING *",
|
|
vals
|
|
)
|
|
row = cur.fetchone()
|
|
db.commit()
|
|
result_cols = [d[0] for d in cur.description]
|
|
return jsonify(dict(zip(result_cols, row))), 201
|
|
|
|
# ── Pipeline ──────────────────────────────────────────────────────────────────
|
|
|
|
@app.route('/rest/v1/pipeline', methods=['GET'])
|
|
@app.route('/rest/v1/companies/<uuid:company_id>/pipeline', methods=['GET'])
|
|
@require_auth
|
|
def list_pipeline(company_id=None):
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
if company_id:
|
|
cur.execute('SELECT * FROM pipeline WHERE company_id = %s ORDER BY created_at', (str(company_id),))
|
|
else:
|
|
cur.execute('SELECT * FROM pipeline ORDER BY created_at DESC')
|
|
cols = [d[0] for d in cur.description]
|
|
return jsonify([dict(zip(cols, r)) for r in cur.fetchall()])
|
|
|
|
@app.route('/rest/v1/pipeline', methods=['POST'])
|
|
@require_auth
|
|
def create_pipeline_stage():
|
|
db = get_db()
|
|
data = request.json
|
|
cur = db.cursor()
|
|
cols = ['company_id','engagement_id','stage','probability','expected_close_date','amount_cents','notes','updated_by']
|
|
vals = [data.get(c) for c in cols]
|
|
placeholders = ','.join(['%s'] * len(cols))
|
|
cur.execute(
|
|
f"INSERT INTO pipeline ({','.join(cols)}) VALUES ({placeholders}) RETURNING *",
|
|
vals
|
|
)
|
|
row = cur.fetchone()
|
|
db.commit()
|
|
result_cols = [d[0] for d in cur.description]
|
|
return jsonify(dict(zip(result_cols, row))), 201
|
|
|
|
# ── Phishing Campaigns & Events ────────────────────────────────────────────────
|
|
|
|
@app.route('/rest/v1/phishing_campaigns', methods=['GET'])
|
|
@require_auth
|
|
def list_phishing_campaigns():
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
cur.execute('SELECT * FROM phishing_campaigns ORDER BY created_at DESC')
|
|
cols = [d[0] for d in cur.description]
|
|
return jsonify([dict(zip(cols, r)) for r in cur.fetchall()])
|
|
|
|
@app.route('/rest/v1/phishing_campaigns', methods=['POST'])
|
|
@require_auth
|
|
def create_phishing_campaign():
|
|
db = get_db()
|
|
data = request.json
|
|
cur = db.cursor()
|
|
cols = ['engagement_id','company_id','campaign_name','gophish_campaign_id','status',
|
|
'pretext_type','target_count','sent_count','start_date','end_date','notes']
|
|
vals = [data.get(c) for c in cols]
|
|
placeholders = ','.join(['%s'] * len(cols))
|
|
cur.execute(
|
|
f"INSERT INTO phishing_campaigns ({','.join(cols)}) VALUES ({placeholders}) RETURNING *",
|
|
vals
|
|
)
|
|
row = cur.fetchone()
|
|
db.commit()
|
|
result_cols = [d[0] for d in cur.description]
|
|
return jsonify(dict(zip(result_cols, row))), 201
|
|
|
|
@app.route('/rest/v1/phishing_events', methods=['POST'])
|
|
@require_auth
|
|
def create_phishing_event():
|
|
db = get_db()
|
|
data = request.json
|
|
cur = db.cursor()
|
|
cols = ['campaign_id','event_type','target_email','ip_address','user_agent','redirect_url','creds_payload']
|
|
vals = [data.get(c) for c in cols]
|
|
placeholders = ','.join(['%s'] * len(cols))
|
|
cur.execute(
|
|
f"INSERT INTO phishing_events ({','.join(cols)}) VALUES ({placeholders}) RETURNING *",
|
|
vals
|
|
)
|
|
row = cur.fetchone()
|
|
db.commit()
|
|
result_cols = [d[0] for d in cur.description]
|
|
return jsonify(dict(zip(result_cols, row))), 201
|
|
|
|
# ── CRM: Contact Interactions ─────────────────────────────────────────────────
|
|
|
|
@app.route('/rest/v1/contact_interactions', methods=['GET'])
|
|
@require_auth
|
|
def list_contact_interactions():
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
limit = request.args.get('limit', 50, type=int)
|
|
offset = request.args.get('offset', 0, type=int)
|
|
company_id = request.args.get('company_id')
|
|
contact_id = request.args.get('contact_id')
|
|
interaction_type = request.args.get('type')
|
|
|
|
where = []
|
|
params = []
|
|
if company_id:
|
|
where.append("ci.company_id = %s")
|
|
params.append(company_id)
|
|
if contact_id:
|
|
where.append("ci.contact_id = %s")
|
|
params.append(contact_id)
|
|
if interaction_type:
|
|
where.append("ci.type = %s")
|
|
params.append(interaction_type)
|
|
|
|
where_clause = " AND ".join(where) if where else "1=1"
|
|
params.extend([limit, offset])
|
|
|
|
cur.execute(f"""
|
|
SELECT ci.*, c.name as contact_name, co.name as company_name
|
|
FROM contact_interactions ci
|
|
LEFT JOIN contacts c ON ci.contact_id = c.id
|
|
LEFT JOIN companies co ON ci.company_id = co.id
|
|
WHERE {where_clause}
|
|
ORDER BY ci.created_at DESC
|
|
LIMIT %s OFFSET %s
|
|
""", params)
|
|
cols = [d[0] for d in cur.description]
|
|
return jsonify([dict(zip(cols, r)) for r in cur.fetchall()])
|
|
|
|
|
|
@app.route('/rest/v1/contact_interactions', methods=['POST'])
|
|
@require_auth
|
|
def create_contact_interaction():
|
|
db = get_db()
|
|
data = request.json
|
|
cur = db.cursor()
|
|
cols = ['contact_id','company_id','type','direction','subject','summary','outcome','next_action','next_action_date','duration_minutes','created_by']
|
|
vals = [data.get(c) for c in cols]
|
|
placeholders = ','.join(['%s'] * len(cols))
|
|
cur.execute(f"INSERT INTO contact_interactions ({','.join(cols)}) VALUES ({placeholders}) RETURNING *", vals)
|
|
row = cur.fetchone()
|
|
db.commit()
|
|
result_cols = [d[0] for d in cur.description]
|
|
return jsonify(dict(zip(result_cols, row))), 201
|
|
|
|
|
|
# ── CRM: Follow-ups ────────────────────────────────────────────────────────────
|
|
|
|
@app.route('/rest/v1/follow_ups', methods=['GET'])
|
|
@require_auth
|
|
def list_follow_ups():
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
status = request.args.get('status', 'pending')
|
|
assigned_to = request.args.get('assigned_to')
|
|
|
|
where = ["fu.status = %s"]
|
|
params = [status]
|
|
if assigned_to:
|
|
where.append("fu.assigned_to = %s")
|
|
params.append(assigned_to)
|
|
|
|
cur.execute(f"""
|
|
SELECT fu.*, c.name as contact_name, co.name as company_name
|
|
FROM follow_ups fu
|
|
LEFT JOIN contacts c ON fu.contact_id = c.id
|
|
LEFT JOIN companies co ON fu.company_id = co.id
|
|
WHERE {' AND '.join(where)}
|
|
ORDER BY fu.due_date ASC NULLS LAST, fu.priority DESC
|
|
""", params)
|
|
cols = [d[0] for d in cur.description]
|
|
return jsonify([dict(zip(cols, r)) for r in cur.fetchall()])
|
|
|
|
|
|
@app.route('/rest/v1/follow_ups', methods=['POST'])
|
|
@require_auth
|
|
def create_follow_up():
|
|
db = get_db()
|
|
data = request.json
|
|
cur = db.cursor()
|
|
cols = ['contact_id','company_id','engagement_id','assigned_to','priority','status','due_date','description','notes']
|
|
vals = [data.get(c) for c in cols]
|
|
placeholders = ','.join(['%s'] * len(cols))
|
|
cur.execute(f"INSERT INTO follow_ups ({','.join(cols)}) VALUES ({placeholders}) RETURNING *", vals)
|
|
row = cur.fetchone()
|
|
db.commit()
|
|
result_cols = [d[0] for d in cur.description]
|
|
return jsonify(dict(zip(result_cols, row))), 201
|
|
|
|
|
|
@app.route('/rest/v1/follow_ups/<uuid:follow_up_id>', methods=['PATCH'])
|
|
@require_auth
|
|
def update_follow_up(follow_up_id):
|
|
db = get_db()
|
|
data = request.json
|
|
cur = db.cursor()
|
|
fid = str(follow_up_id)
|
|
setters = [f"{k} = %s" for k in data.keys() if k != 'id']
|
|
vals = [data[k] for k in data.keys() if k != 'id']
|
|
vals.append(fid)
|
|
if data.get('status') == 'completed':
|
|
setters.append("completed_at = NOW()")
|
|
cur.execute(f"UPDATE follow_ups SET {','.join(setters)} WHERE id = %s RETURNING *", vals)
|
|
row = cur.fetchone()
|
|
db.commit()
|
|
if not row:
|
|
return jsonify({'error': 'not found'}), 404
|
|
result_cols = [d[0] for d in cur.description]
|
|
return jsonify(dict(zip(result_cols, row)))
|
|
|
|
|
|
# ── CRM: Org Units ─────────────────────────────────────────────────────────────
|
|
|
|
@app.route('/rest/v1/company_org_units', methods=['GET'])
|
|
@require_auth
|
|
def list_org_units():
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
company_id = request.args.get('company_id')
|
|
if company_id:
|
|
cur.execute("SELECT * FROM company_org_units WHERE company_id = %s ORDER BY name", (company_id,))
|
|
else:
|
|
cur.execute("SELECT * FROM company_org_units ORDER BY company_id, name")
|
|
cols = [d[0] for d in cur.description]
|
|
return jsonify([dict(zip(cols, r)) for r in cur.fetchall()])
|
|
|
|
|
|
@app.route('/rest/v1/company_org_units', methods=['POST'])
|
|
@require_auth
|
|
def create_org_unit():
|
|
db = get_db()
|
|
data = request.json
|
|
cur = db.cursor()
|
|
cols = ['company_id','parent_id','name','head_contact_id','description']
|
|
vals = [data.get(c) for c in cols]
|
|
placeholders = ','.join(['%s'] * len(cols))
|
|
cur.execute(f"INSERT INTO company_org_units ({','.join(cols)}) VALUES ({placeholders}) RETURNING *", vals)
|
|
row = cur.fetchone()
|
|
db.commit()
|
|
result_cols = [d[0] for d in cur.description]
|
|
return jsonify(dict(zip(result_cols, row))), 201
|
|
|
|
|
|
# ── CRM: Contact Org Placements ────────────────────────────────────────────────
|
|
|
|
@app.route('/rest/v1/contact_org_placement', methods=['GET'])
|
|
@require_auth
|
|
def list_contact_org_placements():
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
contact_id = request.args.get('contact_id')
|
|
org_unit_id = request.args.get('org_unit_id')
|
|
where = []
|
|
params = []
|
|
if contact_id:
|
|
where.append("cop.contact_id = %s")
|
|
params.append(contact_id)
|
|
if org_unit_id:
|
|
where.append("cop.org_unit_id = %s")
|
|
params.append(org_unit_id)
|
|
where_clause = " AND ".join(where) if where else "1=1"
|
|
cur.execute(f"""
|
|
SELECT cop.*, c.name as contact_name, ou.name as org_unit_name, co.name as company_name
|
|
FROM contact_org_placement cop
|
|
LEFT JOIN contacts c ON cop.contact_id = c.id
|
|
LEFT JOIN company_org_units ou ON cop.org_unit_id = ou.id
|
|
LEFT JOIN companies co ON ou.company_id = co.id
|
|
WHERE {where_clause}
|
|
ORDER BY cop.is_primary_role DESC, c.name
|
|
""", params if params else [])
|
|
cols = [d[0] for d in cur.description]
|
|
return jsonify([dict(zip(cols, r)) for r in cur.fetchall()])
|
|
|
|
|
|
@app.route('/rest/v1/contact_org_placement', methods=['POST'])
|
|
@require_auth
|
|
def create_contact_org_placement():
|
|
db = get_db()
|
|
data = request.json
|
|
cur = db.cursor()
|
|
cols = ['contact_id','org_unit_id','role','is_primary_role']
|
|
vals = [data.get(c) for c in cols]
|
|
placeholders = ','.join(['%s'] * len(cols))
|
|
cur.execute(f"INSERT INTO contact_org_placement ({','.join(cols)}) VALUES ({placeholders}) RETURNING *", vals)
|
|
row = cur.fetchone()
|
|
db.commit()
|
|
result_cols = [d[0] for d in cur.description]
|
|
return jsonify(dict(zip(result_cols, row))), 201
|
|
|
|
|
|
# ── Dashboard / Aggregate Views ────────────────────────────────────────────────
|
|
|
|
@app.route('/rest/v1/dashboard/pipeline-summary')
|
|
@require_auth
|
|
def pipeline_summary():
|
|
"""Sales pipeline aggregated by stage."""
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
cur.execute("""
|
|
SELECT stage,
|
|
COUNT(*) as count,
|
|
SUM(amount_cents) as total_value_cents,
|
|
AVG(probability) as avg_probability
|
|
FROM pipeline p
|
|
JOIN companies c ON p.company_id = c.id
|
|
WHERE c.status IN ('prospect','active')
|
|
GROUP BY stage
|
|
ORDER BY
|
|
CASE stage
|
|
WHEN 'lead' THEN 1 WHEN 'qualified' THEN 2
|
|
WHEN 'proposal' THEN 3 WHEN 'negotiation' THEN 4
|
|
WHEN 'closed_won' THEN 5 WHEN 'closed_lost' THEN 6
|
|
END
|
|
""")
|
|
cols = [d[0] for d in cur.description]
|
|
return jsonify([dict(zip(cols, r)) for r in cur.fetchall()])
|
|
|
|
@app.route('/rest/v1/dashboard/company-intel/<uuid:company_id>')
|
|
@require_auth
|
|
def company_intel(company_id):
|
|
"""All intel about a company: OSINT findings + Prowler findings + contacts + engagements."""
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
cid = str(company_id)
|
|
|
|
cur.execute('SELECT * FROM companies WHERE id = %s', (cid,))
|
|
company = dict(zip([d[0] for d in cur.description], cur.fetchone())) if cur.rowcount else None
|
|
if not company:
|
|
return jsonify({'error': 'not found'}), 404
|
|
|
|
cur.execute('SELECT * FROM contacts WHERE company_id = %s', (cid,))
|
|
contacts = [dict(zip([d[0] for d in cur.description], r)) for r in cur.fetchall()]
|
|
|
|
cur.execute('SELECT * FROM engagements WHERE company_id = %s', (cid,))
|
|
engagements = [dict(zip([d[0] for d in cur.description], r)) for r in cur.fetchall()]
|
|
|
|
cur.execute('SELECT * FROM osint_findings WHERE company_id = %s ORDER BY created_at DESC LIMIT 50', (cid,))
|
|
osint_findings = [dict(zip([d[0] for d in cur.description], r)) for r in cur.fetchall()]
|
|
|
|
cur.execute('SELECT * FROM prowler_findings WHERE company_id = %s ORDER BY created_at DESC LIMIT 50', (cid,))
|
|
prowler_findings = [dict(zip([d[0] for d in cur.description], r)) for r in cur.fetchall()]
|
|
|
|
cur.execute('SELECT * FROM ir_cases WHERE engagement_id IN (SELECT id FROM engagements WHERE company_id = %s) ORDER BY created_at DESC LIMIT 10', (cid,))
|
|
ir_cases = [dict(zip([d[0] for d in cur.description], r)) for r in cur.fetchall()]
|
|
|
|
return jsonify({
|
|
'company': company,
|
|
'contacts': contacts,
|
|
'engagements': engagements,
|
|
'osint_findings': osint_findings,
|
|
'prowler_findings': prowler_findings,
|
|
'ir_cases': ir_cases
|
|
})
|
|
|
|
# ── IR Cases ──────────────────────────────────────────────────────────────────
|
|
|
|
@app.route('/rest/v1/ir_cases', methods=['GET'])
|
|
@app.route('/rest/v1/companies/<uuid:company_id>/ir_cases', methods=['GET'])
|
|
@require_auth
|
|
def list_ir_cases(company_id=None):
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
status = request.args.get('status')
|
|
limit = min(int(request.args.get('limit', 50)), 200)
|
|
offset = int(request.args.get('offset', 0))
|
|
if company_id:
|
|
cur.execute(
|
|
'SELECT ic.* FROM ir_cases ic JOIN engagements e ON e.ir_case_id = ic.id WHERE e.company_id = %s ORDER BY ic.created_at DESC LIMIT %s OFFSET %s',
|
|
(str(company_id), limit, offset)
|
|
)
|
|
elif status:
|
|
cur.execute('SELECT * FROM ir_cases WHERE status = %s ORDER BY created_at DESC LIMIT %s OFFSET %s',
|
|
(status, limit, offset))
|
|
else:
|
|
cur.execute(f'SELECT * FROM ir_cases ORDER BY created_at DESC LIMIT %s OFFSET %s', (limit, offset))
|
|
cols = [d[0] for d in cur.description]
|
|
return jsonify([dict(zip(cols, r)) for r in cur.fetchall()])
|
|
|
|
|
|
@app.route('/rest/v1/ir_cases', methods=['POST'])
|
|
@require_auth
|
|
def create_ir_case():
|
|
db = get_db()
|
|
data = request.json
|
|
cur = db.cursor()
|
|
cols = [
|
|
'engagement_id', 'case_id', 'client', 'detection_date', 'reported_date',
|
|
'opened_date', 'closed_date', 'case_type', 'severity', 'incident_type',
|
|
'status', 'containment_status', 'lead_engineer', 'client_poc',
|
|
'evidence_path', 'classification', 'retention_years', 'coc_manifest', 'notes'
|
|
]
|
|
vals = [data.get(c) for c in cols]
|
|
placeholders = ','.join(['%s'] * len(cols))
|
|
cur.execute(
|
|
f"INSERT INTO ir_cases ({','.join(cols)}) VALUES ({placeholders}) RETURNING *",
|
|
vals
|
|
)
|
|
row = cur.fetchone()
|
|
db.commit()
|
|
result_cols = [d[0] for d in cur.description]
|
|
return jsonify(dict(zip(result_cols, row))), 201
|
|
|
|
|
|
@app.route('/rest/v1/ir_cases/<uuid:ir_case_id>', methods=['GET'])
|
|
@require_auth
|
|
def get_ir_case(ir_case_id):
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
cur.execute('SELECT * FROM ir_cases WHERE id = %s', (str(ir_case_id),))
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return jsonify({'error': 'not found'}), 404
|
|
cols = [d[0] for d in cur.description]
|
|
return jsonify(dict(zip(cols, row)))
|
|
|
|
|
|
@app.route('/rest/v1/ir_cases/<uuid:ir_case_id>', methods=['PATCH'])
|
|
@require_auth
|
|
def update_ir_case(ir_case_id):
|
|
db = get_db()
|
|
data = request.json
|
|
cur = db.cursor()
|
|
# Always update updated_at
|
|
data['updated_at'] = 'NOW()'
|
|
set_clauses = [f"{k} = %s" for k in data.keys()]
|
|
cur.execute(
|
|
f"UPDATE ir_cases SET {','.join(set_clauses)} WHERE id = %s RETURNING *",
|
|
list(data.values()) + [str(ir_case_id)]
|
|
)
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return jsonify({'error': 'not found'}), 404
|
|
db.commit()
|
|
result_cols = [d[0] for d in cur.description]
|
|
return jsonify(dict(zip(result_cols, row)))
|
|
|
|
|
|
@app.route('/rest/v1/ir_cases/<uuid:ir_case_id>', methods=['DELETE'])
|
|
@require_auth
|
|
def delete_ir_case(ir_case_id):
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
cur.execute('DELETE FROM ir_cases WHERE id = %s RETURNING id', (str(ir_case_id),))
|
|
if not cur.fetchone():
|
|
return jsonify({'error': 'not found'}), 404
|
|
db.commit()
|
|
return '', 204
|
|
|
|
|
|
@app.route('/rest/v1/ir_cases/<uuid:ir_case_id>/evidence_summary', methods=['GET'])
|
|
@require_auth
|
|
def ir_case_evidence_summary(ir_case_id):
|
|
"""Return file counts and sizes for each evidence category in this case."""
|
|
import os
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
cur.execute('SELECT evidence_path, case_id FROM ir_cases WHERE id = %s', (str(ir_case_id),))
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return jsonify({'error': 'not found'}), 404
|
|
evidence_path, case_id = row
|
|
result = {'case_id': case_id, 'evidence_path': evidence_path, 'categories': {}}
|
|
if evidence_path and os.path.isdir(evidence_path):
|
|
for cat in ['aws', 'o365', 'windows', 'chain_of_custody']:
|
|
cat_path = os.path.join(evidence_path, cat)
|
|
if os.path.isdir(cat_path):
|
|
files = [f for f in os.listdir(cat_path) if os.path.isfile(os.path.join(cat_path, f))]
|
|
total_size = sum(os.path.getsize(os.path.join(cat_path, f)) for f in files)
|
|
result['categories'][cat] = {'file_count': len(files), 'total_bytes': total_size}
|
|
return jsonify(result)
|
|
|
|
|
|
@app.route('/rest/v1/dashboard/ir-summary', methods=['GET'])
|
|
@require_auth
|
|
def ir_dashboard_summary():
|
|
"""IR case stats for the dashboard."""
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
cur.execute("""
|
|
SELECT
|
|
status,
|
|
COUNT(*) as count,
|
|
CASE severity
|
|
WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 ELSE 4 END as sev_order
|
|
FROM ir_cases
|
|
GROUP BY status, sev_order
|
|
ORDER BY sev_order, status
|
|
""")
|
|
by_status = [{'status': r[0], 'count': r[1]} for r in cur.fetchall()]
|
|
cur.execute("SELECT COUNT(*) FROM ir_cases WHERE status NOT IN ('closed')")
|
|
active = cur.fetchone()[0]
|
|
cur.execute("SELECT COUNT(*) FROM ir_cases WHERE status = 'closed'")
|
|
closed = cur.fetchone()[0]
|
|
return jsonify({'by_status': by_status, 'active': active, 'closed': closed})
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
# PRODUCTS
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
@app.route('/rest/v1/products', methods=['GET'])
|
|
@require_auth
|
|
def list_products():
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
filters = []
|
|
params = []
|
|
for col in ('slug', 'type', 'billable', 'active'):
|
|
val = request.args.get(col)
|
|
if val is not None:
|
|
filters.append(f"{col} = %s")
|
|
params.append(val)
|
|
where = ("WHERE " + " AND ".join(filters)) if filters else ""
|
|
limit = min(int(request.args.get('limit', 100)), 500)
|
|
offset = int(request.args.get('offset', 0))
|
|
cur.execute(f"SELECT * FROM products {where} ORDER BY created_at DESC LIMIT %s OFFSET %s", params + [limit, offset])
|
|
cols = [d[0] for d in cur.description]
|
|
return jsonify([dict(zip(cols, r)) for r in cur.fetchall()])
|
|
|
|
|
|
@app.route('/rest/v1/products/<uuid:product_id>', methods=['GET'])
|
|
@require_auth
|
|
def get_product(product_id):
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
cur.execute("SELECT * FROM products WHERE id = %s", (str(product_id),))
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return jsonify({'error': 'not found'}), 404
|
|
return jsonify(dict(zip([d[0] for d in cur.description], row)))
|
|
|
|
|
|
@app.route('/rest/v1/products', methods=['POST'])
|
|
@require_auth
|
|
def create_product():
|
|
db = get_db()
|
|
data = request.get_json() or {}
|
|
required = ('slug', 'name', 'type')
|
|
for f in required:
|
|
if f not in data:
|
|
return jsonify({'error': f'missing required field: {f}'}), 400
|
|
cur = db.cursor()
|
|
cols = ['slug', 'name', 'description', 'type', 'billing_type', 'default_rate_cents',
|
|
'unit', 'billable', 'active', 'notes']
|
|
sql_cols = []
|
|
sql_vals = []
|
|
sql_params = []
|
|
for c in cols:
|
|
if c in data:
|
|
sql_cols.append(c)
|
|
sql_vals.append('%s')
|
|
sql_params.append(data[c])
|
|
cur.execute(
|
|
f"INSERT INTO products ({', '.join(sql_cols)}) VALUES ({', '.join(sql_vals)}) RETURNING *",
|
|
sql_params
|
|
)
|
|
row = cur.fetchone()
|
|
db.commit()
|
|
return jsonify(dict(zip([d[0] for d in cur.description], row))), 201
|
|
|
|
|
|
@app.route('/rest/v1/products/<uuid:product_id>', methods=['PATCH'])
|
|
@require_auth
|
|
def update_product(product_id):
|
|
db = get_db()
|
|
data = request.get_json() or {}
|
|
if not data:
|
|
return jsonify({'error': 'no fields to update'}), 400
|
|
cur = db.cursor()
|
|
setters = [f"{k} = %s" for k in data]
|
|
cur.execute(
|
|
f"UPDATE products SET {', '.join(setters)} WHERE id = %s RETURNING *",
|
|
list(data.values()) + [str(product_id)]
|
|
)
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return jsonify({'error': 'not found'}), 404
|
|
db.commit()
|
|
return jsonify(dict(zip([d[0] for d in cur.description], row)))
|
|
|
|
|
|
@app.route('/rest/v1/products/<uuid:product_id>', methods=['DELETE'])
|
|
@require_auth
|
|
def delete_product(product_id):
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
cur.execute("DELETE FROM products WHERE id = %s RETURNING id", (str(product_id),))
|
|
if not cur.fetchone():
|
|
return jsonify({'error': 'not found'}), 404
|
|
db.commit()
|
|
return '', 204
|
|
|
|
|
|
@app.route('/rest/v1/products/<uuid:product_id>/revenue', methods=['GET'])
|
|
@require_auth
|
|
def product_revenue(product_id):
|
|
"""Aggregate revenue/cost/margin for a single product."""
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
cur.execute("SELECT * FROM v_product_revenue WHERE product_id = %s", (str(product_id),))
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return jsonify({'error': 'not found'}), 404
|
|
cols = [d[0] for d in cur.description]
|
|
return jsonify(dict(zip(cols, row)))
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
# PROJECTS
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
@app.route('/rest/v1/projects', methods=['GET'])
|
|
@require_auth
|
|
def list_projects():
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
filters = []
|
|
params = []
|
|
for col in ('slug', 'type', 'status', 'billable', 'company_id', 'product_id'):
|
|
val = request.args.get(col)
|
|
if val is not None:
|
|
filters.append(f"{col} = %s")
|
|
params.append(val)
|
|
where = ("WHERE " + " AND ".join(filters)) if filters else ""
|
|
limit = min(int(request.args.get('limit', 100)), 500)
|
|
offset = int(request.args.get('offset', 0))
|
|
cur.execute(f"SELECT * FROM projects {where} ORDER BY created_at DESC LIMIT %s OFFSET %s", params + [limit, offset])
|
|
cols = [d[0] for d in cur.description]
|
|
return jsonify([dict(zip(cols, r)) for r in cur.fetchall()])
|
|
|
|
|
|
@app.route('/rest/v1/projects/<uuid:project_id>', methods=['GET'])
|
|
@require_auth
|
|
def get_project(project_id):
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
cur.execute("SELECT * FROM projects WHERE id = %s", (str(project_id),))
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return jsonify({'error': 'not found'}), 404
|
|
return jsonify(dict(zip([d[0] for d in cur.description], row)))
|
|
|
|
|
|
@app.route('/rest/v1/projects', methods=['POST'])
|
|
@require_auth
|
|
def create_project():
|
|
db = get_db()
|
|
data = request.get_json() or {}
|
|
required = ('slug', 'name', 'type')
|
|
for f in required:
|
|
if f not in data:
|
|
return jsonify({'error': f'missing required field: {f}'}), 400
|
|
cur = db.cursor()
|
|
cols = ['slug', 'name', 'product_id', 'company_id', 'type', 'status', 'billable',
|
|
'rate_cents', 'price_cents', 'currency', 'start_date', 'end_date', 'notes']
|
|
sql_cols = []
|
|
sql_vals = []
|
|
sql_params = []
|
|
for c in cols:
|
|
if c in data:
|
|
sql_cols.append(c)
|
|
sql_vals.append('%s')
|
|
sql_params.append(data[c])
|
|
cur.execute(
|
|
f"INSERT INTO projects ({', '.join(sql_cols)}) VALUES ({', '.join(sql_vals)}) RETURNING *",
|
|
sql_params
|
|
)
|
|
row = cur.fetchone()
|
|
db.commit()
|
|
return jsonify(dict(zip([d[0] for d in cur.description], row))), 201
|
|
|
|
|
|
@app.route('/rest/v1/projects/<uuid:project_id>', methods=['PATCH'])
|
|
@require_auth
|
|
def update_project(project_id):
|
|
db = get_db()
|
|
data = request.get_json() or {}
|
|
if not data:
|
|
return jsonify({'error': 'no fields to update'}), 400
|
|
cur = db.cursor()
|
|
setters = [f"{k} = %s" for k in data]
|
|
cur.execute(
|
|
f"UPDATE projects SET {', '.join(setters)} WHERE id = %s RETURNING *",
|
|
list(data.values()) + [str(project_id)]
|
|
)
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return jsonify({'error': 'not found'}), 404
|
|
db.commit()
|
|
return jsonify(dict(zip([d[0] for d in cur.description], row)))
|
|
|
|
|
|
@app.route('/rest/v1/projects/<uuid:project_id>', methods=['DELETE'])
|
|
@require_auth
|
|
def delete_project(project_id):
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
cur.execute("DELETE FROM projects WHERE id = %s RETURNING id", (str(project_id),))
|
|
if not cur.fetchone():
|
|
return jsonify({'error': 'not found'}), 404
|
|
db.commit()
|
|
return '', 204
|
|
|
|
|
|
@app.route('/rest/v1/projects/<uuid:project_id>/summary', methods=['GET'])
|
|
@require_auth
|
|
def project_summary(project_id):
|
|
"""Aggregate hours and financials for a project."""
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
cur.execute("SELECT * FROM v_project_summary WHERE id = %s", (str(project_id),))
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return jsonify({'error': 'not found'}), 404
|
|
return jsonify(dict(zip([d[0] for d in cur.description], row)))
|
|
|
|
|
|
@app.route('/rest/v1/projects/<uuid:project_id>/time_entries', methods=['GET'])
|
|
@require_auth
|
|
def project_time_entries(project_id):
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
limit = min(int(request.args.get('limit', 100)), 500)
|
|
offset = int(request.args.get('offset', 0))
|
|
cur.execute(
|
|
"SELECT * FROM time_entries WHERE project_id = %s ORDER BY date DESC LIMIT %s OFFSET %s",
|
|
(str(project_id), limit, offset)
|
|
)
|
|
cols = [d[0] for d in cur.description]
|
|
return jsonify([dict(zip(cols, r)) for r in cur.fetchall()])
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
# TIME ENTRIES
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
@app.route('/rest/v1/time_entries', methods=['GET'])
|
|
@require_auth
|
|
def list_time_entries():
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
filters = []
|
|
params = []
|
|
for col in ('project_id', 'source'):
|
|
val = request.args.get(col)
|
|
if val is not None:
|
|
filters.append(f"{col} = %s")
|
|
params.append(val)
|
|
date_from = request.args.get('date_from')
|
|
date_to = request.args.get('date_to')
|
|
if date_from:
|
|
filters.append("date >= %s")
|
|
params.append(date_from)
|
|
if date_to:
|
|
filters.append("date <= %s")
|
|
params.append(date_to)
|
|
where = ("WHERE " + " AND ".join(filters)) if filters else ""
|
|
limit = min(int(request.args.get('limit', 100)), 500)
|
|
offset = int(request.args.get('offset', 0))
|
|
cur.execute(
|
|
f"SELECT * FROM time_entries {where} ORDER BY date DESC LIMIT %s OFFSET %s",
|
|
params + [limit, offset]
|
|
)
|
|
cols = [d[0] for d in cur.description]
|
|
return jsonify([dict(zip(cols, r)) for r in cur.fetchall()])
|
|
|
|
|
|
@app.route('/rest/v1/time_entries/<uuid:time_entry_id>', methods=['GET'])
|
|
@require_auth
|
|
def get_time_entry(time_entry_id):
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
cur.execute("SELECT * FROM time_entries WHERE id = %s", (str(time_entry_id),))
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return jsonify({'error': 'not found'}), 404
|
|
return jsonify(dict(zip([d[0] for d in cur.description], row)))
|
|
|
|
|
|
@app.route('/rest/v1/time_entries', methods=['POST'])
|
|
@require_auth
|
|
def create_time_entry():
|
|
db = get_db()
|
|
data = request.get_json() or {}
|
|
required = ('project_id',)
|
|
for f in required:
|
|
if f not in data:
|
|
return jsonify({'error': f'missing required field: {f}'}), 400
|
|
cur = db.cursor()
|
|
cols = ['project_id', 'date', 'ai_hours', 'human_hours', 'ai_rate_cents', 'description', 'source']
|
|
sql_cols = []
|
|
sql_vals = []
|
|
sql_params = []
|
|
for c in cols:
|
|
if c in data:
|
|
sql_cols.append(c)
|
|
sql_vals.append('%s')
|
|
sql_params.append(data[c])
|
|
cur.execute(
|
|
f"INSERT INTO time_entries ({', '.join(sql_cols)}) VALUES ({', '.join(sql_vals)}) RETURNING *",
|
|
sql_params
|
|
)
|
|
row = cur.fetchone()
|
|
db.commit()
|
|
return jsonify(dict(zip([d[0] for d in cur.description], row))), 201
|
|
|
|
|
|
@app.route('/rest/v1/time_entries/<uuid:time_entry_id>', methods=['PATCH'])
|
|
@require_auth
|
|
def update_time_entry(time_entry_id):
|
|
db = get_db()
|
|
data = request.get_json() or {}
|
|
if not data:
|
|
return jsonify({'error': 'no fields to update'}), 400
|
|
cur = db.cursor()
|
|
setters = [f"{k} = %s" for k in data]
|
|
cur.execute(
|
|
f"UPDATE time_entries SET {', '.join(setters)} WHERE id = %s RETURNING *",
|
|
list(data.values()) + [str(time_entry_id)]
|
|
)
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return jsonify({'error': 'not found'}), 404
|
|
db.commit()
|
|
return jsonify(dict(zip([d[0] for d in cur.description], row)))
|
|
|
|
|
|
@app.route('/rest/v1/time_entries/<uuid:time_entry_id>', methods=['DELETE'])
|
|
@require_auth
|
|
def delete_time_entry(time_entry_id):
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
cur.execute("DELETE FROM time_entries WHERE id = %s RETURNING id", (str(time_entry_id),))
|
|
if not cur.fetchone():
|
|
return jsonify({'error': 'not found'}), 404
|
|
db.commit()
|
|
return '', 204
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
# REVENUE / ANALYTICS
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
@app.route('/rest/v1/dashboard/product-revenue', methods=['GET'])
|
|
@require_auth
|
|
def dashboard_product_revenue():
|
|
"""Revenue/cost/margin across all products."""
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
cur.execute("SELECT * FROM v_product_revenue ORDER BY total_revenue_cents DESC")
|
|
cols = [d[0] for d in cur.description]
|
|
return jsonify([dict(zip(cols, r)) for r in cur.fetchall()])
|
|
|
|
|
|
@app.route('/rest/v1/dashboard/project-summary', methods=['GET'])
|
|
@require_auth
|
|
def dashboard_project_summary():
|
|
"""Summary across all projects."""
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
cur.execute("SELECT * FROM v_project_summary ORDER BY created_at DESC")
|
|
cols = [d[0] for d in cur.description]
|
|
return jsonify([dict(zip(cols, r)) for r in cur.fetchall()])
|
|
|
|
|
|
# ── Proposals / SOW Pipeline ─────────────────────────────────────────────────
|
|
|
|
@app.route('/rest/v1/proposals', methods=['GET'])
|
|
@require_auth
|
|
def list_proposals():
|
|
"""List all proposals with optional status filter."""
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
status = request.args.get('status')
|
|
if status:
|
|
cur.execute("""
|
|
SELECT p.*, c.name as company_name, pr.name as project_name, pr.slug as project_slug
|
|
FROM proposals p
|
|
LEFT JOIN companies c ON p.company_id = c.id
|
|
LEFT JOIN projects pr ON p.project_id = pr.id
|
|
WHERE p.status = %s
|
|
ORDER BY p.created_at DESC
|
|
""", (status,))
|
|
else:
|
|
cur.execute("""
|
|
SELECT p.*, c.name as company_name, pr.name as project_name, pr.slug as project_slug
|
|
FROM proposals p
|
|
LEFT JOIN companies c ON p.company_id = c.id
|
|
LEFT JOIN projects pr ON p.project_id = pr.id
|
|
ORDER BY p.created_at DESC
|
|
""")
|
|
cols = [d[0] for d in cur.description]
|
|
rows = []
|
|
for r in cur.fetchall():
|
|
rows.append(dict(zip(cols, r)))
|
|
return jsonify(rows)
|
|
|
|
@app.route('/rest/v1/proposals', methods=['POST'])
|
|
@require_auth
|
|
def create_proposal():
|
|
data = request.json
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
cur.execute("""
|
|
INSERT INTO proposals (project_id, company_id, name, sow_number, amount_cents, our_rate_cents, status, notes)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
|
RETURNING *
|
|
""", (
|
|
data.get('project_id'),
|
|
data.get('company_id'),
|
|
data['name'],
|
|
data.get('sow_number'),
|
|
data['amount_cents'],
|
|
data.get('our_rate_cents'),
|
|
data.get('status', 'draft'),
|
|
data.get('notes', '')
|
|
))
|
|
cols = [d[0] for d in cur.description]
|
|
row = dict(zip(cols, cur.fetchone()))
|
|
db.commit()
|
|
return jsonify(row), 201
|
|
|
|
@app.route('/rest/v1/proposals/<uuid:proposal_id>', methods=['PATCH'])
|
|
@require_auth
|
|
def update_proposal(proposal_id):
|
|
"""Update proposal fields."""
|
|
data = request.json
|
|
if not data:
|
|
return jsonify({'error': 'no fields to update'}), 400
|
|
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
|
|
# Build dynamic UPDATE from provided fields
|
|
allowed = ['name','sow_number','amount_cents','our_rate_cents','status',
|
|
'sent_at','decided_at','expires_at','notes']
|
|
updates = []
|
|
values = []
|
|
for col in allowed:
|
|
if col in data:
|
|
updates.append(f"{col} = %s")
|
|
values.append(data[col])
|
|
|
|
if not updates:
|
|
return jsonify({'error': 'no allowed fields to update'}), 400
|
|
|
|
values.append(str(proposal_id))
|
|
cur.execute(
|
|
f"UPDATE proposals SET {', '.join(updates)} WHERE id = %s RETURNING *",
|
|
tuple(values)
|
|
)
|
|
row = cur.fetchone()
|
|
db.commit()
|
|
if not row:
|
|
return jsonify({'error': 'not found'}), 404
|
|
cols = [d[0] for d in cur.description]
|
|
return jsonify(dict(zip(cols, row)))
|
|
|
|
@app.route('/rest/v1/dashboard/pipeline-proposals')
|
|
@require_auth
|
|
def dashboard_pipeline_proposals():
|
|
"""Active SOW proposals: draft + sent + negotiation."""
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
cur.execute("""
|
|
SELECT
|
|
p.id,
|
|
p.name,
|
|
p.sow_number,
|
|
p.amount_cents,
|
|
p.our_rate_cents,
|
|
p.status,
|
|
p.sent_at,
|
|
c.name AS company_name,
|
|
pr.name AS project_name,
|
|
pr.slug AS project_slug
|
|
FROM proposals p
|
|
LEFT JOIN companies c ON p.company_id = c.id
|
|
LEFT JOIN projects pr ON p.project_id = pr.id
|
|
WHERE p.status IN ('draft', 'sent', 'negotiation')
|
|
ORDER BY p.created_at DESC
|
|
""")
|
|
cols = [d[0] for d in cur.description]
|
|
rows = [dict(zip(cols, r)) for r in cur.fetchall()]
|
|
# Add total
|
|
total = sum(r['amount_cents'] for r in rows)
|
|
return jsonify({'proposals': rows, 'total_pipeline_cents': total})
|
|
|
|
|
|
# ── LitterBox Analysis ─────────────────────────────────────────────────────────
|
|
|
|
@app.route('/rest/v1/litterbox/analyses', methods=['POST'])
|
|
@require_auth
|
|
def create_litterbox_analysis():
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
data = request.get_json()
|
|
if not data:
|
|
return jsonify({'error': 'No JSON body'}), 400
|
|
cols = ['ir_case_id','engagement_id','case_id','md5','sha256','file_name','file_path','file_size_bytes','analysis_type','status']
|
|
vals = [data.get(c) for c in cols]
|
|
placeholders = ','.join(['%s'] * len(cols))
|
|
cur.execute(f"INSERT INTO litterbox_analyses ({','.join(cols)}) VALUES ({placeholders}) RETURNING *", vals)
|
|
db.commit()
|
|
row = dict(zip([d[0] for d in cur.description], cur.fetchone()))
|
|
return jsonify(row), 201
|
|
|
|
@app.route('/rest/v1/litterbox/analyses', methods=['GET'])
|
|
@require_auth
|
|
def list_litterbox_analyses():
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
ir_case_id = request.args.get('ir_case_id')
|
|
limit = int(request.args.get('limit', 50))
|
|
offset = int(request.args.get('offset', 0))
|
|
if ir_case_id:
|
|
cur.execute('SELECT * FROM litterbox_analyses WHERE ir_case_id = %s ORDER BY created_at DESC LIMIT %s OFFSET %s', (str(ir_case_id), limit, offset))
|
|
else:
|
|
cur.execute('SELECT * FROM litterbox_analyses ORDER BY created_at DESC LIMIT %s OFFSET %s', (limit, offset))
|
|
rows = [dict(zip([d[0] for d in cur.description], r)) for r in cur.fetchall()]
|
|
return jsonify({'analyses': rows, 'count': len(rows)})
|
|
|
|
@app.route('/rest/v1/litterbox/analyses/<analysis_id>', methods=['GET'])
|
|
@require_auth
|
|
def get_litterbox_analysis(analysis_id):
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
cur.execute('SELECT * FROM litterbox_analyses WHERE id = %s', (str(analysis_id),))
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return jsonify({'error': 'Not found'}), 404
|
|
return jsonify(dict(zip([d[0] for d in cur.description], row)))
|
|
|
|
@app.route('/rest/v1/litterbox/analyses/<analysis_id>', methods=['PATCH'])
|
|
@require_auth
|
|
def update_litterbox_analysis(analysis_id):
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
data = request.get_json()
|
|
if not data:
|
|
return jsonify({'error': 'No JSON body'}), 400
|
|
data.pop('id', None)
|
|
data.pop('created_at', None)
|
|
if data.get('status') == 'completed':
|
|
data['completed_at'] = 'now()'
|
|
data['updated_at'] = 'now()'
|
|
set_clauses = []
|
|
vals = []
|
|
jsonb_cols = {'result_json', 'yara_matches', 'static_analysis', 'dynamic_analysis', 'edr_data'}
|
|
for k, v in data.items():
|
|
if k in jsonb_cols and isinstance(v, (dict, list)):
|
|
set_clauses.append(f"{k} = %s::jsonb")
|
|
vals.append(json.dumps(v))
|
|
else:
|
|
set_clauses.append(f"{k} = %s")
|
|
vals.append(v)
|
|
cur.execute(f"UPDATE litterbox_analyses SET {','.join(set_clauses)} WHERE id = %s RETURNING *", vals + [str(analysis_id)])
|
|
db.commit()
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return jsonify({'error': 'Not found'}), 404
|
|
return jsonify(dict(zip([d[0] for d in cur.description], row)))
|
|
|
|
@app.route('/rest/v1/litterbox/analyses/<analysis_id>/link-ir-case', methods=['POST'])
|
|
@require_auth
|
|
def link_litterbox_analysis_to_ir_case(analysis_id):
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
data = request.get_json()
|
|
ir_case_id = data.get('ir_case_id')
|
|
if not ir_case_id:
|
|
return jsonify({'error': 'ir_case_id required'}), 400
|
|
cur.execute('UPDATE litterbox_analyses SET ir_case_id = %s, updated_at = now() WHERE id = %s RETURNING *', (str(ir_case_id), str(analysis_id)))
|
|
db.commit()
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return jsonify({'error': 'Not found'}), 404
|
|
return jsonify(dict(zip([d[0] for d in cur.description], row)))
|
|
|
|
@app.route('/rest/v1/litterbox/analyses/<analysis_id>/results', methods=['GET'])
|
|
@require_auth
|
|
def get_litterbox_analysis_results(analysis_id):
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
cur.execute('SELECT result_json, yara_matches, static_analysis, dynamic_analysis, edr_data, status, detection_score FROM litterbox_analyses WHERE id = %s', (str(analysis_id),))
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return jsonify({'error': 'Not found'}), 404
|
|
return jsonify({
|
|
'result_json': row[0],
|
|
'yara_matches': row[1],
|
|
'static_analysis': row[2],
|
|
'dynamic_analysis': row[3],
|
|
'edr_data': row[4],
|
|
'status': row[5],
|
|
'detection_score': row[6]
|
|
})
|
|
|
|
|
|
# ── Bootstrap ─────────────────────────────────────────────────────────────────
|
|
|
|
if __name__ == '__main__':
|
|
import sys
|
|
port = int(sys.argv[1]) if len(sys.argv) > 1 else 3000
|
|
CORS(app)
|
|
app.run(host='0.0.0.0', port=port, debug=False)
|