build prospect intelligence platform MVP
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
*.db
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.DS_Store
|
||||||
@@ -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.
|
||||||
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
|
<script>window.API_BASE = 'http://127.0.0.1:8000';</script>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -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 `<tr data-id="${esc(p.id)}" class="${p.id===selectedId?'selected':''}"><td>${esc(p.name)}<span class="company-sub">${esc(p.website_domain || 'no detected website')}</span></td><td><span class="score ${scoreClass(s)}">${s} <small>/ 100</small></span></td><td class="evidence"><strong>${factors.length} signal${factors.length===1?'':'s'}</strong>${esc(factors.slice(0,2).map(labelFactor).join(' · ') || 'Limited evidence')}</td><td><span class="fresh ${f.cls}">${f.label}</span></td><td><span class="status ${st}">${st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1)}</span></td><td class="row-arrow">›</td></tr>`; }).join('') : '<tr><td colspan="6" class="muted">No prospects match these filters.</td></tr>';
|
||||||
|
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 = `<div class="detail-head"><div><p class="eyebrow">PROSPECT DETAIL</p><h3>${esc(p.name)}</h3><p class="detail-domain">${esc(p.website_domain || 'no detected website')}</p></div><span class="status ${st}">${st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1)}</span></div><div class="detail-score"><div><small>Fit score</small><b>${s}<small>/ 100</small></b></div><span class="score ${scoreClass(s)}">${esc(p.confidence || (s>=80?'High':s>=60?'Medium':'Low'))} confidence</span></div><div class="detail-block"><h4>Evidence & signals</h4>${factors.length ? factors.map(x=>`<p class="evidence-line"><span>✓ ${esc(labelFactor(x))}</span><span class="confidence">${esc(p.confidence || 'Medium')}</span></p>`).join('') : '<p>Limited evidence available for this record.</p>'}</div><div class="detail-block"><h4>Data quality</h4><p class="evidence-line"><span>Last checked</span><span>${f.label}</span></p><p class="evidence-line"><span>Website</span><span>${p.website_domain?'Detected':'no detected website'}</span></p></div>${blocked?`<button class="button disabled-action" disabled aria-disabled="true">Outreach unavailable</button><p class="disabled-reason">${st==='suppressed'?'Suppressed records cannot be contacted.':'Review this prospect before outreach is available.'}</p>`:''}`;
|
||||||
|
}
|
||||||
|
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='<span>⊞</span><p>No data rows found</p>';return;} const h=Object.keys(rows[0]); $('csvPreview').className='csv-table'; $('csvPreview').innerHTML=`<table><thead><tr>${h.map(x=>`<th>${esc(x)}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${h.map(x=>`<td>${esc(r[x])}</td>`).join('')}</tr>`).join('')}</tbody></table><small class="muted">Showing up to 10 rows · Preview only; nothing added yet.</small>`; }
|
||||||
|
$('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();
|
||||||
|
})();
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>ProspectOS · Pipeline intelligence</title>
|
||||||
|
<meta name="description" content="Prospect discovery and review dashboard">
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-shell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<a class="brand" href="#top" aria-label="ProspectOS home"><span class="brand-mark">✦</span><span>Prospect<span class="brand-light">OS</span></span></a>
|
||||||
|
<nav aria-label="Primary navigation">
|
||||||
|
<a class="nav-item active" href="#dashboard"><span>▦</span> Dashboard</a>
|
||||||
|
<a class="nav-item" href="#explorer"><span>⌕</span> Prospect explorer</a>
|
||||||
|
<a class="nav-item" href="#add"><span>+</span> Add prospects</a>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-foot"><div class="live-dot"></div><div><strong>Workspace live</strong><small>Data sync is healthy</small></div></div>
|
||||||
|
</aside>
|
||||||
|
<main class="main" id="top">
|
||||||
|
<header class="topbar"><button class="mobile-menu" id="menuBtn" aria-label="Toggle navigation">☰</button><div class="crumb">Workspace <span>/</span> Growth pipeline</div><div class="top-actions"><span class="api-status" id="apiStatus">● Demo data</span><button class="icon-button" aria-label="Notifications">♢</button><div class="avatar">AR</div></div></header>
|
||||||
|
<div class="content">
|
||||||
|
<section class="hero" id="dashboard"><div><p class="eyebrow">EVIDENCE-LED PROSPECTING</p><h1>Good morning, Alex <span>✦</span></h1><p class="hero-sub">Your pipeline has <strong id="heroCount">0 prospects</strong> ready for review.</p></div><button class="button primary" data-scroll="#add">+ Add prospect</button></section>
|
||||||
|
<section class="metrics" aria-label="Dashboard metrics">
|
||||||
|
<article class="metric-card"><div class="metric-icon violet">◎</div><div><p>Total prospects</p><h2 id="metricTotal">0</h2><span class="trend neutral">● Current workspace</span></div></article>
|
||||||
|
<article class="metric-card"><div class="metric-icon amber">◌</div><div><p>Needs review</p><h2 id="metricReview">0</h2><span class="trend neutral">● Human verification</span></div></article>
|
||||||
|
<article class="metric-card"><div class="metric-icon green">◉</div><div><p>High-fit prospects</p><h2 id="metricHigh">0</h2><span class="trend neutral">● Score 80+</span></div></article>
|
||||||
|
<article class="metric-card"><div class="metric-icon blue">◷</div><div><p>Freshness under 7d</p><h2 id="metricFresh">0%</h2><span class="trend neutral">● Evidence coverage</span></div></article>
|
||||||
|
</section>
|
||||||
|
<section class="workspace-grid" id="explorer">
|
||||||
|
<div class="explorer-panel panel"><div class="panel-heading"><div><p class="eyebrow">PIPELINE</p><h2>Prospect explorer</h2></div><button class="button ghost" id="refreshBtn">↻ Refresh</button></div>
|
||||||
|
<div class="filters"><label class="search-wrap"><span>⌕</span><input id="searchInput" type="search" placeholder="Search companies, domains, locations…" autocomplete="off"></label><select id="scoreFilter" aria-label="Filter by score"><option value="all">All scores</option><option value="high">High fit · 80+</option><option value="medium">Medium · 60–79</option><option value="low">Low · under 60</option></select><select id="statusFilter" aria-label="Filter by status"><option value="all">All statuses</option><option value="review">Needs review</option><option value="reviewed">Reviewed</option><option value="suppressed">Suppressed</option></select></div>
|
||||||
|
<div class="table-meta"><span id="resultCount">Showing 0 prospects</span><span class="legend"><span class="legend-dot high-dot"></span> High fit <span class="legend-dot review-dot"></span> Needs review</span></div>
|
||||||
|
<div class="table-scroll"><table><thead><tr><th>Company</th><th>Fit score</th><th>Evidence</th><th>Freshness</th><th>Status</th><th></th></tr></thead><tbody id="prospectRows"></tbody></table></div>
|
||||||
|
</div>
|
||||||
|
<aside class="detail-panel panel" id="detailPanel"><div class="empty-detail"><span class="empty-icon">◒</span><h3>Select a prospect</h3><p>Review evidence, confidence, and eligibility before taking action.</p></div></aside>
|
||||||
|
</section>
|
||||||
|
<section class="lower-grid" id="add"><article class="panel add-panel"><div class="panel-heading"><div><p class="eyebrow">INTAKE</p><h2>Add a prospect</h2></div><span class="small-label">Manual entry</span></div><form id="addForm"><div class="form-grid"><label>Company name<input required name="name" placeholder="Acme Inc."></label><label>Website <span class="optional">optional</span><input name="website" type="url" placeholder="https://acme.com"></label><label>Location<input name="location" placeholder="Cape Town, ZA"></label><label>Notes <span class="optional">optional</span><input name="description" placeholder="Why this is a fit…"></label></div><div class="form-footer"><p id="formMessage" class="form-message" role="status"></p><button class="button primary" type="submit">Add to review queue</button></div></form></article>
|
||||||
|
<article class="panel csv-panel"><div class="panel-heading"><div><p class="eyebrow">BULK INTAKE</p><h2>CSV preview</h2></div><label class="button ghost upload-label" for="csvInput">↑ Choose CSV</label><input id="csvInput" type="file" accept=".csv,text/csv" hidden></div><p class="muted">Preview rows before adding them to your review queue.</p><div id="csvPreview" class="csv-empty"><span>⊞</span><p>No file selected</p><small>CSV stays in your browser until you confirm.</small></div></article></section>
|
||||||
|
<footer><span>ProspectOS</span><span>Evidence-led prospecting · <a href="#explorer">Review queue</a></span></footer>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<!doctype html><meta charset="utf-8"><title>ProspectOS smoke test</title><style>body{font:16px system-ui;padding:2rem;background:#f7f8fb;color:#172033}li{margin:.5rem 0}.pass{color:#16845b}.fail{color:#b84d55}</style><h1>ProspectOS static smoke test</h1><p id="summary">Running…</p><ul id="checks"></ul><iframe id="app" src="index.html" hidden></iframe><script>const checks=[['Dashboard metrics',d=>!!d.querySelector('#metricTotal')],['Explorer table',d=>!!d.querySelector('#prospectRows')],['Add prospect form',d=>!!d.querySelector('#addForm')],['CSV preview control',d=>!!d.querySelector('#csvInput')],['No outreach/send controls',d=>!Array.from(d.querySelectorAll('button')).some(x=>/outreach|send/i.test(x.textContent)&&!x.disabled)]];const frame=document.querySelector('#app');frame.onload=()=>setTimeout(()=>{const d=frame.contentDocument;let passed=0;document.querySelector('#checks').innerHTML=checks.map(([name,test])=>{const ok=test(d);if(ok)passed++;return `<li class="${ok?'pass':'fail'}">${ok?'PASS':'FAIL'} — ${name}</li>`}).join('');document.querySelector('#summary').textContent=`${passed}/${checks.length} checks passed`;},500);</script>
|
||||||
File diff suppressed because one or more lines are too long
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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"]
|
||||||
@@ -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()
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Prospect Platform</title>
|
||||||
|
<h1>Prospect Platform</h1>
|
||||||
|
<p>MVP static web container is running.</p>
|
||||||
|
<p>Automated outreach is <strong>disabled</strong>.</p>
|
||||||
Reference in New Issue
Block a user