commit 44be4efc82176a39b3dfa8a01c2d852dd6cfb579 Author: Marco0300 Date: Wed Sep 2 17:38:50 2026 +0200 build prospect intelligence platform MVP diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..42d4c06 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Local, non-secret runtime configuration. Copy to .env; never commit real credentials. +APP_ENV=development +LOG_LEVEL=INFO +API_PORT=8000 +WEB_PORT=8080 +CORS_ORIGINS=http://localhost:8080 +# Deliberately fixed to false in compose for this MVP. +AUTOMATED_OUTREACH_ENABLED=false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..39bc4d2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + compose: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate Compose configuration + run: docker compose config + + - name: Compile stdlib container code + run: python3 -m compileall -q apps/api apps/web + + - name: Run API tests + run: python3 -m unittest discover -s apps/api/tests -t apps/api + + - name: Build images + run: docker compose build + + - name: Start services + run: docker compose up -d + + - name: Wait for healthy services and smoke test + run: | + set -eu + for i in $(seq 1 30); do + api_status=$(docker inspect --format '{{.State.Health.Status}}' "$(docker compose ps -q api)") + web_status=$(docker inspect --format '{{.State.Health.Status}}' "$(docker compose ps -q web)") + if [ "$api_status" = healthy ] && [ "$web_status" = healthy ]; then + curl --fail http://localhost:8000/api/v1/health/live + curl --fail http://localhost:8080/healthz + exit 0 + fi + sleep 2 + done + docker compose ps + docker compose logs + exit 1 + + - name: Tear down + if: always() + run: docker compose down -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00fad06 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +*.db +__pycache__/ +*.pyc +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..6fd28e9 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# 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.** + +## 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. +- Docker Compose runtime with non-root containers, read-only filesystems, health checks, and a named SQLite data volume. +- Security and operations guidance in `docs/`. + +## Run locally + +```bash +cd apps/api +python3 -m unittest discover -v +python3 app/main.py --host 127.0.0.1 --port 8000 --db /tmp/prospects.db +``` + +Serve the UI separately: + +```bash +cd apps/web +python3 -m http.server 8080 +``` + +Open `http://127.0.0.1:8080`. The UI uses demo data by default; set `window.API_BASE` in the browser console to `http://127.0.0.1:8000` when testing the API locally. + +## API smoke calls + +```bash +curl http://127.0.0.1:8000/api/v1/health/live +curl http://127.0.0.1:8000/api/v1/businesses +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"}' +``` + +## Compose + +```bash +cp .env.example .env +docker compose config --quiet +docker compose up --build -d +curl -fsS http://localhost:8000/api/v1/health/live +curl -fsS http://localhost:8080/healthz +docker compose down +``` + +The initial pilot deliberately omits Postgres, Redis, Celery, external discovery adapters, DNS/HTTP scanning, authentication, and outbound messaging. Those are separate release gates from the implementation plan and must not be inferred from this MVP. Before production use, add real authentication/tenant isolation, migrations, SSRF-safe scanners, approved source registry, queue idempotency, backups/restore drills, legal review, and security testing. + +## 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 +git diff --check +docker compose config --quiet +``` + +See `apps/api/README.md`, `apps/web/README.md`, `docs/SECURITY.md`, and `docs/OPERATIONS.md` for details. diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..11e8400 --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.13-alpine +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 +RUN addgroup -S app && adduser -S -G app app +WORKDIR /app +COPY app /app/app +COPY schema.sql /app/schema.sql +RUN mkdir -p /data && chown -R app:app /app /data +USER app +EXPOSE 8000 +CMD ["python", "/app/app/main.py", "--host", "0.0.0.0", "--port", "8000", "--db", "/data/prospects.db"] diff --git a/apps/api/README.md b/apps/api/README.md new file mode 100644 index 0000000..6c162a4 --- /dev/null +++ b/apps/api/README.md @@ -0,0 +1,27 @@ +# Prospect Platform API MVP + +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. + +## Run + +From this directory: + +```bash +python3 app/main.py +# listens on http://127.0.0.1:8000 +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 + +- `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 SQL uses parameters and all responses are JSON. Scores include `score_version` and `score_factors` for traceability. There is intentionally no send/outreach endpoint. diff --git a/apps/api/app/__init__.py b/apps/api/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/domain.py b/apps/api/app/domain.py new file mode 100644 index 0000000..9ad28ae --- /dev/null +++ b/apps/api/app/domain.py @@ -0,0 +1,95 @@ +"""Pure, dependency-free prospect domain rules.""" +from __future__ import annotations + +import re +from urllib.parse import urlparse + +SCORE_VERSION = "mvp-1" +_SOCIAL = {"facebook.com", "instagram.com", "linkedin.com", "twitter.com", "x.com", "youtube.com", "tiktok.com"} + + +def normalize_domain(value: str | None) -> str: + value = (value or "").strip().lower() + if not value: + return "" + parsed = urlparse(value if "://" in value else "//" + value) + host = (parsed.hostname or "").strip(".") + if host.startswith("www."): + host = host[4:] + return host + + +def normalize_phone(value: str | None) -> str: + return re.sub(r"[^0-9+]", "", (value or "").strip()) + + +def normalize_business(raw: dict) -> dict: + name = " ".join(str(raw.get("name", "")).split()) + email = str(raw.get("email", "")).strip().lower() + website = str(raw.get("website", "")).strip() + domain = normalize_domain(raw.get("website_domain") or website) + phone = normalize_phone(raw.get("phone")) + result = dict(raw) + result.update({"name": name, "email": email, "website": website, "website_domain": domain, "phone": phone}) + return result + + +def classify_website(website_or_domain: str | None) -> str: + domain = normalize_domain(website_or_domain) + if not domain: + return "missing" + if any(domain == item or domain.endswith("." + item) for item in _SOCIAL): + return "social_profile" + return "business_site" + + +def score_business(business: dict) -> dict: + b = normalize_business(business) + score = 0 + factors = [] + if b.get("name"): + score += 20; factors.append("named_business") + site_class = classify_website(b.get("website_domain") or b.get("website")) + if site_class == "business_site": + score += 30; factors.append("business_site") + if b.get("email"): + score += 25; factors.append("email") + if b.get("phone"): + score += 15; factors.append("phone") + if b.get("description"): + score += 10; factors.append("description") + return {"score": min(score, 100), "score_version": SCORE_VERSION, "factors": factors, "website_class": site_class} + + +def suppression_values(business: dict) -> set[str]: + b = normalize_business(business) + return {x for x in (b.get("email"), b.get("website_domain"), b.get("phone")) if x} + + +def is_suppressed(business: dict, suppressions: list[dict]) -> bool: + values = suppression_values(business) + for item in suppressions: + kind, value = item.get("kind", ""), str(item.get("value", "")).strip().lower() + if kind == "domain": value = normalize_domain(value) + elif kind == "phone": value = normalize_phone(value) + if value and value in values: + return True + return False + + +def deduplication_key(business: dict) -> tuple[str, str]: + b = normalize_business(business) + if b["website_domain"]: return ("domain", b["website_domain"]) + if b["email"]: return ("email", b["email"]) + if b["phone"]: return ("phone", b["phone"]) + return ("name", re.sub(r"[^a-z0-9]", "", b["name"].lower())) + + +def deduplicate_businesses(rows: list[dict]) -> list[dict]: + chosen: dict[tuple[str, str], dict] = {} + for raw in rows: + item = normalize_business(raw) + key = deduplication_key(item) + if key not in chosen or sum(bool(v) for v in item.values()) > sum(bool(v) for v in chosen[key].values()): + chosen[key] = item + return list(chosen.values()) diff --git a/apps/api/app/main.py b/apps/api/app/main.py new file mode 100644 index 0000000..e234d84 --- /dev/null +++ b/apps/api/app/main.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import argparse +import json +import os +import sqlite3 +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +if __package__ in (None, ""): + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + from app.domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, score_business +else: + from .domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, score_business + +ORGANIZATION_ID = "demo-tenant" +SCHEMA = Path(__file__).resolve().parents[1] / "schema.sql" + + +def connect(db_path: str) -> sqlite3.Connection: + db = sqlite3.connect(db_path) + db.row_factory = sqlite3.Row + db.execute("PRAGMA foreign_keys = ON") + db.executescript(SCHEMA.read_text()) + return db + + +def row_json(row: sqlite3.Row) -> dict: + result = dict(row) + result["score_factors"] = json.loads(result.pop("score_factors", "[]")) + return result + + +def existing_businesses(db: sqlite3.Connection) -> list[dict]: + return [row_json(r) for r in db.execute("SELECT * FROM businesses WHERE organization_id = ? ORDER BY id", (ORGANIZATION_ID,))] + + +def suppression_rows(db: sqlite3.Connection) -> list[dict]: + return [dict(r) for r in db.execute("SELECT kind, value FROM suppressions WHERE organization_id = ?", (ORGANIZATION_ID,))] + + +class ApiHandler(BaseHTTPRequestHandler): + server_version = "ProspectPlatform/0.1" + + def send_json(self, status: int, payload: dict | list): + body = json.dumps(payload, sort_keys=True).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Access-Control-Allow-Origin", os.environ.get("CORS_ORIGINS", "http://localhost:8080")) + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def read_json(self) -> dict: + try: + length = int(self.headers.get("Content-Length", "0")) + value = json.loads(self.rfile.read(length) or b"{}") + return value if isinstance(value, dict) else {} + except (ValueError, json.JSONDecodeError): + return {} + + def db(self): + return connect(self.server.db_path) + + def do_OPTIONS(self): + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", os.environ.get("CORS_ORIGINS", "http://localhost:8080")) + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.end_headers() + + def do_GET(self): + parsed = urlparse(self.path) + path = parsed.path.rstrip("/") + if path == "/api/v1/health/live": + return self.send_json(200, {"status": "ok", "organization_id": ORGANIZATION_ID}) + db = self.db() + try: + if path == "/api/v1/dashboard/summary": + row = db.execute("SELECT COUNT(*) AS businesses, COALESCE(AVG(score), 0) AS average_score FROM businesses WHERE organization_id = ?", (ORGANIZATION_ID,)).fetchone() + return self.send_json(200, {"organization_id": ORGANIZATION_ID, "businesses": row["businesses"], "average_score": round(row["average_score"], 2), "suppressed": db.execute("SELECT COUNT(*) FROM suppressions WHERE organization_id = ?", (ORGANIZATION_ID,)).fetchone()[0]}) + if path == "/api/v1/businesses": + query = parse_qs(parsed.query).get("q", [""])[0].strip() + if query: + like = f"%{query}%" + rows = db.execute("SELECT * FROM businesses WHERE organization_id = ? AND (name LIKE ? OR website_domain LIKE ? OR email LIKE ?) ORDER BY score DESC, id", (ORGANIZATION_ID, like, like, like)).fetchall() + else: + rows = db.execute("SELECT * FROM businesses WHERE organization_id = ? ORDER BY score DESC, id", (ORGANIZATION_ID,)).fetchall() + return self.send_json(200, {"organization_id": ORGANIZATION_ID, "items": [row_json(r) for r in rows]}) + if path.startswith("/api/v1/businesses/"): + ident = path.rsplit("/", 1)[1] + if not ident.isdigit(): return self.send_json(404, {"error": "not_found"}) + row = db.execute("SELECT * FROM businesses WHERE id = ? AND organization_id = ?", (int(ident), ORGANIZATION_ID)).fetchone() + return self.send_json(200, row_json(row)) if row else self.send_json(404, {"error": "not_found"}) + return self.send_json(404, {"error": "not_found"}) + finally: + db.close() + + def do_POST(self): + path = urlparse(self.path).path.rstrip("/") + payload = self.read_json() + if path == "/api/v1/businesses": return self.create_business(payload) + if path == "/api/v1/suppressions": return self.create_suppression(payload) + if path == "/api/v1/imports/preview": return self.preview_import(payload) + return self.send_json(404, {"error": "not_found"}) + + def create_business(self, payload): + if not str(payload.get("name", "")).strip(): return self.send_json(400, {"error": "name_required"}) + business = normalize_business(payload) + db = self.db() + try: + if is_suppressed(business, suppression_rows(db)): return self.send_json(409, {"error": "suppressed"}) + identity_fields = [("website_domain", business["website_domain"]), ("email", business["email"]), ("phone", business["phone"])] + identity_fields = [(column, value) for column, value in identity_fields if value] + if identity_fields: + predicates = " OR ".join(f"{column} = ?" for column, _ in identity_fields) + values = [value for _, value in identity_fields] + if db.execute(f"SELECT id FROM businesses WHERE organization_id = ? AND ({predicates})", [ORGANIZATION_ID, *values]).fetchone(): + return self.send_json(409, {"error": "duplicate"}) + scored = score_business(business) + cur = db.execute("INSERT INTO businesses (organization_id,name,website,website_domain,email,phone,description,score,score_version,score_factors,website_class) VALUES (?,?,?,?,?,?,?,?,?,?,?)", (ORGANIZATION_ID, business["name"], business["website"], business["website_domain"], business["email"], business["phone"], str(business.get("description", "")), scored["score"], scored["score_version"], json.dumps(scored["factors"]), scored["website_class"])) + db.commit() + return self.send_json(201, row_json(db.execute("SELECT * FROM businesses WHERE id = ?", (cur.lastrowid,)).fetchone())) + finally: db.close() + + def create_suppression(self, payload): + kind, value = payload.get("kind"), str(payload.get("value", "")).strip().lower() + if kind not in {"email", "domain", "phone"} or not value: return self.send_json(400, {"error": "invalid_suppression"}) + db = self.db() + try: + try: + db.execute("INSERT INTO suppressions (organization_id,kind,value) VALUES (?,?,?)", (ORGANIZATION_ID, kind, value)); db.commit() + except sqlite3.IntegrityError: pass + row = db.execute("SELECT * FROM suppressions WHERE organization_id = ? AND kind = ? AND value = ?", (ORGANIZATION_ID, kind, value)).fetchone() + return self.send_json(201, dict(row)) + finally: db.close() + + def preview_import(self, payload): + rows = payload.get("rows", []) + if not isinstance(rows, list): return self.send_json(400, {"error": "rows_required"}) + normalized = deduplicate_businesses([r for r in rows if isinstance(r, dict) and str(r.get("name", "")).strip()]) + db = self.db() + try: + suppressions = suppression_rows(db); existing = existing_businesses(db); seen = set() + accepted, duplicate, suppressed = [], 0, 0 + for b in normalized: + key = deduplication_key(b) + if is_suppressed(b, suppressions): suppressed += 1 + elif (key in {deduplication_key(x) for x in existing} or key in seen): duplicate += 1 + else: seen.add(key); accepted.append(b) + return self.send_json(200, {"accepted": len(accepted), "duplicates": duplicate + len(rows) - len(normalized), "suppressed": suppressed, "rows": accepted}) + finally: db.close() + + def log_message(self, *_): pass + + +def create_server(host="127.0.0.1", port=8000, db_path="prospects.db"): + server = ThreadingHTTPServer((host, port), ApiHandler) + server.db_path = db_path + connect(db_path).close() + return server + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Prospect Platform API") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=int(os.environ.get("PROSPECT_API_PORT", "8000"))) + parser.add_argument("--db", default=os.environ.get("PROSPECT_API_DB", "prospects.db")) + args = parser.parse_args() + server = create_server(args.host, args.port, args.db) + print(f"Prospect API listening on http://{args.host}:{args.port}", flush=True) + try: server.serve_forever() + except KeyboardInterrupt: pass + finally: server.server_close() diff --git a/apps/api/schema.sql b/apps/api/schema.sql new file mode 100644 index 0000000..3a78137 --- /dev/null +++ b/apps/api/schema.sql @@ -0,0 +1,28 @@ +PRAGMA foreign_keys = ON; +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 +); +CREATE INDEX IF NOT EXISTS idx_businesses_org ON businesses(organization_id); +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) +); diff --git a/apps/api/server.py b/apps/api/server.py new file mode 100644 index 0000000..79e53e0 --- /dev/null +++ b/apps/api/server.py @@ -0,0 +1,34 @@ +"""Small stdlib-only MVP API/container smoke-test service.""" +import json +import os +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +class Handler(BaseHTTPRequestHandler): + server_version = "ProspectPlatformMVP/0.1" + + def do_GET(self): # noqa: N802 + if self.path == "/healthz": + self.respond(200, {"status": "ok", "outreach_enabled": False}) + elif self.path == "/": + self.respond(200, {"service": "api", "status": "ok"}) + else: + self.respond(404, {"error": "not_found"}) + + def respond(self, status, payload): + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): + if os.environ.get("LOG_LEVEL", "INFO").upper() != "QUIET": + super().log_message(format, *args) + + +if __name__ == "__main__": + host = os.environ.get("API_HOST", "0.0.0.0") + port = int(os.environ.get("API_PORT", "8000")) + ThreadingHTTPServer((host, port), Handler).serve_forever() diff --git a/apps/api/tests/__init__.py b/apps/api/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py new file mode 100644 index 0000000..ba0e448 --- /dev/null +++ b/apps/api/tests/test_api.py @@ -0,0 +1,55 @@ +import json +import threading +import unittest +from http.client import HTTPConnection +from tempfile import TemporaryDirectory + +from app.main import create_server + + +class ApiSmokeTests(unittest.TestCase): + def setUp(self): + self.tmp = TemporaryDirectory() + self.server = create_server("127.0.0.1", 0, self.tmp.name + "/test.db") + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.conn = HTTPConnection("127.0.0.1", self.server.server_port, timeout=3) + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=2) + self.tmp.cleanup() + + def request(self, method, path, payload=None): + body = json.dumps(payload).encode() if payload is not None else None + self.conn.request(method, path, body, {"Content-Type": "application/json"} if body else {}) + response = self.conn.getresponse() + return response.status, json.loads(response.read()) + + def test_health_create_get_summary_and_import_preview(self): + self.assertEqual(self.request("GET", "/api/v1/health/live")[0], 200) + status, created = self.request("POST", "/api/v1/businesses", {"name": "Acme", "website": "https://acme.co.za", "email": "a@acme.co.za"}) + self.assertEqual(status, 201) + self.assertEqual(created["organization_id"], "demo-tenant") + status, fetched = self.request("GET", "/api/v1/businesses/" + str(created["id"])) + self.assertEqual(status, 200) + self.assertEqual(fetched["name"], "Acme") + status, summary = self.request("GET", "/api/v1/dashboard/summary") + self.assertEqual(status, 200) + self.assertEqual(summary["businesses"], 1) + status, preview = self.request("POST", "/api/v1/imports/preview", {"rows": [{"name": "Acme", "website": "https://acme.co.za"}, {"name": "New Co"}]}) + self.assertEqual(status, 200) + self.assertEqual(preview["accepted"], 1) + self.assertEqual(preview["duplicates"], 1) + + def test_suppression_blocks_new_business(self): + status, _ = self.request("POST", "/api/v1/suppressions", {"kind": "domain", "value": "blocked.co.za"}) + self.assertEqual(status, 201) + status, response = self.request("POST", "/api/v1/businesses", {"name": "Blocked", "website": "https://blocked.co.za"}) + self.assertEqual(status, 409) + self.assertEqual(response["error"], "suppressed") + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/api/tests/test_domain.py b/apps/api/tests/test_domain.py new file mode 100644 index 0000000..42653fa --- /dev/null +++ b/apps/api/tests/test_domain.py @@ -0,0 +1,46 @@ +import unittest + +from app.domain import ( + classify_website, + deduplicate_businesses, + normalize_business, + normalize_domain, + score_business, + is_suppressed, +) + + +class DomainTests(unittest.TestCase): + def test_normalization_canonicalizes_contact_and_domain(self): + value = normalize_business({"name": " Acme ", "website": "HTTPS://WWW.Acme.co.za/path", "email": " SALES@ACME.CO.ZA "}) + self.assertEqual(value["name"], "Acme") + self.assertEqual(value["website_domain"], "acme.co.za") + self.assertEqual(value["email"], "sales@acme.co.za") + + def test_website_classification_is_conservative(self): + self.assertEqual(classify_website("https://acme.co.za"), "business_site") + self.assertEqual(classify_website("https://www.facebook.com/acme"), "social_profile") + self.assertEqual(classify_website(""), "missing") + + def test_scoring_is_versioned_and_explains_factors(self): + result = score_business({"name": "Acme", "website_domain": "acme.co.za", "email": "a@acme.co.za", "phone": "123"}) + self.assertEqual(result["score_version"], "mvp-1") + self.assertGreaterEqual(result["score"], 70) + self.assertIn("business_site", result["factors"]) + + def test_suppression_matches_email_domain_or_phone(self): + business = {"email": "person@example.com", "website_domain": "example.com", "phone": "+27123456789"} + self.assertTrue(is_suppressed(business, [{"kind": "domain", "value": "example.com"}])) + self.assertTrue(is_suppressed(business, [{"kind": "email", "value": "person@example.com"}])) + self.assertTrue(is_suppressed(business, [{"kind": "phone", "value": "+27123456789"}])) + self.assertFalse(is_suppressed(business, [{"kind": "email", "value": "other@example.com"}])) + + def test_deduplication_prefers_richer_record(self): + rows = [{"name": "Acme", "website": "https://acme.com"}, {"name": " acme ", "website": "https://www.acme.com", "email": "a@acme.com"}] + result = deduplicate_businesses(rows) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["email"], "a@acme.com") + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..8e6cce0 --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.13-alpine +RUN addgroup -S app && adduser -S -G app app +WORKDIR /srv +COPY index.html /srv/index.html +COPY styles.css /srv/styles.css +COPY app.js /srv/app.js +COPY healthz /srv/healthz +RUN chown -R app:app /srv +USER app +EXPOSE 8080 +CMD ["python", "-m", "http.server", "8080", "--bind", "0.0.0.0", "--directory", "/srv"] diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..d444260 --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,32 @@ +# ProspectOS web MVP + +Self-contained static frontend for the Prospect Platform API. There is no bundler or runtime dependency: serve this directory with any static HTTP server. + +## Configure and run + +The API base is configurable before `app.js` runs: + +```html + + +``` + +If not set, the UI uses `localStorage.prospect_api_base` when present, then renders clearly-labelled demo data. The API contract used by this page is the current MVP contract: + +- `GET /api/v1/businesses` (optional filtering is performed client-side) +- `GET /api/v1/dashboard/summary` +- `POST /api/v1/businesses` + +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. + +## 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. + +A zero-dependency static smoke page (`smoke-test.html`) checks the key DOM contract in an iframe and reports pass/fail. diff --git a/apps/web/app.js b/apps/web/app.js new file mode 100644 index 0000000..341eadc --- /dev/null +++ b/apps/web/app.js @@ -0,0 +1,60 @@ +/* ProspectOS frontend MVP. Configure before loading with window.API_BASE = 'http://127.0.0.1:8000'; */ +(() => { + 'use strict'; + const API_BASE = (window.API_BASE || localStorage.getItem('prospect_api_base') || '').replace(/\/$/, ''); + const endpoint = (path) => `${API_BASE}${path}`; + const demoProspects = [ + {id:1,name:'Northstar Creative',website:'https://northstarcreative.co.za',website_domain:'northstarcreative.co.za',location:'Cape Town, ZA',score:92,score_factors:['named_business','business_site','email','phone'],email:'hello@northstarcreative.co.za',phone:'+27215550101',updated_at:'2026-08-31T09:00:00Z',status:'reviewed',confidence:'High'}, + {id:2,name:'Berg & Bloom',website:'https://bergandbloom.co.za',website_domain:'bergandbloom.co.za',location:'Johannesburg, ZA',score:78,score_factors:['named_business','business_site','description'],description:'Independent retail studio',updated_at:'2026-08-29T09:00:00Z',status:'review',confidence:'Medium'}, + {id:3,name:'Mosaic Studio',website:'',website_domain:'',location:'Durban, ZA',score:45,score_factors:['named_business'],updated_at:'2026-08-12T09:00:00Z',status:'review',confidence:'Low'}, + {id:4,name:'Cedar Works',website:'https://cedarworks.co.za',website_domain:'cedarworks.co.za',location:'Pretoria, ZA',score:83,score_factors:['named_business','business_site','phone'],phone:'+27125550102',updated_at:'2026-08-30T09:00:00Z',status:'reviewed',confidence:'High'}, + {id:5,name:'Studio Lumen',website:'https://instagram.com/studiolumen',website_domain:'instagram.com',location:'Gqeberha, ZA',score:55,score_factors:['named_business'],updated_at:'2026-08-20T09:00:00Z',status:'suppressed',suppressed:true,suppression_reason:'Suppressed by domain match',confidence:'Low'}, + {id:6,name:'Field Notes Co.',website:'https://fieldnotes.example',website_domain:'fieldnotes.example',location:'Cape Town, ZA',score:67,score_factors:['named_business','business_site'],updated_at:'2026-08-25T09:00:00Z',status:'review',confidence:'Medium'} + ]; + let prospects = []; + let selectedId = null; + 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 labelFactor = (f) => ({named_business:'Named business',business_site:'Business site',email:'Email found',phone:'Phone found',description:'Description'}[f] || String(f).replaceAll('_',' ')); + 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 `${esc(p.name)}${esc(p.website_domain || 'no detected website')}${s} / 100${factors.length} signal${factors.length===1?'':'s'}${esc(factors.slice(0,2).map(labelFactor).join(' · ') || 'Limited evidence')}${f.label}${st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1)}›`; }).join('') : 'No prospects match these filters.'; + document.querySelectorAll('#prospectRows tr[data-id]').forEach(row => row.addEventListener('click', () => { selectedId = Number(row.dataset.id); renderRows(); renderDetail(); })); + } + function renderDetail() { + const p = prospects.find(x => Number(x.id) === Number(selectedId)); if (!p) return; + const s=scoreFor(p), st=statusOf(p), f=freshness(p), factors=p.score_factors||p.factors||[], blocked=st==='review' || st==='suppressed'; + $('detailPanel').innerHTML = `

PROSPECT DETAIL

${esc(p.name)}

${esc(p.website_domain || 'no detected website')}

${st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1)}
Fit score${s}/ 100
${esc(p.confidence || (s>=80?'High':s>=60?'Medium':'Low'))} confidence

Evidence & signals

${factors.length ? factors.map(x=>`

✓ ${esc(labelFactor(x))}${esc(p.confidence || 'Medium')}

`).join('') : '

Limited evidence available for this record.

'}

Data quality

Last checked${f.label}

Website${p.website_domain?'Detected':'no detected website'}

${blocked?`

${st==='suppressed'?'Suppressed records cannot be contacted.':'Review this prospect before outreach is available.'}

`:''}`; + } + async function loadData() { + $('apiStatus').textContent = API_BASE ? '● Connecting…' : '● Demo data'; + try { const [listRes, summaryRes] = await Promise.all([fetch(endpoint('/api/v1/businesses')), fetch(endpoint('/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) { prospects=demoProspects; renderMetrics(null); $('apiStatus').textContent=API_BASE?'● API unavailable · demo data':'● Demo data'; } + renderRows(); + } + async function addProspect(event) { event.preventDefault(); const data=Object.fromEntries(new FormData(event.currentTarget).entries()); const msg=$('formMessage'); if (!API_BASE) { prospects.unshift({...data,id:`local-${Date.now()}`,score:data.website?50:20,status:'review',confidence:'Low'}); msg.textContent='Added to local preview review queue.'; event.currentTarget.reset(); renderMetrics(null); renderRows(); return; } try { const res=await fetch(endpoint('/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) { 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=`${h.map(x=>``).join('')}${rows.map(r=>`${h.map(x=>``).join('')}`).join('')}
${esc(x)}
${esc(r[x])}
Showing up to 10 rows · Preview only; nothing added yet.`; } + $('searchInput').addEventListener('input',renderRows); $('scoreFilter').addEventListener('change',renderRows); $('statusFilter').addEventListener('change',renderRows); $('refreshBtn').addEventListener('click',loadData); $('addForm').addEventListener('submit',addProspect); $('csvInput').addEventListener('change',e=>{const file=e.target.files[0]; if(file){const reader=new FileReader();reader.onload=()=>renderCsv(parseCsv(reader.result));reader.readAsText(file);}}); $('menuBtn').addEventListener('click',()=>document.querySelector('.sidebar').classList.toggle('open')); document.querySelectorAll('[data-scroll]').forEach(b=>b.addEventListener('click',()=>document.querySelector(b.dataset.scroll)?.scrollIntoView())); + loadData(); +})(); diff --git a/apps/web/healthz b/apps/web/healthz new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/apps/web/healthz @@ -0,0 +1 @@ +ok diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..e671990 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,47 @@ + + + + + + ProspectOS · Pipeline intelligence + + + + +
+ +
+
Workspace / Growth pipeline
● Demo data
AR
+
+

EVIDENCE-LED PROSPECTING

Good morning, Alex

Your pipeline has 0 prospects ready for review.

+
+

Total prospects

0

● Current workspace
+

Needs review

0

● Human verification
+

High-fit prospects

0

● Score 80+
+

Freshness under 7d

0%

● Evidence coverage
+
+
+

PIPELINE

Prospect explorer

+
+
Showing 0 prospects High fit Needs review
+
CompanyFit scoreEvidenceFreshnessStatus
+
+ +
+

INTAKE

Add a prospect

Manual entry
+

BULK INTAKE

CSV preview

Preview rows before adding them to your review queue.

No file selected

CSV stays in your browser until you confirm.
+ +
+
+
+ + + diff --git a/apps/web/smoke-test.html b/apps/web/smoke-test.html new file mode 100644 index 0000000..227a7ce --- /dev/null +++ b/apps/web/smoke-test.html @@ -0,0 +1 @@ +ProspectOS smoke test

ProspectOS static smoke test

Running…

\ No newline at end of file diff --git a/apps/web/styles.css b/apps/web/styles.css new file mode 100644 index 0000000..e2f099d --- /dev/null +++ b/apps/web/styles.css @@ -0,0 +1 @@ +:root{--ink:#172033;--muted:#6d7890;--line:#e7eaf1;--surface:#fff;--bg:#f7f8fb;--violet:#6756e8;--violet-soft:#efedff;--green:#16845b;--green-soft:#e5f7ef;--amber:#b87513;--amber-soft:#fff3dd;--red:#b84d55;--red-soft:#fff0f1;--shadow:0 10px 30px rgba(33,36,75,.05)}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.app-shell{display:flex;min-height:100vh}.sidebar{width:238px;background:#17152e;color:#e5e4f2;display:flex;flex-direction:column;padding:28px 16px;position:fixed;inset:0 auto 0 0}.brand{color:#fff;display:flex;align-items:center;gap:10px;text-decoration:none;font-size:20px;font-weight:750;padding:0 14px 44px;letter-spacing:-.5px}.brand-mark{width:27px;height:27px;border-radius:8px;background:#7263f3;display:grid;place-items:center;font-size:16px}.brand-light{font-weight:400;color:#a7a5c1}.nav-item{display:flex;align-items:center;gap:13px;color:#a8a7bd;text-decoration:none;padding:12px 15px;border-radius:9px;margin:3px 0}.nav-item span{font-size:20px;width:18px;text-align:center}.nav-item.active,.nav-item:hover{color:#fff;background:#2a2749}.sidebar-foot{margin-top:auto;border-top:1px solid #302d4b;padding:20px 14px 4px;display:flex;gap:9px;align-items:flex-start;font-size:12px}.sidebar-foot small{display:block;color:#85839e;margin-top:3px}.live-dot{background:#45d99c;width:7px;height:7px;border-radius:50%;margin-top:5px;box-shadow:0 0 0 4px #23463d}.main{margin-left:238px;flex:1;min-width:0}.topbar{height:76px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 4.5%;color:var(--muted)}.crumb span{padding:0 10px;color:#c2c5ce}.top-actions{display:flex;align-items:center;gap:22px}.api-status{color:#7d8494;font-size:12px}.api-status.live{color:var(--green)}.icon-button{border:0;background:none;color:#7c8497;font-size:21px;cursor:pointer}.avatar{width:33px;height:33px;border-radius:50%;display:grid;place-items:center;background:#e5e2ff;color:#5648c8;font-weight:700;font-size:11px}.content{max-width:1450px;margin:auto;padding:40px 4.5% 28px}.hero{display:flex;justify-content:space-between;align-items:end;margin-bottom:30px}.eyebrow{color:#8d94a4;font-size:10px;letter-spacing:1.6px;font-weight:750;margin:0 0 9px}.hero h1{font-size:30px;letter-spacing:-1px;margin:0 0 7px}.hero h1 span{color:#7666f1}.hero-sub{color:var(--muted);margin:0}.hero-sub strong{color:var(--ink)}.button{border:0;border-radius:8px;padding:10px 15px;font-weight:650;cursor:pointer;white-space:nowrap}.primary{background:var(--violet);color:#fff;box-shadow:0 6px 14px #6756e833}.primary:hover{background:#5848d7}.ghost{background:#fff;border:1px solid var(--line);color:#5d6678}.ghost:hover{border-color:#bcb6ff;color:var(--violet)}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:24px}.metric-card,.panel{background:var(--surface);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow)}.metric-card{padding:20px;display:flex;gap:14px;min-height:130px}.metric-icon{height:40px;width:40px;border-radius:11px;display:grid;place-items:center;font-size:22px}.violet{background:var(--violet-soft);color:var(--violet)}.amber{background:var(--amber-soft);color:var(--amber)}.green{background:var(--green-soft);color:var(--green)}.blue{background:#e9f2ff;color:#3e80d5}.metric-card p{margin:2px 0 5px;color:var(--muted);font-size:12px}.metric-card h2{margin:0 0 6px;font-size:26px;letter-spacing:-1px}.trend{font-size:11px;font-weight:700}.trend em{font-style:normal;font-weight:400;color:#a2a8b5}.up{color:var(--green)}.neutral{color:#8b93a2}.workspace-grid{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(290px,.75fr);gap:18px;margin-bottom:24px}.panel{padding:24px}.panel-heading{display:flex;justify-content:space-between;align-items:start;margin-bottom:20px}.panel h2{font-size:18px;margin:0;letter-spacing:-.3px}.filters{display:grid;grid-template-columns:minmax(160px,1fr) 150px 150px;gap:9px;margin-bottom:16px}.search-wrap{display:flex;align-items:center;border:1px solid var(--line);border-radius:8px;background:#fff;color:#a2a8b5;padding:0 11px}.search-wrap input{border:0;outline:0;padding:10px 8px;width:100%;font:inherit;color:var(--ink);background:transparent}.filters select{border:1px solid var(--line);border-radius:8px;padding:0 10px;color:#596478;background:#fff;font:inherit}.table-meta{color:#9299a8;font-size:11px;display:flex;justify-content:space-between;margin:0 0 9px}.legend{display:flex;gap:6px;align-items:center}.legend-dot{width:7px;height:7px;border-radius:50%;display:inline-block;margin-left:8px}.high-dot{background:#52bf93}.review-dot{background:#e8a84f}.table-scroll{overflow-x:auto}table{border-collapse:collapse;width:100%;min-width:650px}th{text-align:left;color:#9aa1af;font-size:10px;letter-spacing:.6px;text-transform:uppercase;font-weight:700;padding:11px 8px;border-bottom:1px solid var(--line)}td{padding:15px 8px;border-bottom:1px solid #f0f1f5;vertical-align:middle;color:#485367;font-size:12px}tbody tr{cursor:pointer;transition:background .15s}tbody tr:hover,tbody tr.selected{background:#faf9ff}td:first-child{color:var(--ink);font-weight:700;font-size:13px}.company-sub{display:block;font-size:11px;color:#99a0ae;font-weight:400;margin-top:2px}.score{display:inline-flex;align-items:center;gap:5px;border-radius:15px;padding:4px 8px;font-weight:750;font-size:11px}.score.high{color:var(--green);background:var(--green-soft)}.score.medium{color:var(--amber);background:var(--amber-soft)}.score.low{color:#788193;background:#eef0f4}.evidence{color:#596478}.evidence strong{display:block;color:var(--ink);font-size:12px}.fresh{font-size:11px}.fresh.good{color:var(--green)}.fresh.stale{color:var(--amber)}.status{font-size:10px;border-radius:4px;padding:4px 6px;font-weight:700}.status.review{background:var(--amber-soft);color:var(--amber)}.status.reviewed{background:var(--green-soft);color:var(--green)}.status.suppressed{background:var(--red-soft);color:var(--red)}.row-arrow{font-size:18px;color:#aeb4c0}.detail-panel{min-height:420px}.empty-detail{text-align:center;color:var(--muted);padding:55px 20px}.empty-icon{display:grid;place-items:center;margin:auto auto 17px;background:var(--violet-soft);color:var(--violet);width:48px;height:48px;border-radius:50%;font-size:23px}.empty-detail h3{color:var(--ink);margin:0 0 8px}.empty-detail p{margin:auto;max-width:220px;font-size:12px}.detail-head{display:flex;justify-content:space-between;gap:10px}.detail-head h3{margin:0;font-size:19px}.detail-domain{color:#949baa;font-size:12px;margin:3px 0 20px}.detail-score{display:flex;align-items:center;justify-content:space-between;background:#f9f8ff;padding:14px;border-radius:9px;margin-bottom:18px}.detail-score b{font-size:27px;color:var(--violet)}.detail-score small{display:block;color:var(--muted)}.detail-block{border-top:1px solid var(--line);padding:15px 0}.detail-block h4{font-size:10px;color:#8c94a4;text-transform:uppercase;letter-spacing:1px;margin:0 0 10px}.detail-block p{font-size:12px;margin:5px 0;color:#556176}.evidence-line{display:flex;justify-content:space-between;gap:10px}.confidence{color:var(--violet);font-weight:700}.disabled-action{width:100%;margin-top:5px;color:#a0a6b3;background:#f0f1f4;cursor:not-allowed}.disabled-reason{font-size:11px;color:var(--red);margin:8px 0 0}.lower-grid{display:grid;grid-template-columns:1.3fr 1fr;gap:18px}.small-label,.optional{color:#9ca3b1;font-size:11px;font-weight:400}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}label{display:block;color:#596478;font-size:12px;font-weight:650}label input{display:block;width:100%;margin-top:7px;border:1px solid var(--line);border-radius:7px;padding:10px 11px;font:inherit;outline:0}label input:focus{border-color:#9489f5;box-shadow:0 0 0 3px #eeeaff}.form-footer{display:flex;align-items:center;justify-content:space-between;margin-top:20px}.form-message{font-size:11px;color:var(--green);margin:0}.form-message.error{color:var(--red)}.muted{color:var(--muted);font-size:12px}.csv-empty{border:1px dashed #d9dce8;border-radius:9px;text-align:center;padding:22px;color:#adb3c0}.csv-empty span{font-size:26px}.csv-empty p{margin:4px 0;font-size:12px;color:#737d90}.csv-empty small{font-size:10px}.csv-table{max-height:150px;overflow:auto;font-size:11px}.csv-table table{min-width:400px}.csv-table th,.csv-table td{padding:7px}.upload-label{display:inline-block}footer{display:flex;justify-content:space-between;color:#a0a6b2;font-size:11px;padding:30px 2px 0}footer a{color:var(--violet);text-decoration:none}.mobile-menu{display:none;border:0;background:transparent;font-size:21px;color:var(--ink)}@media(max-width:1050px){.metrics{grid-template-columns:repeat(2,1fr)}.workspace-grid{grid-template-columns:1fr}.detail-panel{min-height:auto}.lower-grid{grid-template-columns:1fr}}@media(max-width:700px){.sidebar{transform:translateX(-100%);transition:transform .2s;z-index:5;width:230px}.sidebar.open{transform:translateX(0)}.main{margin-left:0}.topbar{padding:0 20px}.mobile-menu{display:block}.crumb{font-size:12px}.top-actions{gap:12px}.api-status{display:none}.content{padding:28px 16px}.hero{align-items:start;gap:18px;flex-direction:column}.hero h1{font-size:25px}.metrics{grid-template-columns:1fr 1fr;gap:10px}.metric-card{padding:15px;min-height:112px;gap:9px}.metric-icon{width:34px;height:34px;font-size:18px}.metric-card h2{font-size:22px}.panel{padding:18px}.filters{grid-template-columns:1fr;gap:8px}.filters select{height:38px}.table-meta{align-items:start;gap:8px;flex-direction:column}.legend{display:none}.form-grid{grid-template-columns:1fr}.form-footer{align-items:start;gap:14px;flex-direction:column}.form-footer .button{width:100%}footer{flex-direction:column;gap:5px}} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..97e90a1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,60 @@ +services: + api: + build: + context: ./apps/api + dockerfile: Dockerfile + image: prospect-platform-api:local + environment: + APP_ENV: ${APP_ENV:-development} + API_HOST: 0.0.0.0 + API_PORT: 8000 + LOG_LEVEL: ${LOG_LEVEL:-INFO} + CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8080} + DATA_DIR: /data + AUTOMATED_OUTREACH_ENABLED: "false" + ports: + - "${API_PORT:-8000}:8000" + volumes: + - prospect_api_data:/data + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health/live', timeout=2)"] + interval: 10s + timeout: 3s + retries: 5 + start_period: 5s + restart: unless-stopped + + web: + build: + context: ./apps/web + dockerfile: Dockerfile + image: prospect-platform-web:local + environment: + AUTOMATED_OUTREACH_ENABLED: "false" + ports: + - "${WEB_PORT:-8080}:8080" + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + healthcheck: + test: ["CMD", "wget", "--spider", "--quiet", "http://127.0.0.1:8080/healthz"] + interval: 10s + timeout: 3s + retries: 5 + start_period: 3s + restart: unless-stopped + +volumes: + prospect_api_data: + name: prospect-platform-api-data diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md new file mode 100644 index 0000000..4db8de3 --- /dev/null +++ b/docs/OPERATIONS.md @@ -0,0 +1,67 @@ +# Operations Runbook + +## Start, inspect, stop + +From the repository root: + +```sh +docker compose up --build -d +docker compose ps +docker compose logs --follow api web +docker compose down +``` + +The expected health endpoints are: + +- API: `GET http://localhost:8000/healthz` +- Web: `GET http://localhost:8080/healthz` + +A service is ready only when Compose reports `healthy`; container running status alone is insufficient. The API health response includes `"outreach_enabled": false` as an operational safety check. + +## Configuration and deployment + +Copy `.env.example` for local development. Production values must be supplied by the deployment environment, never committed. Keep `AUTOMATED_OUTREACH_ENABLED=false`; automated outreach is explicitly disabled in this MVP and there is no supported production enablement path in this repository. + +Before deployment: + +1. Run `docker compose config` and review the rendered configuration. +2. Build from a reviewed commit and scan the resulting images. +3. Restrict host/network exposure at the ingress/firewall. +4. Verify both health checks and review logs for unexpected errors or sensitive data. +5. Record the image digest and configuration revision for rollback. + +## Data, backups, and retention + +The canonical API runtime under `apps/api` uses the named Docker volume `prospect-platform-api-data`. Inspect it with `docker volume inspect prospect-platform-api-data`; do not treat a local Docker volume as a backup. + +For the current MVP there is no database migration or backup command. If runtime data is material, stop writes first and snapshot/copy the volume using an approved host backup process. Protect backup files with encryption and access controls, test a restore into an isolated environment, and document the result. + +Recommended starting policy for a future production data store: + +- daily encrypted backups, with at least 30 days of retention; +- point-in-time recovery where supported; +- one offline or separately isolated copy; +- quarterly restore drills, plus a restore test after storage/provider changes; +- retention and deletion schedules aligned with the source/contact policy and applicable law. + +Do not run `docker compose down -v` on a data-bearing environment: it removes the named volume. + +## Failure handling + +- **Unhealthy API:** inspect `docker compose logs api`, verify port binding and resource availability, then restart with `docker compose restart api` if appropriate. +- **Unhealthy web:** inspect `docker compose logs web`; confirm port `8080` is available and the image contains `/healthz`. +- **Build failure:** run `docker compose build --no-cache` from a reviewed checkout and check Docker daemon/network status. +- **Unexpected outbound traffic:** stop the stack, preserve logs/metadata, and investigate. The MVP has no outreach worker and must not send automated messages. + +## Scaling path + +Adding Postgres, Redis, workers, or schedulers requires explicit readiness checks, migrations, queue durability/idempotency, secret injection, network segmentation, metrics/alerts, backup/restore procedures, and an operational owner. Do not add them as an implicit Compose dependency: this MVP is intentionally runnable without external Postgres or Redis. + +## Incident checklist + +1. Record time, affected service, image/config revision, and observed health state. +2. Preserve relevant logs without exporting secrets or unnecessary contact data. +3. Stop or isolate the affected service if data loss, unauthorized access, SSRF, or unexpected outreach is suspected. +4. Rotate exposed credentials through the secret manager. +5. Validate recovery with health checks and a targeted smoke test. +6. Document root cause, corrective action, and any retention/suppression impact. diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..6c12ac4 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,28 @@ +# Security Notes + +## Current safety boundary + +- **Automated outreach is disabled.** The compose file sets `AUTOMATED_OUTREACH_ENABLED=false` for both services. The MVP sends no email, SMS, or other outbound communication. +- No credentials are committed. `.env.example` contains non-secret names and local defaults only. +- Containers run as an unprivileged user, drop Linux capabilities, use `no-new-privileges`, and use read-only root filesystems. The API data volume is the only intended writable persistent location. +- The stdlib API is a health/smoke service, not a production security boundary. It has no user authentication, authorization, CSRF protection, rate limiting, or audit log implementation. + +## Known limitations before production + +1. **Authentication and authorization:** add an identity provider/session or signed-token design, enforce authorization server-side on every protected route, rotate sessions/tokens, and test tenant isolation. +2. **CSRF:** browser state-changing endpoints must use same-site cookies plus CSRF tokens (or a rigorously reviewed equivalent). Do not rely on CORS as CSRF protection. +3. **SSRF:** any future URL fetcher must allow only `http`/`https`, validate DNS/IP targets, block loopback/private/link-local/cloud-metadata ranges after resolution, limit redirects, enforce size/time limits, and re-check each redirect. Never fetch arbitrary user-provided URLs from the server without these controls. +4. **Input/output safety:** validate schema and content types, bound request sizes, parameterize database queries, escape output, and avoid logging contact data or secrets. +5. **Secrets:** inject production secrets from a secret manager or orchestrator secret store. Do not place them in images, Compose files, source, CI logs, or committed `.env` files. +6. **Transport and perimeter:** terminate TLS at a trusted ingress, restrict exposed ports, add network policy, and place admin surfaces behind appropriate access controls. +7. **Data protection:** define retention and deletion rules for prospect/contact data, restrict volume access, encrypt backups, and maintain an access/audit trail. + +## Source and contact policy + +Treat discovered business information as potentially personal or copyrighted data. Collect only what is needed for the documented product purpose, preserve source attribution where required, respect site terms and robots/access policies, and provide suppression/deletion handling. Do not infer consent to contact from public availability. Any future outreach feature requires an explicit product/legal review and must remain off by default. + +## CI/dependency hygiene + +Pin or review base-image and dependency updates, scan images before release, use least-privilege GitHub tokens, and avoid printing environment values. CI in this MVP performs build and health checks; it is not a substitute for a security assessment. + +Report vulnerabilities privately to the repository maintainers; do not include live credentials or personal data in an issue. diff --git a/infrastructure/docker/api/Dockerfile b/infrastructure/docker/api/Dockerfile new file mode 100644 index 0000000..6276f1f --- /dev/null +++ b/infrastructure/docker/api/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.13-alpine + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +RUN addgroup -S app && adduser -S -G app app +WORKDIR /app +COPY infrastructure/docker/api/server.py /app/server.py +RUN chown -R app:app /app +USER app +EXPOSE 8000 +CMD ["python", "/app/server.py"] diff --git a/infrastructure/docker/api/server.py b/infrastructure/docker/api/server.py new file mode 100644 index 0000000..79e53e0 --- /dev/null +++ b/infrastructure/docker/api/server.py @@ -0,0 +1,34 @@ +"""Small stdlib-only MVP API/container smoke-test service.""" +import json +import os +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +class Handler(BaseHTTPRequestHandler): + server_version = "ProspectPlatformMVP/0.1" + + def do_GET(self): # noqa: N802 + if self.path == "/healthz": + self.respond(200, {"status": "ok", "outreach_enabled": False}) + elif self.path == "/": + self.respond(200, {"service": "api", "status": "ok"}) + else: + self.respond(404, {"error": "not_found"}) + + def respond(self, status, payload): + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): + if os.environ.get("LOG_LEVEL", "INFO").upper() != "QUIET": + super().log_message(format, *args) + + +if __name__ == "__main__": + host = os.environ.get("API_HOST", "0.0.0.0") + port = int(os.environ.get("API_PORT", "8000")) + ThreadingHTTPServer((host, port), Handler).serve_forever() diff --git a/infrastructure/docker/web/Dockerfile b/infrastructure/docker/web/Dockerfile new file mode 100644 index 0000000..8e91838 --- /dev/null +++ b/infrastructure/docker/web/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.13-alpine + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +RUN addgroup -S app && adduser -S -G app app +WORKDIR /srv +COPY infrastructure/docker/web/index.html /srv/index.html +COPY infrastructure/docker/web/healthz /srv/healthz +RUN chown -R app:app /srv +USER app +EXPOSE 8080 +CMD ["python", "-m", "http.server", "8080", "--bind", "0.0.0.0", "--directory", "/srv"] diff --git a/infrastructure/docker/web/healthz b/infrastructure/docker/web/healthz new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/infrastructure/docker/web/healthz @@ -0,0 +1 @@ +ok diff --git a/infrastructure/docker/web/index.html b/infrastructure/docker/web/index.html new file mode 100644 index 0000000..fd095c7 --- /dev/null +++ b/infrastructure/docker/web/index.html @@ -0,0 +1,6 @@ + + +Prospect Platform +

Prospect Platform

+

MVP static web container is running.

+

Automated outreach is disabled.