expand manual prospect intelligence workflows

This commit is contained in:
Marco0300
2026-09-02 17:57:17 +02:00
parent d6a5354eea
commit bc33b03075
12 changed files with 477 additions and 347 deletions
+47 -11
View File
@@ -1,6 +1,6 @@
# Prospect Platform API MVP
# Prospect Platform API — Phase 3
Dependency-light JSON API for a single demo tenant (`demo-tenant`). Core domain rules use only Python's standard library; persistence is SQLite. This MVP stores prospect evidence and scores, but never sends outreach.
Dependency-light JSON API for tenant-scoped, **manual** prospect workflows. Core domain rules use Python's standard library and persistence is SQLite. The API stores businesses plus child intelligence/evidence records, pipeline state, notes, and audit context. It never performs automated discovery, DNS/website scanning, or outreach.
## Run
@@ -14,14 +14,50 @@ python3 -m unittest discover
Set `PROSPECT_API_PORT` or pass `--port`; set `PROSPECT_API_DB` or pass `--db` to override the default `prospects.db`.
## Endpoints
## Endpoint contract
- `GET /api/v1/health/live` — liveness.
- `GET /api/v1/dashboard/summary` — tenant-scoped counts and score average.
- `GET /api/v1/businesses` — list businesses; optional `?q=` filter.
- `POST /api/v1/businesses` — create a business from JSON (`name` required; website, email, phone, description optional). Normalization, scoring, deduplication, and suppression are enforced server-side.
- `GET /api/v1/businesses/{id}` — retrieve one tenant-scoped business.
- `POST /api/v1/suppressions` — add `{kind: email|domain|phone, value: ...}`. Future matching business creation is blocked.
- `POST /api/v1/imports/preview` — preview `{rows: [...]}` without writing; reports accepted, duplicates, suppressed, normalized rows.
All protected endpoints require the server-side session cookie. Every query is constrained by the authenticated user's `organization_id`; IDs from another tenant behave as not found and must not disclose whether a record exists.
All SQL uses parameters and all responses are JSON. Scores include `score_version` and `score_factors` for traceability. There is intentionally no send/outreach endpoint.
### Health and workspace
- `GET /api/v1/health/live` — unauthenticated liveness check.
- `GET /api/v1/auth/me` — current authenticated user and tenant.
- `GET /api/v1/dashboard/summary` — tenant-scoped counts and score summary.
### Prospect and child intelligence records
- `GET /api/v1/businesses` — paginated tenant list. Supports bounded `page`/`page_size`, text `q`, `score_min`/`score_max`, `website_class`, and `pipeline_stage` filters, with stable ordering. Responses contain `items`, `page`, `page_size`, and `has_next`; callers must not assume all records are returned.
- `POST /api/v1/businesses` — manual business creation. `name` is required; website, email, phone, description, and other explicitly supported intake fields are optional. Normalization, scoring, deduplication, and suppression are enforced server-side.
- `GET /api/v1/businesses/{id}` — tenant-scoped business detail, including the permitted child intelligence/evidence projection and current pipeline/review context.
The business detail includes the supported child collections: `contacts`, `domains`, `websites`, `evidence`, `pipeline`, and `notes`. The child collection routes are:
- `POST /api/v1/businesses/{id}/contacts` — manually add a contact; suppression matching marks a matching contact as do-not-contact.
- `POST /api/v1/businesses/{id}/domains` — manually add a domain observation.
- `POST /api/v1/businesses/{id}/websites` — manually add a website observation/classification.
- `POST /api/v1/businesses/{id}/evidence` — manually add evidence with its kind, claim, and source URL/reference. This records provenance supplied by the operator; it does not scan or independently verify the URL.
- `POST /api/v1/businesses/{id}/notes` — add a manual note.
Child records are subordinate to their parent business. A child ID is never sufficient authorization: the API verifies both the child ID and the parent business's organization. Do not use a missing source or a score as proof that a website or DNS check occurred.
### Pipeline, verification, and audit behavior
- `POST /api/v1/businesses/{id}/pipeline` — record an allowed human workflow-stage transition, with server-side validation and an audit event.
- `POST /api/v1/businesses/{id}/verify` — record the permitted human verification action and its audit event; it does not perform an external check.
- Each business detail response returns the tenant-scoped child collections and current verification/pipeline context. Audit events are retained in the workspace audit log; the detail projection includes the relevant mutation context where supported.
Pipeline state and verification are review metadata, not outreach authorization. Suppression always wins, and the API exposes no send/contact endpoint. State changes, child records, and notes are human-entered; they do not trigger discovery, scanning, or outbound messaging.
### Existing safety and intake routes
- `POST /api/v1/suppressions` — add `{kind: email|domain|phone, value: ...}` for the current tenant. Future matching business creation is blocked.
- `POST /api/v1/imports/preview` — preview `{rows: [...]}` without writing; reports accepted, duplicates, suppressed, and normalized rows. It is not an import/persistence endpoint.
All SQL uses parameters and all responses are JSON. Scores include `score_version` and `score_factors` for traceability. Provenance is supplied by the operator/source record; the MVP does not validate external sources or independently refresh evidence.
## Pagination and filtering rules
List and child-record endpoints are deliberately bounded. For business lists, use `page` (starting at 1) and `page_size` within the server-enforced maximum; invalid values are rejected rather than allowing an unbounded query. Supported filters are applied inside the tenant-scoped query before pagination: `q`, `score_min`, `score_max`, `website_class`, and `pipeline_stage`. The UI's page and filter controls are convenience clients, not authorization controls. A filtered page is not a count of the entire unfiltered tenant unless the response explicitly says so.
## Remaining limitations
SQLite is a pilot store with no production migration runner, queue, scheduler, durable backup command, or tested restore workflow. Authentication currently uses a development password fallback and does not by itself provide production Argon2id, MFA, CSRF protection, rate limiting, or a complete retention-grade audit system. Automated discovery, DNS/HTTP scanning, and outreach remain explicitly out of scope.
+186 -183
View File
@@ -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()
+61 -45
View File
@@ -1,66 +1,82 @@
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS organizations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
id TEXT PRIMARY KEY, name TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
organization_id TEXT NOT NULL REFERENCES organizations(id),
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
password_salt TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('viewer','owner','admin','researcher')),
id INTEGER PRIMARY KEY AUTOINCREMENT, organization_id TEXT NOT NULL REFERENCES organizations(id), email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, password_salt TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('viewer','owner','admin','researcher')),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_org ON users(organization_id);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token_hash);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
organization_id TEXT REFERENCES organizations(id),
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
details TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
id INTEGER PRIMARY KEY AUTOINCREMENT, organization_id TEXT REFERENCES organizations(id), user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL, details TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_audit_org ON audit_log(organization_id);
CREATE INDEX IF NOT EXISTS idx_audit_org ON audit_log(organization_id,created_at);
CREATE TABLE IF NOT EXISTS businesses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
organization_id TEXT NOT NULL,
name TEXT NOT NULL,
website TEXT NOT NULL DEFAULT '',
website_domain TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
score INTEGER NOT NULL DEFAULT 0,
score_version TEXT NOT NULL DEFAULT 'mvp-1',
score_factors TEXT NOT NULL DEFAULT '[]',
website_class TEXT NOT NULL DEFAULT 'missing',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
id INTEGER PRIMARY KEY AUTOINCREMENT, organization_id TEXT NOT NULL, name TEXT NOT NULL, website TEXT NOT NULL DEFAULT '', website_domain TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '', phone TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '', score INTEGER NOT NULL DEFAULT 0,
score_version TEXT NOT NULL DEFAULT 'mvp-1', score_factors TEXT NOT NULL DEFAULT '[]', website_class TEXT NOT NULL DEFAULT 'missing',
verified INTEGER NOT NULL DEFAULT 0, verified_at TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_businesses_org ON businesses(organization_id);
CREATE INDEX IF NOT EXISTS idx_businesses_score ON businesses(organization_id,score DESC,id);
CREATE INDEX IF NOT EXISTS idx_businesses_class ON businesses(organization_id,website_class);
CREATE UNIQUE INDEX IF NOT EXISTS uq_business_domain ON businesses(organization_id, website_domain) WHERE website_domain <> '';
CREATE UNIQUE INDEX IF NOT EXISTS uq_business_email ON businesses(organization_id, email) WHERE email <> '';
CREATE UNIQUE INDEX IF NOT EXISTS uq_business_phone ON businesses(organization_id, phone) WHERE phone <> '';
CREATE TABLE IF NOT EXISTS suppressions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
organization_id TEXT NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('email','domain','phone')),
value TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(organization_id, kind, value)
id INTEGER PRIMARY KEY AUTOINCREMENT, organization_id TEXT NOT NULL, kind TEXT NOT NULL CHECK(kind IN ('email','domain','phone')),
value TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(organization_id, kind, value)
);
CREATE TABLE IF NOT EXISTS business_identifiers (
id INTEGER PRIMARY KEY AUTOINCREMENT, business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
organization_id TEXT NOT NULL REFERENCES organizations(id), kind TEXT NOT NULL, value TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT, business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
organization_id TEXT NOT NULL REFERENCES organizations(id), name TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '', phone TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '', do_not_contact INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS domains (
id INTEGER PRIMARY KEY AUTOINCREMENT, business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
organization_id TEXT NOT NULL REFERENCES organizations(id), domain TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'other', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS websites (
id INTEGER PRIMARY KEY AUTOINCREMENT, business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
organization_id TEXT NOT NULL REFERENCES organizations(id), url TEXT NOT NULL, website_class TEXT NOT NULL DEFAULT 'business_site', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS evidence (
id INTEGER PRIMARY KEY AUTOINCREMENT, business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
organization_id TEXT NOT NULL REFERENCES organizations(id), kind TEXT NOT NULL, url TEXT NOT NULL DEFAULT '', claim TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS pipeline_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT, business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
organization_id TEXT NOT NULL REFERENCES organizations(id), stage TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS interactions (
id INTEGER PRIMARY KEY AUTOINCREMENT, business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
organization_id TEXT NOT NULL REFERENCES organizations(id), kind TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT, business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
organization_id TEXT NOT NULL REFERENCES organizations(id), body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_contacts_business ON contacts(business_id);
CREATE INDEX IF NOT EXISTS idx_contacts_org_business ON contacts(organization_id,business_id);
CREATE INDEX IF NOT EXISTS idx_identifiers_business ON business_identifiers(business_id,kind);
CREATE INDEX IF NOT EXISTS idx_domains_business ON domains(business_id);
CREATE INDEX IF NOT EXISTS idx_websites_business ON websites(business_id);
CREATE INDEX IF NOT EXISTS idx_evidence_business ON evidence(business_id);
CREATE INDEX IF NOT EXISTS idx_pipeline_business ON pipeline_entries(business_id,created_at);
CREATE INDEX IF NOT EXISTS idx_pipeline_stage ON pipeline_entries(organization_id,stage);
CREATE INDEX IF NOT EXISTS idx_notes_business ON notes(business_id,created_at);
CREATE INDEX IF NOT EXISTS idx_interactions_business ON interactions(business_id,created_at);
+52
View File
@@ -101,6 +101,58 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(status, 409)
self.assertEqual(response["error"], "suppressed")
def test_business_detail_contains_nested_phase_three_resources_and_mutations_audit(self):
status, business = self.request("POST", "/api/v1/businesses", {"name": "Nested Co", "website": "https://nested.test"})
self.assertEqual(status, 201)
bid = business["id"]
for path, payload in [
("contacts", {"name": "Jane", "email": "jane@nested.test"}),
("domains", {"domain": "nested.test", "kind": "primary"}),
("websites", {"url": "https://nested.test", "website_class": "business_site"}),
("evidence", {"kind": "source", "url": "https://source.test", "claim": "Founded 2020"}),
("notes", {"body": "Call next week"}),
]:
self.assertEqual(self.request("POST", f"/api/v1/businesses/{bid}/{path}", payload)[0], 201)
self.assertEqual(self.request("PATCH", f"/api/v1/businesses/{bid}/pipeline", {"stage": "qualified"})[0], 200)
self.assertEqual(self.request("POST", f"/api/v1/businesses/{bid}/verify", {"verified": True})[0], 200)
status, detail = self.request("GET", f"/api/v1/businesses/{bid}")
self.assertEqual(status, 200)
for key in ("contacts", "domains", "websites", "evidence", "pipeline", "notes"):
self.assertEqual(len(detail[key]), 1, key)
self.assertTrue(detail["verified"])
db = sqlite3.connect(self.db_path)
self.assertGreaterEqual(db.execute("SELECT COUNT(*) FROM audit_log WHERE organization_id='demo-tenant'").fetchone()[0], 8)
db.close()
def test_suppressed_contact_is_do_not_contact(self):
self.assertEqual(self.request("POST", "/api/v1/suppressions", {"kind": "email", "value": "blocked@co.test"})[0], 201)
_, business = self.request("POST", "/api/v1/businesses", {"name": "Contact Co"})
status, contact = self.request("POST", f"/api/v1/businesses/{business['id']}/contacts", {"email": "blocked@co.test"})
self.assertEqual(status, 201)
self.assertTrue(contact["do_not_contact"])
def test_business_list_pagination_and_filters(self):
for name, website in [("Alpha", "https://alpha.test"), ("Beta", "https://beta.test"), ("Gamma", "https://gamma.test")]:
self.assertEqual(self.request("POST", "/api/v1/businesses", {"name": name, "website": website})[0], 201)
status, page = self.request("GET", "/api/v1/businesses?page=1&page_size=2&score_min=20&q=Alpha")
self.assertEqual(status, 200)
self.assertEqual([x["name"] for x in page["items"]], ["Alpha"])
status, invalid = self.request("GET", "/api/v1/businesses?page_size=0")
self.assertEqual(status, 400)
self.assertEqual(invalid["error"], "invalid_pagination")
def test_child_resources_are_tenant_scoped_and_validated(self):
_, business = self.request("POST", "/api/v1/businesses", {"name": "Private Co"})
self.assertEqual(self.request("POST", f"/api/v1/businesses/{business['id']}/contacts", {"email": "bad"})[0], 400)
password_hash, salt = hash_password("other-password")
db = sqlite3.connect(self.db_path)
db.execute("INSERT INTO organizations (id,name) VALUES (?,?)", ("other-tenant", "Other"))
db.execute("INSERT INTO users (organization_id,email,password_hash,password_salt,role) VALUES (?,?,?,?,?)", ("other-tenant", "other@example.test", password_hash, salt, "owner"))
db.commit(); db.close()
self.cookie = None
self.assertEqual(self.request("POST", "/api/v1/auth/login", {"email": "other@example.test", "password": "other-password"})[0], 200)
self.assertEqual(self.request("POST", f"/api/v1/businesses/{business['id']}/notes", {"body": "leak"})[0], 404)
if __name__ == "__main__":
unittest.main()