diff --git a/README.md b/README.md index 7aa188a..b5b9565 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,26 @@ # Prospect Intelligence Platform -A safety-first MVP vertical slice for evidence-led prospect discovery and qualification. It stores normalized businesses, scores transparent opportunity signals, preserves reviewable fields, and blocks suppressed records. **Automated outreach is disabled.** +A safety-first Phase 3 vertical slice for **manual**, evidence-led prospect qualification. It stores tenant-owned businesses and their child intelligence records, keeps provenance with each evidence item, supports a review pipeline, and records operationally relevant changes. **Automated discovery, DNS/website scanning, and outreach are not part of this release. Automated outreach is disabled.** ## Included - Dependency-free Python/SQLite API under `apps/api`. -- Normalization, conservative website classification, exact deduplication, versioned scoring, suppression checks. -- JSON API under `/api/v1` for health, dashboard summary, businesses, suppression, and CSV-style import preview. -- Responsive static dashboard under `apps/web` with explorer filters, evidence/freshness labels, detail review, manual intake, and browser-only CSV preview. +- Tenant-scoped business detail APIs with child intelligence/evidence records, provenance fields, notes, pipeline state, and audit history. +- Server-side normalization, conservative website classification, exact deduplication, versioned scoring, and suppression checks. +- Bounded list pagination and server-side filters so a tenant cannot request an unbounded prospect collection. +- Responsive static dashboard under `apps/web` with authenticated explorer filters, paginated results, detail review, manual intake, notes/pipeline context, evidence provenance, and browser-only CSV preview. - Docker Compose runtime with non-root containers, read-only filesystems, health checks, and a named SQLite data volume. - Browser authentication with server-side sessions and an optional first-run admin bootstrap. -- Security and operations guidance in `docs/`. + +## Phase 3 workflow + +1. A permitted workspace member manually creates or reviews a prospect. +2. The business detail response is the aggregate record for that tenant; related intelligence/evidence rows are returned only through the tenant-scoped detail surface. +3. Each manually entered intelligence item should retain its source/provenance (for example, source label or URL, observed value, and captured/verified time). Missing provenance is a data-quality limitation, not permission to infer facts. +4. Members use the pipeline state and notes to coordinate human review. A state change or note is an application event and is included in the record's audit/activity history where exposed by the API. +5. Suppression remains a hard safety boundary. Suppressed or unreviewed records must not be treated as eligible for contact. + +The API applies the organization/tenant boundary server-side to list, detail, child-record, notes, pipeline, and audit reads and writes. Clients must use the returned pagination metadata and follow `next`/`previous` links or tokens rather than assuming that one response contains the whole tenant dataset. See `apps/api/README.md` for the route contract and limits. ## Run locally @@ -33,12 +43,15 @@ Open `http://127.0.0.1:8080`. Set `window.API_BASE` in the browser console to `h ```bash curl http://127.0.0.1:8000/api/v1/health/live -curl http://127.0.0.1:8000/api/v1/businesses +curl 'http://127.0.0.1:8000/api/v1/businesses?page=1&page_size=25&pipeline_stage=new' +curl http://127.0.0.1:8000/api/v1/businesses/1 curl -X POST http://127.0.0.1:8000/api/v1/businesses \ -H 'content-type: application/json' \ -d '{"name":"Example Plumbing","website":"https://example.invalid","email":"info@example.invalid","phone":"+27 21 555 0100"}' ``` +The protected calls require the authenticated session cookie. Exact child-record, notes, pipeline, and audit routes are documented in `apps/api/README.md` and are never cross-tenant addressable by changing an ID. + ## Compose ```bash @@ -54,13 +67,15 @@ Compose passes the optional `BOOTSTRAP_ADMIN_EMAIL` and `BOOTSTRAP_ADMIN_PASSWOR Authenticated browser requests use a server-side session cookie; login creates a session and logout invalidates it. The liveness endpoints (`GET /api/v1/health/live` and `GET /healthz`) intentionally remain unauthenticated so Docker, ingress, and monitoring health checks can use them. Authentication is not a substitute for tenant/authorization checks: protected routes must enforce the session and organization boundary server-side. -The initial pilot still omits Postgres, Redis, Celery, external discovery adapters, DNS/HTTP scanning, and outbound messaging. Before production use, complete the production security gates described in `docs/SECURITY.md`, including Argon2id password hashing, MFA for administrator accounts, TLS, CSRF protection, rate limiting, audit logging, migrations, SSRF-safe scanners, approved source registry, queue idempotency, and tested backups/restores. +## Explicit non-goals and remaining limitations + +This Phase 3 release still has no automated discovery, DNS resolution, website/HTTP scanning, enrichment scheduler, external source adapter, email/SMS sender, or outreach endpoint. CSV remains a browser/API preview flow and does not silently persist rows. SQLite and the named local volume are suitable for the pilot only; there is no production migration runner, queue, or tested backup/restore command. The development password fallback is PBKDF2 rather than production Argon2id. Before production, complete the gates in `docs/SECURITY.md` and `docs/OPERATIONS.md`, including MFA, TLS, CSRF protection, rate limiting, durable audit retention, migrations, approved source policy, SSRF-safe fetching if a future scanner is approved, and tested backups/restores. ## Verification ```bash python3 -m unittest discover -v -s apps/api/tests -t apps/api -php -l /dev/null 2>/dev/null || true # no PHP application is used here +python3 -m compileall -q apps/api apps/web git diff --check docker compose config --quiet ``` diff --git a/apps/api/README.md b/apps/api/README.md index 6c162a4..1192e92 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -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. diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 2954d5f..d7dbeca 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -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() diff --git a/apps/api/schema.sql b/apps/api/schema.sql index ab86073..5844a23 100644 --- a/apps/api/schema.sql +++ b/apps/api/schema.sql @@ -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); diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index 6fca9aa..42b180a 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -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() diff --git a/apps/web/README.md b/apps/web/README.md index c84d120..af7f3ba 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -1,6 +1,6 @@ -# ProspectOS web MVP +# ProspectOS web — Phase 3 -Self-contained static frontend for the Prospect Platform API. There is no bundler or runtime dependency: serve this directory with any static HTTP server. +Self-contained static frontend for the Prospect Platform API. There is no bundler or runtime dependency: serve this directory with any static HTTP server. The UI supports a manual, tenant-scoped review workflow; it does not discover prospects, scan DNS/websites, or send outreach. ## Configure and run @@ -11,22 +11,33 @@ The API base is configurable before `app.js` runs: ``` -If not set, the UI uses `localStorage.prospect_api_base` when present; otherwise it targets the same origin. The dashboard requires an authenticated API session and shows the login screen until `/api/v1/auth/me` succeeds. The API contract used by this page is the current MVP contract: +If not set, the UI uses `localStorage.prospect_api_base` when present; otherwise it targets the same origin. The dashboard requires an authenticated API session and shows the login screen until `/api/v1/auth/me` succeeds. -- `GET /api/v1/businesses` (optional filtering is performed client-side) -- `GET /api/v1/dashboard/summary` -- `POST /api/v1/businesses` +## Phase 3 UI contract -The CSV control is intentionally preview-only. The backend's `POST /api/v1/imports/preview` can be wired to a confirmation flow later; this UI does not claim that import rows have been persisted. +- The explorer requests tenant-scoped business pages from `GET /api/v1/businesses` and sends bounded pagination plus supported search/score/status filters to the API. Filtering is not a substitute for server-side authorization. +- Selecting a row loads the tenant-scoped detail view, including child intelligence/evidence records, provenance/source labels, confidence/freshness, current pipeline state, notes, and relevant audit/activity context when available. +- Add prospect, add intelligence, change pipeline state, and add note are explicit manual actions. The API records the acting user and applies permission, tenant, validation, deduplication, and suppression rules server-side. +- Evidence labels describe stored observations and their provenance. The UI must not present them as the result of automated discovery, DNS lookup, website crawling, or verification unless a future approved integration explicitly supplies that evidence. +- Review and suppressed states remain safety states. The UI shows outreach as unavailable; there is no send button, message composer, sender, or outreach endpoint. +- The CSV control is preview-only and local to the browser. Selecting a file does not persist rows or send them to the API. + +The API remains the source of truth for tenant isolation, pagination bounds, filters, pipeline transitions, notes, audit records, and suppression. See `apps/api/README.md` for the route contract. ## Browser verification 1. Start the API from `apps/api` with `python3 app/main.py`. 2. Serve this directory: `python3 -m http.server 8080 --directory apps/web`. 3. Open `http://127.0.0.1:8080`, with `window.API_BASE` set to `http://127.0.0.1:8000` using a tiny pre-load edit or browser devtools. -4. Confirm the header changes to **API connected**, metrics populate, search and score/status filters update the table, selecting a row opens evidence/confidence/freshness, and adding a prospect POSTs to `/api/v1/businesses`. -5. Confirm rows with `status: review` show **Outreach unavailable — Review this prospect before outreach is available**, and suppressed rows show **Outreach unavailable — Suppressed records cannot be contacted**. There is no outreach/send endpoint or button. -6. Select a CSV and confirm a local, preview-only table appears without a network request. -7. Resize below 700px to verify the collapsible nav, stacked panels, and horizontally scrollable explorer table. +4. Sign in and confirm the header changes to **API connected**, tenant metrics populate, and the explorer renders a bounded page with search, score, status, and pagination controls. +5. Select a row and confirm the detail view keeps the business, child intelligence, evidence provenance, confidence/freshness, pipeline, notes, and audit context associated with that tenant. +6. Add or update only through the explicit manual controls. Confirm the refreshed detail/list state reflects the API response and that a viewer cannot mutate records. +7. Confirm review and suppressed rows show **Outreach unavailable** with the appropriate reason. Confirm there is no outreach/send endpoint or button. +8. Select a CSV and confirm a local, preview-only table appears without a network request or persistence. +9. Resize below 700px to verify the collapsible nav, stacked panels, and horizontally scrollable explorer/detail content. -A zero-dependency static smoke page (`smoke-test.html`) checks the key DOM contract in an iframe and reports pass/fail. +A zero-dependency static smoke page (`smoke-test.html`) checks the key DOM contract in an iframe and reports pass/fail. Browser smoke coverage should treat pagination, detail child records, provenance rendering, pipeline/notes actions, and the disabled outreach state as separate checks. + +## Remaining limitations + +The static client has no background discovery, DNS/website scanner, enrichment scheduler, or outreach integration. It cannot make missing provenance authoritative and should display API-provided limitations rather than infer them. CSV preview is capped for display and is not an import workflow. Production deployment still requires the security and operations gates in `docs/SECURITY.md` and `docs/OPERATIONS.md`. diff --git a/apps/web/app.js b/apps/web/app.js index 935ca70..110b145 100644 --- a/apps/web/app.js +++ b/apps/web/app.js @@ -3,72 +3,42 @@ 'use strict'; const API_BASE = (window.API_BASE || localStorage.getItem('prospect_api_base') || '').replace(/\/$/, ''); const endpoint = (path) => `${API_BASE}${path}`; - let prospects = []; - let selectedId = null; - let currentUser = null; + let prospects = [], selectedId = null, selectedDetail = null, currentUser = null; + let page = 1, pageSize = 10, hasNextPage = false; const $ = (id) => document.getElementById(id); const esc = (value) => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const scoreClass = (score) => score >= 80 ? 'high' : score >= 60 ? 'medium' : 'low'; - const statusOf = (p) => p.suppressed || p.status === 'suppressed' ? 'suppressed' : (p.reviewed || p.status === 'reviewed' || p.reviewed_at ? 'reviewed' : 'review'); - const freshness = (p) => { - const raw = p.updated_at || p.last_checked_at || p.created_at; - if (!raw) return {label:'Unknown', cls:'stale'}; - const days = Math.max(0, Math.floor((Date.now() - new Date(raw).getTime()) / 86400000)); - return {label: days === 0 ? 'Today' : `${days}d ago`, cls: days <= 7 ? 'good' : 'stale'}; - }; - const scoreFor = (p) => Number.isFinite(Number(p.score)) ? Number(p.score) : (p.score_factors || p.factors || []).reduce((n, f) => n + ({named_business:20,business_site:30,email:25,phone:15,description:10}[f] || 0), 0); + const statusOf = (p) => p.suppressed || p.status === 'suppressed' ? 'suppressed' : (p.verified || p.reviewed || p.status === 'reviewed' || p.reviewed_at ? 'reviewed' : 'review'); + const freshness = (p) => { const raw=p.updated_at||p.last_checked_at||p.created_at; if(!raw)return {label:'Unknown',cls:'stale'}; const days=Math.max(0,Math.floor((Date.now()-new Date(raw).getTime())/86400000)); return {label:days===0?'Today':`${days}d ago`,cls:days<=7?'good':'stale'}; }; + const scoreFor = (p) => Number.isFinite(Number(p.score)) ? Number(p.score) : (p.score_factors||p.factors||[]).reduce((n,f)=>n+({named_business:20,business_site:30,email:25,phone:15,description:10}[f]||0),0); const labelFactor = (f) => ({named_business:'Named business',business_site:'Business site',email:'Email found',phone:'Phone found',description:'Description'}[f] || String(f).replaceAll('_',' ')); - async function request(path, options = {}) { - const response = await fetch(endpoint(path), {...options, credentials:'include'}); - if (response.status === 401) { showLogin('Your session has expired. Please sign in again.'); throw new Error('unauthorized'); } - return response; - } - function showLogin(message = '') { - currentUser = null; - $('dashboardShell').hidden = true; $('loginScreen').hidden = false; - $('loginMessage').textContent = message; $('loginMessage').className = `form-message${message ? ' error' : ''}`; - } - function showDashboard(user) { - currentUser = user || {}; - const name = currentUser.name || currentUser.full_name || currentUser.email || 'Workspace member'; - const role = currentUser.role || currentUser.roles?.[0] || 'Member'; - $('userIdentity').textContent = `${name} · ${role}`; - $('userAvatar').textContent = name.split(/\s+/).map(x => x[0]).join('').slice(0,2).toUpperCase(); - $('loginScreen').hidden = true; $('dashboardShell').hidden = false; - } - function renderMetrics(summary) { - const total = Number(summary?.businesses ?? summary?.total ?? prospects.length); - const high = prospects.filter(p => scoreFor(p) >= 80).length; - const review = prospects.filter(p => statusOf(p) === 'review').length; - const fresh = prospects.length ? Math.round(prospects.filter(p => freshness(p).cls === 'good').length / prospects.length * 100) : 0; - $('metricTotal').textContent = total; $('heroCount').textContent = `${total} prospects`; - $('metricReview').textContent = summary?.needs_review ?? review; $('metricHigh').textContent = summary?.high_fit ?? high; $('metricFresh').textContent = `${summary?.freshness_under_7d ?? fresh}%`; - } - function filtered() { - const q = $('searchInput').value.trim().toLowerCase(), sf = $('scoreFilter').value, st = $('statusFilter').value; - return prospects.filter(p => { const s=scoreFor(p), text=`${p.name} ${p.website_domain||p.website||''} ${p.location||''}`.toLowerCase(); return (!q || text.includes(q)) && (sf==='all' || (sf==='high'&&s>=80) || (sf==='medium'&&s>=60&&s<80) || (sf==='low'&&s<60)) && (st==='all' || statusOf(p)===st); }); - } - function renderRows() { - const rows = filtered(); $('resultCount').textContent = `Showing ${rows.length} prospect${rows.length===1?'':'s'}`; - $('prospectRows').innerHTML = rows.length ? rows.map(p => { const s=scoreFor(p), f=freshness(p), st=statusOf(p), factors=p.score_factors||p.factors||[]; return `
PROSPECT DETAIL
${esc(p.website_domain || 'no detected website')}
✓ ${esc(labelFactor(x))}${esc(p.confidence || 'Medium')}
`).join('') : 'Limited evidence available for this record.
'}Last checked${f.label}
Website${p.website_domain?'Detected':'no detected website'}
${st==='suppressed'?'Suppressed records cannot be contacted.':'Review this prospect before outreach is available.'}
`:''}`; - } - async function loadData() { - $('apiStatus').textContent='● Connecting…'; $('apiStatus').classList.remove('live'); - try { const [listRes, summaryRes] = await Promise.all([request('/api/v1/businesses'), request('/api/v1/dashboard/summary')]); if (!listRes.ok || !summaryRes.ok) throw new Error('API request failed'); const list=await listRes.json(), summary=await summaryRes.json(); prospects=Array.isArray(list)?list:(list.businesses||list.items||[]); renderMetrics(summary); $('apiStatus').textContent='● API connected'; $('apiStatus').classList.add('live'); } catch (error) { if (error.message === 'unauthorized') { return; } prospects=[]; renderMetrics(null); $('apiStatus').textContent='● API unavailable'; } - renderRows(); - } - async function addProspect(event) { event.preventDefault(); const data=Object.fromEntries(new FormData(event.currentTarget).entries()); const msg=$('formMessage'); try { const res=await request('/api/v1/businesses',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)}); const body=await res.json(); if(!res.ok) throw new Error(body.error||'Could not add prospect'); prospects.unshift(body); msg.textContent='Added to review queue.'; event.currentTarget.reset(); renderMetrics(null); renderRows(); } catch(e) { if(e.message !== 'unauthorized') { msg.textContent=e.message; msg.className='form-message error'; } } } - function parseCsv(text) { const lines=text.trim().split(/\r?\n/).filter(Boolean), cells=line=>line.match(/("[^"]*(?:""[^"]*)*"|[^,]+)(?=,|$)/g)?.map(x=>x.replace(/^"|"$/g,'').replaceAll('""','"'))||[]; if(!lines.length)return []; const headers=cells(lines[0]); return lines.slice(1,11).map(l=>Object.fromEntries(cells(l).map((v,i)=>[headers[i]||`column_${i+1}`,v]))); } - function renderCsv(rows) { if(!rows.length){$('csvPreview').innerHTML='⊞No data rows found
';return;} const h=Object.keys(rows[0]); $('csvPreview').className='csv-table'; $('csvPreview').innerHTML=`| ${esc(x)} | `).join('')}
|---|
| ${esc(r[x])} | `).join('')}
${esc(error.message)}
${empty}
`; + function renderDetail(p){const s=scoreFor(p),st=statusOf(p),f=freshness(p),factors=p.score_factors||p.factors||[],blocked=st==='review'||st==='suppressed',stage=p.pipeline_stage||p.pipeline?.stage||(Array.isArray(p.pipeline)?p.pipeline.at(-1)?.stage:'')||'new',contacts=p.contacts||[],domains=p.domains||[],websites=p.websites||[],evidence=p.evidence||p.evidence_timeline||[],notes=p.notes||[],review=p.review_status||p.review|| (st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1));$('detailPanel').innerHTML=`PROSPECT DETAIL
${esc(p.website_domain||'no detected website')}
✓ ${esc(labelFactor(x))}${esc(p.confidence||'Medium')}
`).join(''):''}${esc(review)}
${st!=='suppressed'?'':''}${st==='suppressed'?'Suppressed records cannot be contacted.':'Review this prospect before outreach is available.'}
`:''}`;} + function message(id,text,error=false){const el=$(id);if(el){el.textContent=text;el.className=`form-message${error?' error':''}`;}} + async function saveContact(form){const data=Object.fromEntries(new FormData(form).entries());if(!data.email.trim()){message('contactMessage','Email is required.',true);return;}try{await jsonRequest(`/api/v1/businesses/${selectedId}/contacts`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});message('contactMessage','Contact added.');await loadDetail(selectedId);}catch(e){if(e.message!=='unauthorized')message('contactMessage',e.message,true);}} + async function saveNote(form){const data=Object.fromEntries(new FormData(form).entries());if(!data.body.trim()){message('noteMessage','Note cannot be empty.',true);return;}try{await jsonRequest(`/api/v1/businesses/${selectedId}/notes`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});message('noteMessage','Note added.');await loadDetail(selectedId);}catch(e){if(e.message!=='unauthorized')message('noteMessage',e.message,true);}} + async function saveStage(form){const stage=new FormData(form).get('stage');if(!stage){message('pipelineMessage','Choose a pipeline stage.',true);return;}try{await jsonRequest(`/api/v1/businesses/${selectedId}/pipeline`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({stage})});message('pipelineMessage','Pipeline stage updated.');await loadDetail(selectedId);await loadData();}catch(e){if(e.message!=='unauthorized')message('pipelineMessage',e.message,true);}} + async function verify(){try{await jsonRequest(`/api/v1/businesses/${selectedId}/verify`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({verified:true})});message('verifyMessage','Prospect marked verified.');await loadDetail(selectedId);await loadData();}catch(e){if(e.message!=='unauthorized')message('verifyMessage',e.message,true);}} + async function addProspect(event){event.preventDefault();const data=Object.fromEntries(new FormData(event.currentTarget).entries());const msg=$('formMessage');try{const body=await jsonRequest('/api/v1/businesses',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});prospects.unshift(body);msg.textContent='Added to review queue.';event.currentTarget.reset();renderMetrics(null);renderRows();}catch(e){if(e.message!=='unauthorized'){msg.textContent=e.message;msg.className='form-message error';}}} + function parseCsv(text){const lines=text.trim().split(/\r?\n/).filter(Boolean),cells=line=>line.match(/("[^"]*(?:""[^"]*)*"|[^,]+)(?=,|$)/g)?.map(x=>x.replace(/^"|"$/g,'').replaceAll('""','"'))||[];if(!lines.length)return[];const headers=cells(lines[0]);return lines.slice(1,11).map(l=>Object.fromEntries(cells(l).map((v,i)=>[headers[i]||`column_${i+1}`,v])));} + function renderCsv(rows){if(!rows.length){$('csvPreview').innerHTML='⊞No data rows found
';return;}const h=Object.keys(rows[0]);$('csvPreview').className='csv-table';$('csvPreview').innerHTML=`| ${esc(x)} | `).join('')}
|---|
| ${esc(r[x])} | `).join('')}
PIPELINE
| Company | Fit score | Evidence | Freshness | Status |
|---|