from __future__ import annotations import argparse, hashlib, json, os, re, secrets, sqlite3, sys, threading, time 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, normalize_domain, normalize_phone else: 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 MUTATING_ROLES = {"owner", "admin", "researcher"} JOB_TYPES = {"noop", "prospect_recalculate"} JOB_PAGE_SIZE = 100 SECRET_KEYS = {"password", "passwd", "secret", "token", "api_key", "apikey", "authorization", "credential", "private_key"} CHILD_TABLES = {"contacts": ("name", "email", "phone", "title", "do_not_contact"), "domains": ("domain", "kind"), "websites": ("url", "website_class"), "evidence": ("kind", "url", "claim"), "notes": ("body",)} def redact(value): if isinstance(value, dict): return {k: ("[REDACTED]" if str(k).lower() in SECRET_KEYS or any(s in str(k).lower() for s in ("password", "token", "secret", "api_key")) else redact(v)) for k,v in value.items()} if isinstance(value, list): return [redact(v) for v in value[:100]] if isinstance(value, str): return value[:2000] return value def job_json(row): result = row_json(row) try: result["payload"] = json.loads(result.get("payload") or "{}") except (ValueError, TypeError): result["payload"] = {} return result def hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]: 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()) # 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 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): 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, 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: 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 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,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() 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"}) 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=="/api/v1/jobs": return self.list_jobs(db,org,parse_qs(parsed.query)) if path.startswith("/api/v1/jobs/"): return self.get_job_route(db,org,path,parse_qs(parsed.query)) if path.startswith("/api/v1/businesses/"): 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_jobs(self, db, org, query): try: limit=max(1,min(int(query.get("page_size",[50])[0]),JOB_PAGE_SIZE)); offset=max(0,int(query.get("offset",[0])[0])) except (ValueError, TypeError): return self.send_json(400,{"error":"invalid_pagination"}) rows=db.execute("SELECT * FROM jobs WHERE organization_id=? ORDER BY id DESC LIMIT ? OFFSET ?",(org,limit+1,offset)).fetchall(); more=len(rows)>limit return self.send_json(200,{"organization_id":org,"items":[job_json(r) for r in rows[:limit]],"limit":limit,"offset":offset,"has_more":more}) def get_job_route(self, db, org, path, query): bits=path.split("/") if len(bits)<5 or not bits[4].isdigit(): return self.send_json(404,{"error":"not_found"}) job=db.execute("SELECT * FROM jobs WHERE id=? AND organization_id=?",(int(bits[4]),org)).fetchone() if not job:return self.send_json(404,{"error":"not_found"}) if len(bits)==5:return self.send_json(200,job_json(job)) if len(bits)==6 and bits[5]=="events": try: after=max(0,int(query.get("after",[0])[0])) except (ValueError,TypeError): return self.send_json(400,{"error":"invalid_sequence"}) events=[row_json(r) for r in db.execute("SELECT * FROM job_events WHERE job_id=? AND organization_id=? AND sequence>? ORDER BY sequence",(job["id"],org,after))] return self.send_json(200,{"items":events,"after":after}) if len(bits)==7 and bits[5]=="events" and bits[6]=="stream": try: after=max(0,int(query.get("after",[0])[0])) except (ValueError,TypeError): return self.send_json(400,{"error":"invalid_sequence"}) events=[row_json(r) for r in db.execute("SELECT * FROM job_events WHERE job_id=? AND organization_id=? AND sequence>? ORDER BY sequence",(job["id"],org,after))] body=b"".join((b"event: "+str(e["event_type"]).encode()+b"\\ndata: "+json.dumps(e,sort_keys=True).encode()+b"\\n\\n") for e in events) self.send_response(200);self.send_header("Content-Type","text/event-stream");self.send_header("Cache-Control","no-cache");self.send_header("Content-Length",str(len(body)));self.end_headers();self.wfile.write(body);return return self.send_json(404,{"error":"not_found"}) def create_job(self, payload, db, user): kind=str(payload.get("type","")).strip(); key=str(payload.get("idempotency_key","")).strip(); data=payload.get("payload",{}) if kind not in JOB_TYPES:return self.send_json(400,{"error":"invalid_job_type"}) if not key or len(key)>200 or not isinstance(data,dict):return self.send_json(400,{"error":"invalid_job_request"}) safe=json.dumps(redact(data),sort_keys=True,separators=(",",":")); max_attempts=max(1,min(int(payload.get("max_attempts",3)),5)) if str(payload.get("max_attempts",3)).isdigit() else 3 try: cur=db.execute("INSERT INTO jobs(organization_id,idempotency_key,type,payload,max_attempts) VALUES(?,?,?,?,?)",(user["organization_id"],key,kind,safe,max_attempts)); jid=cur.lastrowid self.add_job_event(db,jid,user["organization_id"],"queued","Job queued",0); self.audit(db,user,"job.created",str(jid)); db.commit(); getattr(self.server,"job_wakeup",threading.Event()).set() return self.send_json(201,job_json(db.execute("SELECT * FROM jobs WHERE id=?",(jid,)).fetchone())) except sqlite3.IntegrityError: row=db.execute("SELECT * FROM jobs WHERE organization_id=? AND idempotency_key=?",(user["organization_id"],key)).fetchone(); return self.send_json(200,job_json(row)) def add_job_event(self, db, jid, org, event_type, message, progress=0, error_code=None): seq=db.execute("SELECT COALESCE(MAX(sequence),0)+1 FROM job_events WHERE job_id=?",(jid,)).fetchone()[0] db.execute("INSERT INTO job_events(job_id,organization_id,sequence,event_type,message,progress,error_code) VALUES(?,?,?,?,?,?,?)",(jid,org,seq,event_type,str(message)[:500],progress,error_code)); return seq def job_action(self, db, user, path): bits=path.split("/"); jid=int(bits[4]) if len(bits)>4 and bits[4].isdigit() else -1; action=bits[5] if len(bits)>5 else "" job=db.execute("SELECT * FROM jobs WHERE id=? AND organization_id=?",(jid,user["organization_id"])).fetchone() if not job:return self.send_json(404,{"error":"not_found"}) if action=="cancel": if job["status"] in ("queued","running"): db.execute("UPDATE jobs SET status='cancelled',completed_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=?",(jid,));self.add_job_event(db,jid,user["organization_id"],"cancelled","Job cancelled",job["progress"]) self.audit(db,user,"job.cancelled",str(jid));db.commit();return self.send_json(200,job_json(db.execute("SELECT * FROM jobs WHERE id=?",(jid,)).fetchone())) if action=="retry": if job["status"]!="failed":return self.send_json(409,{"error":"job_not_failed"}) db.execute("UPDATE jobs SET status='queued',error_code=NULL,completed_at=NULL,updated_at=CURRENT_TIMESTAMP WHERE id=?",(jid,));self.add_job_event(db,jid,user["organization_id"],"retry","Job retry queued",job["progress"]);self.audit(db,user,"job.retried",str(jid));db.commit();getattr(self.server,"job_wakeup",threading.Event()).set();return self.send_json(200,job_json(db.execute("SELECT * FROM jobs WHERE id=?",(jid,)).fetchone())) return self.send_json(404,{"error":"not_found"}) 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() try: 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 path=="/api/v1/jobs": if user["role"] not in MUTATING_ROLES:return self.send_json(403,{"error":"forbidden"}) return self.create_job(self.read_json(),db,user) if user["role"] not in MUTATING_ROLES:return self.send_json(403,{"error":"forbidden"}) payload=self.read_json(); org=user["organization_id"] if path.startswith("/api/v1/jobs/"): return self.job_action(db,user,path) 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=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: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 _job_worker(server): while not server.job_stop.is_set(): db=connect(server.db_path) try: job=db.execute("SELECT * FROM jobs WHERE status='queued' ORDER BY id LIMIT 1").fetchone() if not job: db.close(); server.job_wakeup.wait(.1); server.job_wakeup.clear(); continue changed=db.execute("UPDATE jobs SET status='running',attempts=attempts+1,started_at=COALESCE(started_at,CURRENT_TIMESTAMP),updated_at=CURRENT_TIMESTAMP WHERE id=? AND status='queued'",(job["id"],)).rowcount if not changed: db.close(); continue db.commit(); org=job["organization_id"]; jid=job["id"]; server_handler=object.__new__(ApiHandler) server_handler.add_job_event(db,jid,org,"started","Job started",0); db.commit() try: payload=json.loads(job["payload"] or "{}") except ValueError: payload={} try: steps=1 if job["type"]=="noop" else max(1,min(int(payload.get("steps",5)),20)) except (ValueError,TypeError): steps=5 cancelled=False for i in range(steps): time.sleep(.01) fresh=db.execute("SELECT status FROM jobs WHERE id=?",(jid,)).fetchone() if not fresh or fresh["status"]=="cancelled": cancelled=True; break progress=int((i+1)*100/steps); db.execute("UPDATE jobs SET progress=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND status='running'",(progress,jid)); server_handler.add_job_event(db,jid,org,"progress",f"Job progress {progress}%",progress); db.commit() if cancelled: continue if payload.get("force_fail") or (payload.get("fail_once") and job["attempts"] == 0): db.execute("UPDATE jobs SET status='failed',error_code='DEMO_FAILURE',completed_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=?",(jid,));server_handler.add_job_event(db,jid,org,"failed","Job failed",job["progress"],"DEMO_FAILURE") else: db.execute("UPDATE jobs SET status='succeeded',progress=100,completed_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=?",(jid,));server_handler.add_job_event(db,jid,org,"succeeded","Job completed",100) db.commit() finally: db.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();server.job_stop=threading.Event();server.job_wakeup=threading.Event();server.job_thread=threading.Thread(target=_job_worker,args=(server,),daemon=True);server.job_thread.start() original_close=server.server_close def close(): server.job_stop.set();server.job_wakeup.set();server.job_thread.join(timeout=2);original_close() server.server_close=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()