expand manual prospect intelligence workflows
This commit is contained in:
+186
-183
@@ -1,219 +1,222 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
import sys
|
||||
import argparse, hashlib, json, os, re, secrets, sqlite3, sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from http.cookies import SimpleCookie
|
||||
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
|
||||
from app.domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, score_business, normalize_domain, normalize_phone
|
||||
else:
|
||||
from .domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, score_business
|
||||
|
||||
from .domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, score_business, normalize_domain, normalize_phone
|
||||
ORGANIZATION_ID = "demo-tenant"
|
||||
SCHEMA = Path(__file__).resolve().parents[1] / "schema.sql"
|
||||
SESSION_DAYS = 7
|
||||
PBKDF2_ITERATIONS = 300_000 # Development fallback: stdlib PBKDF2, not Argon2id.
|
||||
PBKDF2_ITERATIONS = 300_000
|
||||
MUTATING_ROLES = {"owner", "admin", "researcher"}
|
||||
|
||||
CHILD_TABLES = {"contacts": ("name", "email", "phone", "title", "do_not_contact"), "domains": ("domain", "kind"), "websites": ("url", "website_class"), "evidence": ("kind", "url", "claim"), "notes": ("body",)}
|
||||
|
||||
def hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]:
|
||||
salt = salt or secrets.token_bytes(16)
|
||||
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITERATIONS)
|
||||
return digest.hex(), salt.hex()
|
||||
|
||||
|
||||
def verify_password(password: str, encoded_hash: str, encoded_salt: str) -> bool:
|
||||
try:
|
||||
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(encoded_salt), PBKDF2_ITERATIONS).hex()
|
||||
return secrets.compare_digest(digest, encoded_hash)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
salt = salt or secrets.token_bytes(16); return hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITERATIONS).hex(), salt.hex()
|
||||
def verify_password(password, encoded_hash, encoded_salt):
|
||||
try: return secrets.compare_digest(hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(encoded_salt), PBKDF2_ITERATIONS).hex(), encoded_hash)
|
||||
except (TypeError, ValueError): return False
|
||||
|
||||
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())
|
||||
db.execute("INSERT OR IGNORE INTO organizations (id, name) VALUES (?, ?)", (ORGANIZATION_ID, "Demo organization"))
|
||||
db = sqlite3.connect(db_path); db.row_factory = sqlite3.Row; db.execute("PRAGMA foreign_keys = ON"); db.executescript(SCHEMA.read_text())
|
||||
# Upgrade databases created by Phase 1/2 without destroying data.
|
||||
cols = {r[1] for r in db.execute("PRAGMA table_info(businesses)")}
|
||||
for col, definition in (("verified", "INTEGER NOT NULL DEFAULT 0"), ("verified_at", "TEXT"), ("updated_at", "TEXT")):
|
||||
if col not in cols: db.execute(f"ALTER TABLE businesses ADD COLUMN {col} {definition}")
|
||||
db.execute("UPDATE businesses SET updated_at=COALESCE(updated_at,created_at) WHERE updated_at IS NULL")
|
||||
db.execute("INSERT OR IGNORE INTO organizations (id,name) VALUES (?,?)", (ORGANIZATION_ID, "Demo organization"))
|
||||
email, password = os.environ.get("BOOTSTRAP_ADMIN_EMAIL"), os.environ.get("BOOTSTRAP_ADMIN_PASSWORD")
|
||||
if email and password:
|
||||
existing = db.execute("SELECT id FROM users WHERE email = ?", (email.strip().lower(),)).fetchone()
|
||||
if not existing:
|
||||
password_hash, salt = hash_password(password)
|
||||
db.execute("INSERT INTO users (organization_id,email,password_hash,password_salt,role) VALUES (?,?,?,?,?)", (ORGANIZATION_ID, email.strip().lower(), password_hash, salt, "owner"))
|
||||
db.commit()
|
||||
return db
|
||||
if email and password and not db.execute("SELECT id FROM users WHERE email=?", (email.strip().lower(),)).fetchone():
|
||||
ph, salt = hash_password(password); db.execute("INSERT INTO users (organization_id,email,password_hash,password_salt,role) VALUES (?,?,?,?,?)", (ORGANIZATION_ID,email.strip().lower(),ph,salt,"owner"))
|
||||
db.commit(); return db
|
||||
|
||||
def safe_value(value):
|
||||
if isinstance(value, bytes): return value.decode("utf-8", "replace")
|
||||
return value
|
||||
|
||||
def row_json(row: sqlite3.Row) -> dict:
|
||||
result = dict(row)
|
||||
result["score_factors"] = json.loads(result.pop("score_factors", "[]"))
|
||||
def row_json(row):
|
||||
result = {k: safe_value(v) for k, v in dict(row).items()}
|
||||
if "score_factors" in result:
|
||||
try: result["score_factors"] = json.loads(result["score_factors"] or "[]")
|
||||
except (TypeError, ValueError): result["score_factors"] = []
|
||||
for key in ("verified", "do_not_contact"):
|
||||
if key in result: result[key] = bool(result[key])
|
||||
return result
|
||||
|
||||
|
||||
class ApiHandler(BaseHTTPRequestHandler):
|
||||
server_version = "ProspectPlatform/0.1"
|
||||
|
||||
def send_json(self, status: int, payload: dict | list, extra_headers: dict[str, str] | None = None):
|
||||
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-Credentials", "true")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
for key, value in (extra_headers or {}).items(): self.send_header(key, value)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def read_json(self) -> dict:
|
||||
def send_json(self, status, payload, extra_headers=None):
|
||||
body = json.dumps(payload, sort_keys=True, default=str).encode(); 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-Credentials","true"); self.send_header("Access-Control-Allow-Methods","GET, POST, PATCH, OPTIONS"); self.send_header("Access-Control-Allow-Headers","Content-Type")
|
||||
for k,v in (extra_headers or {}).items(): self.send_header(k,v)
|
||||
self.send_header("Content-Length",str(len(body))); self.end_headers(); self.wfile.write(body)
|
||||
def read_json(self):
|
||||
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(getattr(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-Credentials", "true")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
self.end_headers()
|
||||
|
||||
def session_user(self, db: sqlite3.Connection):
|
||||
cookie = SimpleCookie(); cookie.load(self.headers.get("Cookie", ""))
|
||||
token = cookie.get("session")
|
||||
value=json.loads(self.rfile.read(int(self.headers.get("Content-Length","0"))) or b"{}"); return value if isinstance(value,dict) else {}
|
||||
except (ValueError,json.JSONDecodeError): return {}
|
||||
def db(self): return connect(getattr(self.server,"db_path"))
|
||||
def do_OPTIONS(self): self.send_response(204); self.send_header("Access-Control-Allow-Methods","GET, POST, PATCH, OPTIONS"); self.end_headers()
|
||||
def session_user(self, db):
|
||||
cookie=SimpleCookie(); cookie.load(self.headers.get("Cookie","")); token=cookie.get("session")
|
||||
if not token: return None
|
||||
token_hash = hashlib.sha256(token.value.encode()).hexdigest()
|
||||
now = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
||||
return db.execute("SELECT u.id, u.email, u.role, u.organization_id FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ? AND s.expires_at > ?", (token_hash, now)).fetchone()
|
||||
|
||||
def require_auth(self, db):
|
||||
user = self.session_user(db)
|
||||
if not user:
|
||||
self.send_json(401, {"error": "unauthorized"})
|
||||
return None
|
||||
now=datetime.now(timezone.utc).replace(microsecond=0).isoformat(); h=hashlib.sha256(token.value.encode()).hexdigest()
|
||||
return db.execute("SELECT u.id,u.email,u.role,u.organization_id FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_at>?",(h,now)).fetchone()
|
||||
def require_auth(self,db):
|
||||
user=self.session_user(db)
|
||||
if not user: self.send_json(401,{"error":"unauthorized"}); return None
|
||||
return user
|
||||
|
||||
def auth_cookie(self, token: str, max_age: int) -> str:
|
||||
return f"session={token}; Max-Age={max_age}; Path=/; HttpOnly; SameSite=Lax"
|
||||
|
||||
def auth_cookie(self,token,max_age): return f"session={token}; Max-Age={max_age}; Path=/; HttpOnly; SameSite=Lax"
|
||||
def audit(self, db, user, action, details=""):
|
||||
db.execute("INSERT INTO audit_log (organization_id,user_id,action,details) VALUES (?,?,?,?)",(user["organization_id"],user["id"],action,details))
|
||||
def business(self, db, ident, org): return db.execute("SELECT * FROM businesses WHERE id=? AND organization_id=?",(ident,org)).fetchone()
|
||||
def nested(self, db, bid, org):
|
||||
result={"contacts":[],"domains":[],"websites":[],"evidence":[],"pipeline":[],"notes":[]}
|
||||
tables={"contacts":"contacts","domains":"domains","websites":"websites","evidence":"evidence","pipeline":"pipeline_entries","notes":"notes"}
|
||||
for key, table in tables.items():
|
||||
result[key]=[row_json(r) for r in db.execute(f"SELECT * FROM {table} WHERE business_id=? AND organization_id=? ORDER BY id",(bid,org))]
|
||||
return result
|
||||
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()
|
||||
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:
|
||||
user = self.require_auth(db)
|
||||
if not user: return
|
||||
org = user["organization_id"]
|
||||
if path == "/api/v1/auth/me": return self.send_json(200, {"id": user["id"], "email": user["email"], "role": user["role"], "organization_id": org})
|
||||
if path == "/api/v1/admin/users":
|
||||
if user["role"] not in {"owner", "admin"}: return self.send_json(403, {"error": "forbidden"})
|
||||
rows = db.execute("SELECT id,email,role,organization_id,created_at FROM users WHERE organization_id = ? ORDER BY id", (org,)).fetchall()
|
||||
return self.send_json(200, {"items": [dict(r) for r in rows]})
|
||||
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 = ?", (org,)).fetchone()
|
||||
return self.send_json(200, {"organization_id": org, "businesses": row["businesses"], "average_score": round(row["average_score"], 2), "suppressed": db.execute("SELECT COUNT(*) FROM suppressions WHERE organization_id = ?", (org,)).fetchone()[0]})
|
||||
if path == "/api/v1/businesses":
|
||||
query = parse_qs(parsed.query).get("q", [""])[0].strip(); params = [org]
|
||||
sql = "SELECT * FROM businesses WHERE organization_id = ?"
|
||||
if query:
|
||||
like = f"%{query}%"; sql += " AND (name LIKE ? OR website_domain LIKE ? OR email LIKE ?)"; params += [like] * 3
|
||||
rows = db.execute(sql + " ORDER BY score DESC, id", params).fetchall()
|
||||
return self.send_json(200, {"organization_id": org, "items": [row_json(r) for r in rows]})
|
||||
user=self.require_auth(db)
|
||||
if not user:return
|
||||
org=user["organization_id"]
|
||||
if path=="/api/v1/auth/me": return self.send_json(200,{"id":user["id"],"email":user["email"],"role":user["role"],"organization_id":org})
|
||||
if path=="/api/v1/admin/users":
|
||||
if user["role"] not in {"owner","admin"}: return self.send_json(403,{"error":"forbidden"})
|
||||
return self.send_json(200,{"items":[dict(r) for r in db.execute("SELECT id,email,role,organization_id,created_at FROM users WHERE organization_id=? ORDER BY id",(org,))]})
|
||||
if path=="/api/v1/dashboard/summary":
|
||||
row=db.execute("SELECT COUNT(*) businesses,COALESCE(AVG(score),0) average_score FROM businesses WHERE organization_id=?",(org,)).fetchone(); return self.send_json(200,{"organization_id":org,"businesses":row["businesses"],"average_score":round(row["average_score"],2),"suppressed":db.execute("SELECT COUNT(*) FROM suppressions WHERE organization_id=?",(org,)).fetchone()[0]})
|
||||
if path=="/api/v1/businesses": return self.list_businesses(db,org,parse_qs(parsed.query))
|
||||
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), org)).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"})
|
||||
bits=path.split("/"); ident=bits[4] if len(bits)>4 else ""
|
||||
if not ident.isdigit(): return self.send_json(404,{"error":"not_found"})
|
||||
row=self.business(db,int(ident),org)
|
||||
if not row:return self.send_json(404,{"error":"not_found"})
|
||||
payload=row_json(row); payload.update(self.nested(db,int(ident),org)); return self.send_json(200,payload)
|
||||
return self.send_json(404,{"error":"not_found"})
|
||||
finally: db.close()
|
||||
|
||||
def list_businesses(self,db,org,query):
|
||||
def number(name, default=None):
|
||||
raw=query.get(name,[None])[0]
|
||||
if raw is None:return default
|
||||
try:return int(raw)
|
||||
except ValueError: raise ValueError
|
||||
try:
|
||||
page=number("page",1); size=number("page_size",50); score=number("score_min",None); cursor=number("cursor",0)
|
||||
except ValueError:return self.send_json(400,{"error":"invalid_pagination"})
|
||||
if page<1 or size<1 or size>100 or cursor<0:return self.send_json(400,{"error":"invalid_pagination"})
|
||||
params=[org]; where=["b.organization_id=?"]; q=query.get("q",[""])[0].strip(); website_class=query.get("website_class",[""])[0].strip(); stage=query.get("pipeline_stage",[""])[0].strip()
|
||||
if score is not None: where.append("b.score>=?"); params.append(score)
|
||||
if website_class: where.append("b.website_class=?"); params.append(website_class)
|
||||
if q: where.append("(b.name LIKE ? OR b.website_domain LIKE ? OR b.email LIKE ?)"); params += [f"%{q}%"]*3
|
||||
if stage: where.append("EXISTS (SELECT 1 FROM pipeline_entries p WHERE p.business_id=b.id AND p.organization_id=b.organization_id AND p.stage=?)"); params.append(stage)
|
||||
offset=(number("cursor",0) or 0)+(page-1)*size
|
||||
rows=db.execute("SELECT b.* FROM businesses b WHERE "+" AND ".join(where)+" ORDER BY b.score DESC,b.id LIMIT ? OFFSET ?",params+[size+1,offset]).fetchall(); more=len(rows)>size; rows=rows[:size]
|
||||
return self.send_json(200,{"organization_id":org,"items":[row_json(r) for r in rows],"page":page,"page_size":size,"next_cursor":str(offset+size) if more else None})
|
||||
def do_POST(self):
|
||||
path = urlparse(self.path).path.rstrip("/")
|
||||
if path == "/api/v1/auth/login": return self.login(self.read_json())
|
||||
db = self.db()
|
||||
path=urlparse(self.path).path.rstrip("/")
|
||||
if path=="/api/v1/auth/login":return self.login(self.read_json())
|
||||
db=self.db()
|
||||
try:
|
||||
user = self.require_auth(db)
|
||||
if not user: return
|
||||
if path == "/api/v1/auth/logout":
|
||||
cookie = SimpleCookie(); cookie.load(self.headers.get("Cookie", "")); token = cookie.get("session")
|
||||
if token: db.execute("DELETE FROM sessions WHERE token_hash = ?", (hashlib.sha256(token.value.encode()).hexdigest(),))
|
||||
db.execute("INSERT INTO audit_log (organization_id,user_id,action) VALUES (?,?,?)", (user["organization_id"], user["id"], "logout")); db.commit()
|
||||
return self.send_json(200, {"ok": True}, {"Set-Cookie": self.auth_cookie("", 0)})
|
||||
if user["role"] not in MUTATING_ROLES: return self.send_json(403, {"error": "forbidden"})
|
||||
payload = self.read_json()
|
||||
if path == "/api/v1/businesses": return self.create_business(payload, db, user["organization_id"])
|
||||
if path == "/api/v1/suppressions": return self.create_suppression(payload, db, user["organization_id"])
|
||||
if path == "/api/v1/imports/preview": return self.preview_import(payload, db, user["organization_id"])
|
||||
return self.send_json(404, {"error": "not_found"})
|
||||
finally: db.close()
|
||||
|
||||
def login(self, payload):
|
||||
email = str(payload.get("email", "")).strip().lower(); password = str(payload.get("password", "")); db = self.db()
|
||||
user=self.require_auth(db)
|
||||
if not user:return
|
||||
if path=="/api/v1/auth/logout":
|
||||
c=SimpleCookie();c.load(self.headers.get("Cookie",""));t=c.get("session");
|
||||
if t:db.execute("DELETE FROM sessions WHERE token_hash=?",(hashlib.sha256(t.value.encode()).hexdigest(),))
|
||||
self.audit(db,user,"logout");db.commit();return self.send_json(200,{"ok":True},{"Set-Cookie":self.auth_cookie("",0)})
|
||||
if user["role"] not in MUTATING_ROLES:return self.send_json(403,{"error":"forbidden"})
|
||||
payload=self.read_json(); org=user["organization_id"]
|
||||
if path=="/api/v1/businesses":return self.create_business(payload,db,user)
|
||||
if path=="/api/v1/suppressions":return self.create_suppression(payload,db,user)
|
||||
if path=="/api/v1/imports/preview":return self.preview_import(payload,db,org)
|
||||
bits=path.split("/")
|
||||
if len(bits)==7 and bits[:4]==["","api","v1","businesses"] and bits[5] in CHILD_TABLES and bits[6]=="": pass
|
||||
if len(bits)==6 and bits[:4]==["","api","v1","businesses"] and bits[5] in CHILD_TABLES:return self.create_child(int(bits[4]) if bits[4].isdigit() else -1,bits[5],payload,db,user)
|
||||
if len(bits)==6 and bits[:4]==["","api","v1","businesses"] and bits[5]=="verify":return self.verify_business(int(bits[4]) if bits[4].isdigit() else -1,payload,db,user)
|
||||
return self.send_json(404,{"error":"not_found"})
|
||||
finally:db.close()
|
||||
def do_PATCH(self):
|
||||
path=urlparse(self.path).path.rstrip("/"); db=self.db()
|
||||
try:
|
||||
user = db.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone()
|
||||
if not user or not verify_password(password, user["password_hash"], user["password_salt"]): return self.send_json(401, {"error": "invalid_credentials"})
|
||||
token = secrets.token_urlsafe(32); expires = datetime.now(timezone.utc) + timedelta(days=SESSION_DAYS)
|
||||
db.execute("INSERT INTO sessions (user_id,token_hash,expires_at) VALUES (?,?,?)", (user["id"], hashlib.sha256(token.encode()).hexdigest(), expires.replace(microsecond=0).isoformat()))
|
||||
db.execute("INSERT INTO audit_log (organization_id,user_id,action) VALUES (?,?,?)", (user["organization_id"], user["id"], "login")); db.commit()
|
||||
return self.send_json(200, {"id": user["id"], "email": user["email"], "role": user["role"], "organization_id": user["organization_id"]}, {"Set-Cookie": self.auth_cookie(token, int(timedelta(days=SESSION_DAYS).total_seconds()))})
|
||||
finally: db.close()
|
||||
|
||||
def create_business(self, payload, db, org):
|
||||
if not str(payload.get("name", "")).strip(): return self.send_json(400, {"error": "name_required"})
|
||||
business = normalize_business(payload); suppressions = [dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id = ?", (org,))]
|
||||
if is_suppressed(business, suppressions): return self.send_json(409, {"error": "suppressed"})
|
||||
fields = [(c, business[c]) for c in ("website_domain", "email", "phone") if business[c]]
|
||||
if fields and db.execute("SELECT id FROM businesses WHERE organization_id = ? AND (" + " OR ".join(f"{c} = ?" for c, _ in fields) + ")", [org] + [v for _, v in fields]).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 (?,?,?,?,?,?,?,?,?,?,?)", (org,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 = ? AND organization_id = ?", (cur.lastrowid, org)).fetchone()))
|
||||
|
||||
def create_suppression(self, payload, db, org):
|
||||
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"})
|
||||
try: db.execute("INSERT INTO suppressions (organization_id,kind,value) VALUES (?,?,?)", (org, kind, value)); db.commit()
|
||||
except sqlite3.IntegrityError: pass
|
||||
return self.send_json(201, dict(db.execute("SELECT * FROM suppressions WHERE organization_id = ? AND kind = ? AND value = ?", (org, kind, value)).fetchone()))
|
||||
|
||||
def preview_import(self, payload, db, org):
|
||||
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()])
|
||||
suppressions = [dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id = ?", (org,))]; existing = [row_json(r) for r in db.execute("SELECT * FROM businesses WHERE organization_id = ?", (org,))]; seen = set(); accepted, duplicate, suppressed = [], 0, 0
|
||||
existing_keys = {deduplication_key(x) for x in existing}
|
||||
user=self.require_auth(db)
|
||||
if not user:return
|
||||
if user["role"] not in MUTATING_ROLES:return self.send_json(403,{"error":"forbidden"})
|
||||
bits=path.split("/")
|
||||
if len(bits)==6 and bits[:4]==["","api","v1","businesses"] and bits[5]=="pipeline":return self.update_pipeline(int(bits[4]) if bits[4].isdigit() else -1,self.read_json(),db,user)
|
||||
return self.send_json(404,{"error":"not_found"})
|
||||
finally:db.close()
|
||||
def login(self,payload):
|
||||
db=self.db(); email=str(payload.get("email"," ")).strip().lower(); password=str(payload.get("password","")); user=db.execute("SELECT * FROM users WHERE email=?",(email,)).fetchone()
|
||||
try:
|
||||
if not user or not verify_password(password,user["password_hash"],user["password_salt"]):return self.send_json(401,{"error":"invalid_credentials"})
|
||||
token=secrets.token_urlsafe(32); expires=datetime.now(timezone.utc)+timedelta(days=SESSION_DAYS);db.execute("INSERT INTO sessions(user_id,token_hash,expires_at) VALUES(?,?,?)",(user["id"],hashlib.sha256(token.encode()).hexdigest(),expires.replace(microsecond=0).isoformat()));self.audit(db,user,"login");db.commit();return self.send_json(200,{"id":user["id"],"email":user["email"],"role":user["role"],"organization_id":user["organization_id"]},{"Set-Cookie":self.auth_cookie(token,int(timedelta(days=SESSION_DAYS).total_seconds()))})
|
||||
finally:db.close()
|
||||
def create_business(self,payload,db,user):
|
||||
org=user["organization_id"]
|
||||
if not str(payload.get("name","")).strip():return self.send_json(400,{"error":"name_required"})
|
||||
b=normalize_business(payload); suppressions=[dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id=?",(org,))]
|
||||
if is_suppressed(b,suppressions):return self.send_json(409,{"error":"suppressed"})
|
||||
fields=[(c,b[c]) for c in ("website_domain","email","phone") if b[c]]
|
||||
if fields and db.execute("SELECT id FROM businesses WHERE organization_id=? AND ("+" OR ".join(f"{c}=?" for c,_ in fields)+")",[org]+[v for _,v in fields]).fetchone():return self.send_json(409,{"error":"duplicate"})
|
||||
scored=score_business(b);cur=db.execute("INSERT INTO businesses(organization_id,name,website,website_domain,email,phone,description,score,score_version,score_factors,website_class) VALUES(?,?,?,?,?,?,?,?,?,?,?)",(org,b["name"],b["website"],b["website_domain"],b["email"],b["phone"],str(b.get("description","")),scored["score"],scored["score_version"],json.dumps(scored["factors"]),scored["website_class"])); self.audit(db,user,"business.created",str(cur.lastrowid));db.commit();return self.send_json(201,row_json(db.execute("SELECT * FROM businesses WHERE id=?",(cur.lastrowid,)).fetchone()))
|
||||
def create_suppression(self,payload,db,user):
|
||||
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"})
|
||||
try:db.execute("INSERT INTO suppressions(organization_id,kind,value) VALUES(?,?,?)",(user["organization_id"],kind,value))
|
||||
except sqlite3.IntegrityError:pass
|
||||
self.audit(db,user,"suppression.created",kind);db.commit();return self.send_json(201,dict(db.execute("SELECT * FROM suppressions WHERE organization_id=? AND kind=? AND value=?",(user["organization_id"],kind,value)).fetchone()))
|
||||
def child_business(self,db,bid,user):return self.business(db,bid,user["organization_id"])
|
||||
def create_child(self,bid,table,payload,db,user):
|
||||
if not self.child_business(db,bid,user):return self.send_json(404,{"error":"not_found"})
|
||||
if table=="contacts":
|
||||
email=str(payload.get("email","")).strip().lower(); phone=normalize_phone(payload.get("phone"));
|
||||
if email and not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$",email):return self.send_json(400,{"error":"invalid_contact"})
|
||||
suppressed=is_suppressed({"email":email,"phone":phone},[dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id=?",(user["organization_id"],))]); values=(str(payload.get("name","")).strip(),email,phone,str(payload.get("title","")).strip(),int(bool(payload.get("do_not_contact"))) or int(suppressed))
|
||||
elif table=="domains":
|
||||
value=normalize_domain(payload.get("domain"));
|
||||
if not value:return self.send_json(400,{"error":"invalid_domain"})
|
||||
values=(value,str(payload.get("kind","other")).strip() or "other")
|
||||
elif table=="websites":
|
||||
value=str(payload.get("url","")).strip();
|
||||
if not urlparse(value).scheme or not urlparse(value).netloc:return self.send_json(400,{"error":"invalid_website"})
|
||||
values=(value,str(payload.get("website_class","business_site")).strip() or "business_site")
|
||||
elif table=="evidence":
|
||||
if not str(payload.get("kind","")).strip():return self.send_json(400,{"error":"invalid_evidence"})
|
||||
values=(str(payload["kind"]).strip(),str(payload.get("url","")).strip(),str(payload.get("claim","")).strip())
|
||||
else:
|
||||
if not str(payload.get("body","")).strip():return self.send_json(400,{"error":"body_required"})
|
||||
values=(str(payload["body"]).strip(),)
|
||||
columns=CHILD_TABLES[table]; db.execute(f"INSERT INTO {table}(business_id,organization_id,{','.join(columns)}) VALUES(?, ?, {','.join('?' for _ in columns)})",(bid,user["organization_id"])+values); rid=db.execute("SELECT last_insert_rowid()").fetchone()[0];self.audit(db,user,f"{table}.created",str(rid));db.commit();return self.send_json(201,row_json(db.execute(f"SELECT * FROM {table} WHERE id=?",(rid,)).fetchone()))
|
||||
def update_pipeline(self,bid,payload,db,user):
|
||||
if not self.child_business(db,bid,user) or not str(payload.get("stage","")).strip():return self.send_json(404 if not self.child_business(db,bid,user) else 400,{"error":"not_found" if not self.child_business(db,bid,user) else "stage_required"})
|
||||
stage=str(payload["stage"]).strip();status=str(payload.get("status","active")).strip() or "active";db.execute("INSERT INTO pipeline_entries(business_id,organization_id,stage,status) VALUES(?,?,?,?)",(bid,user["organization_id"],stage,status));rid=db.execute("SELECT last_insert_rowid()").fetchone()[0];self.audit(db,user,"pipeline.updated",stage);db.commit();return self.send_json(200,row_json(db.execute("SELECT * FROM pipeline_entries WHERE id=?",(rid,)).fetchone()))
|
||||
def verify_business(self,bid,payload,db,user):
|
||||
if not self.child_business(db,bid,user):return self.send_json(404,{"error":"not_found"})
|
||||
verified=bool(payload.get("verified",True)); now=datetime.now(timezone.utc).replace(microsecond=0).isoformat();db.execute("UPDATE businesses SET verified=?,verified_at=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND organization_id=?",(int(verified),now if verified else None,bid,user["organization_id"]));self.audit(db,user,"business.verified",str(verified));db.commit();row=self.business(db,bid,user["organization_id"]);return self.send_json(200,row_json(row))
|
||||
def preview_import(self,payload,db,org):
|
||||
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()]); suppressions=[dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id=?",(org,))];existing=[row_json(r) for r in db.execute("SELECT * FROM businesses WHERE organization_id=?",(org,))];seen=set();accepted=[];suppressed=0;existing_keys={deduplication_key(x) for x in existing}
|
||||
for b in normalized:
|
||||
key = deduplication_key(b)
|
||||
if is_suppressed(b, suppressions): suppressed += 1
|
||||
elif key in existing_keys 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})
|
||||
key=deduplication_key(b)
|
||||
if is_suppressed(b,suppressions):suppressed+=1
|
||||
elif key in existing_keys or key in seen:continue
|
||||
else:seen.add(key);accepted.append(b)
|
||||
return self.send_json(200,{"accepted":len(accepted),"duplicates":len(rows)-len(normalized)+len(normalized)-len(accepted)-suppressed,"suppressed":suppressed,"rows":accepted})
|
||||
def log_message(self,*_):pass
|
||||
|
||||
def log_message(self, *_): pass
|
||||
|
||||
|
||||
def create_server(host="127.0.0.1", port=8000, db_path="prospects.db"):
|
||||
server = ThreadingHTTPServer((host, port), ApiHandler); setattr(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()
|
||||
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();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()
|
||||
|
||||
Reference in New Issue
Block a user