179 lines
9.3 KiB
Python
179 lines
9.3 KiB
Python
from __future__ import annotations
|
|||
|
|
|
||
|
|
import argparse
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import sqlite3
|
||
|
|
import sys
|
||
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
|
|
from pathlib import Path
|
||
|
|
from urllib.parse import parse_qs, urlparse
|
||
|
|
|
||
|
|
if __package__ in (None, ""):
|
||
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||
|
|
from app.domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, score_business
|
||
|
|
else:
|
||
|
|
from .domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, score_business
|
||
|
|
|
||
|
|
ORGANIZATION_ID = "demo-tenant"
|
||
|
|
SCHEMA = Path(__file__).resolve().parents[1] / "schema.sql"
|
||
|
|
|
||
|
|
|
||
|
|
def connect(db_path: str) -> sqlite3.Connection:
|
||
|
|
db = sqlite3.connect(db_path)
|
||
|
|
db.row_factory = sqlite3.Row
|
||
|
|
db.execute("PRAGMA foreign_keys = ON")
|
||
|
|
db.executescript(SCHEMA.read_text())
|
||
|
|
return db
|
||
|
|
|
||
|
|
|
||
|
|
def row_json(row: sqlite3.Row) -> dict:
|
||
|
|
result = dict(row)
|
||
|
|
result["score_factors"] = json.loads(result.pop("score_factors", "[]"))
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def existing_businesses(db: sqlite3.Connection) -> list[dict]:
|
||
|
|
return [row_json(r) for r in db.execute("SELECT * FROM businesses WHERE organization_id = ? ORDER BY id", (ORGANIZATION_ID,))]
|
||
|
|
|
||
|
|
|
||
|
|
def suppression_rows(db: sqlite3.Connection) -> list[dict]:
|
||
|
|
return [dict(r) for r in db.execute("SELECT kind, value FROM suppressions WHERE organization_id = ?", (ORGANIZATION_ID,))]
|
||
|
|
|
||
|
|
|
||
|
|
class ApiHandler(BaseHTTPRequestHandler):
|
||
|
|
server_version = "ProspectPlatform/0.1"
|
||
|
|
|
||
|
|
def send_json(self, status: int, payload: dict | list):
|
||
|
|
body = json.dumps(payload, sort_keys=True).encode("utf-8")
|
||
|
|
self.send_response(status)
|
||
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||
|
|
self.send_header("Access-Control-Allow-Origin", os.environ.get("CORS_ORIGINS", "http://localhost:8080"))
|
||
|
|
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||
|
|
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||
|
|
self.send_header("Content-Length", str(len(body)))
|
||
|
|
self.end_headers()
|
||
|
|
self.wfile.write(body)
|
||
|
|
|
||
|
|
def read_json(self) -> dict:
|
||
|
|
try:
|
||
|
|
length = int(self.headers.get("Content-Length", "0"))
|
||
|
|
value = json.loads(self.rfile.read(length) or b"{}")
|
||
|
|
return value if isinstance(value, dict) else {}
|
||
|
|
except (ValueError, json.JSONDecodeError):
|
||
|
|
return {}
|
||
|
|
|
||
|
|
def db(self):
|
||
|
|
return connect(self.server.db_path)
|
||
|
|
|
||
|
|
def do_OPTIONS(self):
|
||
|
|
self.send_response(204)
|
||
|
|
self.send_header("Access-Control-Allow-Origin", os.environ.get("CORS_ORIGINS", "http://localhost:8080"))
|
||
|
|
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||
|
|
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||
|
|
self.end_headers()
|
||
|
|
|
||
|
|
def do_GET(self):
|
||
|
|
parsed = urlparse(self.path)
|
||
|
|
path = parsed.path.rstrip("/")
|
||
|
|
if path == "/api/v1/health/live":
|
||
|
|
return self.send_json(200, {"status": "ok", "organization_id": ORGANIZATION_ID})
|
||
|
|
db = self.db()
|
||
|
|
try:
|
||
|
|
if path == "/api/v1/dashboard/summary":
|
||
|
|
row = db.execute("SELECT COUNT(*) AS businesses, COALESCE(AVG(score), 0) AS average_score FROM businesses WHERE organization_id = ?", (ORGANIZATION_ID,)).fetchone()
|
||
|
|
return self.send_json(200, {"organization_id": ORGANIZATION_ID, "businesses": row["businesses"], "average_score": round(row["average_score"], 2), "suppressed": db.execute("SELECT COUNT(*) FROM suppressions WHERE organization_id = ?", (ORGANIZATION_ID,)).fetchone()[0]})
|
||
|
|
if path == "/api/v1/businesses":
|
||
|
|
query = parse_qs(parsed.query).get("q", [""])[0].strip()
|
||
|
|
if query:
|
||
|
|
like = f"%{query}%"
|
||
|
|
rows = db.execute("SELECT * FROM businesses WHERE organization_id = ? AND (name LIKE ? OR website_domain LIKE ? OR email LIKE ?) ORDER BY score DESC, id", (ORGANIZATION_ID, like, like, like)).fetchall()
|
||
|
|
else:
|
||
|
|
rows = db.execute("SELECT * FROM businesses WHERE organization_id = ? ORDER BY score DESC, id", (ORGANIZATION_ID,)).fetchall()
|
||
|
|
return self.send_json(200, {"organization_id": ORGANIZATION_ID, "items": [row_json(r) for r in rows]})
|
||
|
|
if path.startswith("/api/v1/businesses/"):
|
||
|
|
ident = path.rsplit("/", 1)[1]
|
||
|
|
if not ident.isdigit(): return self.send_json(404, {"error": "not_found"})
|
||
|
|
row = db.execute("SELECT * FROM businesses WHERE id = ? AND organization_id = ?", (int(ident), ORGANIZATION_ID)).fetchone()
|
||
|
|
return self.send_json(200, row_json(row)) if row else self.send_json(404, {"error": "not_found"})
|
||
|
|
return self.send_json(404, {"error": "not_found"})
|
||
|
|
finally:
|
||
|
|
db.close()
|
||
|
|
|
||
|
|
def do_POST(self):
|
||
|
|
path = urlparse(self.path).path.rstrip("/")
|
||
|
|
payload = self.read_json()
|
||
|
|
if path == "/api/v1/businesses": return self.create_business(payload)
|
||
|
|
if path == "/api/v1/suppressions": return self.create_suppression(payload)
|
||
|
|
if path == "/api/v1/imports/preview": return self.preview_import(payload)
|
||
|
|
return self.send_json(404, {"error": "not_found"})
|
||
|
|
|
||
|
|
def create_business(self, payload):
|
||
|
|
if not str(payload.get("name", "")).strip(): return self.send_json(400, {"error": "name_required"})
|
||
|
|
business = normalize_business(payload)
|
||
|
|
db = self.db()
|
||
|
|
try:
|
||
|
|
if is_suppressed(business, suppression_rows(db)): return self.send_json(409, {"error": "suppressed"})
|
||
|
|
identity_fields = [("website_domain", business["website_domain"]), ("email", business["email"]), ("phone", business["phone"])]
|
||
|
|
identity_fields = [(column, value) for column, value in identity_fields if value]
|
||
|
|
if identity_fields:
|
||
|
|
predicates = " OR ".join(f"{column} = ?" for column, _ in identity_fields)
|
||
|
|
values = [value for _, value in identity_fields]
|
||
|
|
if db.execute(f"SELECT id FROM businesses WHERE organization_id = ? AND ({predicates})", [ORGANIZATION_ID, *values]).fetchone():
|
||
|
|
return self.send_json(409, {"error": "duplicate"})
|
||
|
|
scored = score_business(business)
|
||
|
|
cur = db.execute("INSERT INTO businesses (organization_id,name,website,website_domain,email,phone,description,score,score_version,score_factors,website_class) VALUES (?,?,?,?,?,?,?,?,?,?,?)", (ORGANIZATION_ID, business["name"], business["website"], business["website_domain"], business["email"], business["phone"], str(business.get("description", "")), scored["score"], scored["score_version"], json.dumps(scored["factors"]), scored["website_class"]))
|
||
|
|
db.commit()
|
||
|
|
return self.send_json(201, row_json(db.execute("SELECT * FROM businesses WHERE id = ?", (cur.lastrowid,)).fetchone()))
|
||
|
|
finally: db.close()
|
||
|
|
|
||
|
|
def create_suppression(self, payload):
|
||
|
|
kind, value = payload.get("kind"), str(payload.get("value", "")).strip().lower()
|
||
|
|
if kind not in {"email", "domain", "phone"} or not value: return self.send_json(400, {"error": "invalid_suppression"})
|
||
|
|
db = self.db()
|
||
|
|
try:
|
||
|
|
try:
|
||
|
|
db.execute("INSERT INTO suppressions (organization_id,kind,value) VALUES (?,?,?)", (ORGANIZATION_ID, kind, value)); db.commit()
|
||
|
|
except sqlite3.IntegrityError: pass
|
||
|
|
row = db.execute("SELECT * FROM suppressions WHERE organization_id = ? AND kind = ? AND value = ?", (ORGANIZATION_ID, kind, value)).fetchone()
|
||
|
|
return self.send_json(201, dict(row))
|
||
|
|
finally: db.close()
|
||
|
|
|
||
|
|
def preview_import(self, payload):
|
||
|
|
rows = payload.get("rows", [])
|
||
|
|
if not isinstance(rows, list): return self.send_json(400, {"error": "rows_required"})
|
||
|
|
normalized = deduplicate_businesses([r for r in rows if isinstance(r, dict) and str(r.get("name", "")).strip()])
|
||
|
|
db = self.db()
|
||
|
|
try:
|
||
|
|
suppressions = suppression_rows(db); existing = existing_businesses(db); seen = set()
|
||
|
|
accepted, duplicate, suppressed = [], 0, 0
|
||
|
|
for b in normalized:
|
||
|
|
key = deduplication_key(b)
|
||
|
|
if is_suppressed(b, suppressions): suppressed += 1
|
||
|
|
elif (key in {deduplication_key(x) for x in existing} or key in seen): duplicate += 1
|
||
|
|
else: seen.add(key); accepted.append(b)
|
||
|
|
return self.send_json(200, {"accepted": len(accepted), "duplicates": duplicate + len(rows) - len(normalized), "suppressed": suppressed, "rows": accepted})
|
||
|
|
finally: db.close()
|
||
|
|
|
||
|
|
def log_message(self, *_): pass
|
||
|
|
|
||
|
|
|
||
|
|
def create_server(host="127.0.0.1", port=8000, db_path="prospects.db"):
|
||
|
|
server = ThreadingHTTPServer((host, port), ApiHandler)
|
||
|
|
server.db_path = db_path
|
||
|
|
connect(db_path).close()
|
||
|
|
return server
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
parser = argparse.ArgumentParser(description="Prospect Platform API")
|
||
|
|
parser.add_argument("--host", default="127.0.0.1")
|
||
|
|
parser.add_argument("--port", type=int, default=int(os.environ.get("PROSPECT_API_PORT", "8000")))
|
||
|
|
parser.add_argument("--db", default=os.environ.get("PROSPECT_API_DB", "prospects.db"))
|
||
|
|
args = parser.parse_args()
|
||
|
|
server = create_server(args.host, args.port, args.db)
|
||
|
|
print(f"Prospect API listening on http://{args.host}:{args.port}", flush=True)
|
||
|
|
try: server.serve_forever()
|
||
|
|
except KeyboardInterrupt: pass
|
||
|
|
finally: server.server_close()
|