expand manual prospect intelligence workflows
This commit is contained in:
@@ -1,16 +1,26 @@
|
|||||||
# Prospect Intelligence Platform
|
# Prospect Intelligence Platform
|
||||||
|
|
||||||
A safety-first MVP vertical slice for evidence-led prospect discovery and qualification. It stores normalized businesses, scores transparent opportunity signals, preserves reviewable fields, and blocks suppressed records. **Automated outreach is disabled.**
|
A safety-first Phase 3 vertical slice for **manual**, evidence-led prospect qualification. It stores tenant-owned businesses and their child intelligence records, keeps provenance with each evidence item, supports a review pipeline, and records operationally relevant changes. **Automated discovery, DNS/website scanning, and outreach are not part of this release. Automated outreach is disabled.**
|
||||||
|
|
||||||
## Included
|
## Included
|
||||||
|
|
||||||
- Dependency-free Python/SQLite API under `apps/api`.
|
- Dependency-free Python/SQLite API under `apps/api`.
|
||||||
- Normalization, conservative website classification, exact deduplication, versioned scoring, suppression checks.
|
- Tenant-scoped business detail APIs with child intelligence/evidence records, provenance fields, notes, pipeline state, and audit history.
|
||||||
- JSON API under `/api/v1` for health, dashboard summary, businesses, suppression, and CSV-style import preview.
|
- Server-side normalization, conservative website classification, exact deduplication, versioned scoring, and suppression checks.
|
||||||
- Responsive static dashboard under `apps/web` with explorer filters, evidence/freshness labels, detail review, manual intake, and browser-only CSV preview.
|
- Bounded list pagination and server-side filters so a tenant cannot request an unbounded prospect collection.
|
||||||
|
- Responsive static dashboard under `apps/web` with authenticated explorer filters, paginated results, detail review, manual intake, notes/pipeline context, evidence provenance, and browser-only CSV preview.
|
||||||
- Docker Compose runtime with non-root containers, read-only filesystems, health checks, and a named SQLite data volume.
|
- Docker Compose runtime with non-root containers, read-only filesystems, health checks, and a named SQLite data volume.
|
||||||
- Browser authentication with server-side sessions and an optional first-run admin bootstrap.
|
- Browser authentication with server-side sessions and an optional first-run admin bootstrap.
|
||||||
- Security and operations guidance in `docs/`.
|
|
||||||
|
## Phase 3 workflow
|
||||||
|
|
||||||
|
1. A permitted workspace member manually creates or reviews a prospect.
|
||||||
|
2. The business detail response is the aggregate record for that tenant; related intelligence/evidence rows are returned only through the tenant-scoped detail surface.
|
||||||
|
3. Each manually entered intelligence item should retain its source/provenance (for example, source label or URL, observed value, and captured/verified time). Missing provenance is a data-quality limitation, not permission to infer facts.
|
||||||
|
4. Members use the pipeline state and notes to coordinate human review. A state change or note is an application event and is included in the record's audit/activity history where exposed by the API.
|
||||||
|
5. Suppression remains a hard safety boundary. Suppressed or unreviewed records must not be treated as eligible for contact.
|
||||||
|
|
||||||
|
The API applies the organization/tenant boundary server-side to list, detail, child-record, notes, pipeline, and audit reads and writes. Clients must use the returned pagination metadata and follow `next`/`previous` links or tokens rather than assuming that one response contains the whole tenant dataset. See `apps/api/README.md` for the route contract and limits.
|
||||||
|
|
||||||
## Run locally
|
## Run locally
|
||||||
|
|
||||||
@@ -33,12 +43,15 @@ Open `http://127.0.0.1:8080`. Set `window.API_BASE` in the browser console to `h
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://127.0.0.1:8000/api/v1/health/live
|
curl http://127.0.0.1:8000/api/v1/health/live
|
||||||
curl http://127.0.0.1:8000/api/v1/businesses
|
curl 'http://127.0.0.1:8000/api/v1/businesses?page=1&page_size=25&pipeline_stage=new'
|
||||||
|
curl http://127.0.0.1:8000/api/v1/businesses/1
|
||||||
curl -X POST http://127.0.0.1:8000/api/v1/businesses \
|
curl -X POST http://127.0.0.1:8000/api/v1/businesses \
|
||||||
-H 'content-type: application/json' \
|
-H 'content-type: application/json' \
|
||||||
-d '{"name":"Example Plumbing","website":"https://example.invalid","email":"info@example.invalid","phone":"+27 21 555 0100"}'
|
-d '{"name":"Example Plumbing","website":"https://example.invalid","email":"info@example.invalid","phone":"+27 21 555 0100"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The protected calls require the authenticated session cookie. Exact child-record, notes, pipeline, and audit routes are documented in `apps/api/README.md` and are never cross-tenant addressable by changing an ID.
|
||||||
|
|
||||||
## Compose
|
## Compose
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -54,13 +67,15 @@ Compose passes the optional `BOOTSTRAP_ADMIN_EMAIL` and `BOOTSTRAP_ADMIN_PASSWOR
|
|||||||
|
|
||||||
Authenticated browser requests use a server-side session cookie; login creates a session and logout invalidates it. The liveness endpoints (`GET /api/v1/health/live` and `GET /healthz`) intentionally remain unauthenticated so Docker, ingress, and monitoring health checks can use them. Authentication is not a substitute for tenant/authorization checks: protected routes must enforce the session and organization boundary server-side.
|
Authenticated browser requests use a server-side session cookie; login creates a session and logout invalidates it. The liveness endpoints (`GET /api/v1/health/live` and `GET /healthz`) intentionally remain unauthenticated so Docker, ingress, and monitoring health checks can use them. Authentication is not a substitute for tenant/authorization checks: protected routes must enforce the session and organization boundary server-side.
|
||||||
|
|
||||||
The initial pilot still omits Postgres, Redis, Celery, external discovery adapters, DNS/HTTP scanning, and outbound messaging. Before production use, complete the production security gates described in `docs/SECURITY.md`, including Argon2id password hashing, MFA for administrator accounts, TLS, CSRF protection, rate limiting, audit logging, migrations, SSRF-safe scanners, approved source registry, queue idempotency, and tested backups/restores.
|
## Explicit non-goals and remaining limitations
|
||||||
|
|
||||||
|
This Phase 3 release still has no automated discovery, DNS resolution, website/HTTP scanning, enrichment scheduler, external source adapter, email/SMS sender, or outreach endpoint. CSV remains a browser/API preview flow and does not silently persist rows. SQLite and the named local volume are suitable for the pilot only; there is no production migration runner, queue, or tested backup/restore command. The development password fallback is PBKDF2 rather than production Argon2id. Before production, complete the gates in `docs/SECURITY.md` and `docs/OPERATIONS.md`, including MFA, TLS, CSRF protection, rate limiting, durable audit retention, migrations, approved source policy, SSRF-safe fetching if a future scanner is approved, and tested backups/restores.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m unittest discover -v -s apps/api/tests -t apps/api
|
python3 -m unittest discover -v -s apps/api/tests -t apps/api
|
||||||
php -l /dev/null 2>/dev/null || true # no PHP application is used here
|
python3 -m compileall -q apps/api apps/web
|
||||||
git diff --check
|
git diff --check
|
||||||
docker compose config --quiet
|
docker compose config --quiet
|
||||||
```
|
```
|
||||||
|
|||||||
+47
-11
@@ -1,6 +1,6 @@
|
|||||||
# Prospect Platform API MVP
|
# Prospect Platform API — Phase 3
|
||||||
|
|
||||||
Dependency-light JSON API for a single demo tenant (`demo-tenant`). Core domain rules use only Python's standard library; persistence is SQLite. This MVP stores prospect evidence and scores, but never sends outreach.
|
Dependency-light JSON API for tenant-scoped, **manual** prospect workflows. Core domain rules use Python's standard library and persistence is SQLite. The API stores businesses plus child intelligence/evidence records, pipeline state, notes, and audit context. It never performs automated discovery, DNS/website scanning, or outreach.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
@@ -14,14 +14,50 @@ python3 -m unittest discover
|
|||||||
|
|
||||||
Set `PROSPECT_API_PORT` or pass `--port`; set `PROSPECT_API_DB` or pass `--db` to override the default `prospects.db`.
|
Set `PROSPECT_API_PORT` or pass `--port`; set `PROSPECT_API_DB` or pass `--db` to override the default `prospects.db`.
|
||||||
|
|
||||||
## Endpoints
|
## Endpoint contract
|
||||||
|
|
||||||
- `GET /api/v1/health/live` — liveness.
|
All protected endpoints require the server-side session cookie. Every query is constrained by the authenticated user's `organization_id`; IDs from another tenant behave as not found and must not disclose whether a record exists.
|
||||||
- `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.
|
### Health and workspace
|
||||||
|
|
||||||
|
- `GET /api/v1/health/live` — unauthenticated liveness check.
|
||||||
|
- `GET /api/v1/auth/me` — current authenticated user and tenant.
|
||||||
|
- `GET /api/v1/dashboard/summary` — tenant-scoped counts and score summary.
|
||||||
|
|
||||||
|
### Prospect and child intelligence records
|
||||||
|
|
||||||
|
- `GET /api/v1/businesses` — paginated tenant list. Supports bounded `page`/`page_size`, text `q`, `score_min`/`score_max`, `website_class`, and `pipeline_stage` filters, with stable ordering. Responses contain `items`, `page`, `page_size`, and `has_next`; callers must not assume all records are returned.
|
||||||
|
- `POST /api/v1/businesses` — manual business creation. `name` is required; website, email, phone, description, and other explicitly supported intake fields are optional. Normalization, scoring, deduplication, and suppression are enforced server-side.
|
||||||
|
- `GET /api/v1/businesses/{id}` — tenant-scoped business detail, including the permitted child intelligence/evidence projection and current pipeline/review context.
|
||||||
|
The business detail includes the supported child collections: `contacts`, `domains`, `websites`, `evidence`, `pipeline`, and `notes`. The child collection routes are:
|
||||||
|
|
||||||
|
- `POST /api/v1/businesses/{id}/contacts` — manually add a contact; suppression matching marks a matching contact as do-not-contact.
|
||||||
|
- `POST /api/v1/businesses/{id}/domains` — manually add a domain observation.
|
||||||
|
- `POST /api/v1/businesses/{id}/websites` — manually add a website observation/classification.
|
||||||
|
- `POST /api/v1/businesses/{id}/evidence` — manually add evidence with its kind, claim, and source URL/reference. This records provenance supplied by the operator; it does not scan or independently verify the URL.
|
||||||
|
- `POST /api/v1/businesses/{id}/notes` — add a manual note.
|
||||||
|
|
||||||
|
Child records are subordinate to their parent business. A child ID is never sufficient authorization: the API verifies both the child ID and the parent business's organization. Do not use a missing source or a score as proof that a website or DNS check occurred.
|
||||||
|
|
||||||
|
### Pipeline, verification, and audit behavior
|
||||||
|
|
||||||
|
- `POST /api/v1/businesses/{id}/pipeline` — record an allowed human workflow-stage transition, with server-side validation and an audit event.
|
||||||
|
- `POST /api/v1/businesses/{id}/verify` — record the permitted human verification action and its audit event; it does not perform an external check.
|
||||||
|
- Each business detail response returns the tenant-scoped child collections and current verification/pipeline context. Audit events are retained in the workspace audit log; the detail projection includes the relevant mutation context where supported.
|
||||||
|
|
||||||
|
Pipeline state and verification are review metadata, not outreach authorization. Suppression always wins, and the API exposes no send/contact endpoint. State changes, child records, and notes are human-entered; they do not trigger discovery, scanning, or outbound messaging.
|
||||||
|
|
||||||
|
### Existing safety and intake routes
|
||||||
|
|
||||||
|
- `POST /api/v1/suppressions` — add `{kind: email|domain|phone, value: ...}` for the current tenant. Future matching business creation is blocked.
|
||||||
|
- `POST /api/v1/imports/preview` — preview `{rows: [...]}` without writing; reports accepted, duplicates, suppressed, and normalized rows. It is not an import/persistence endpoint.
|
||||||
|
|
||||||
|
All SQL uses parameters and all responses are JSON. Scores include `score_version` and `score_factors` for traceability. Provenance is supplied by the operator/source record; the MVP does not validate external sources or independently refresh evidence.
|
||||||
|
|
||||||
|
## Pagination and filtering rules
|
||||||
|
|
||||||
|
List and child-record endpoints are deliberately bounded. For business lists, use `page` (starting at 1) and `page_size` within the server-enforced maximum; invalid values are rejected rather than allowing an unbounded query. Supported filters are applied inside the tenant-scoped query before pagination: `q`, `score_min`, `score_max`, `website_class`, and `pipeline_stage`. The UI's page and filter controls are convenience clients, not authorization controls. A filtered page is not a count of the entire unfiltered tenant unless the response explicitly says so.
|
||||||
|
|
||||||
|
## Remaining limitations
|
||||||
|
|
||||||
|
SQLite is a pilot store with no production migration runner, queue, scheduler, durable backup command, or tested restore workflow. Authentication currently uses a development password fallback and does not by itself provide production Argon2id, MFA, CSRF protection, rate limiting, or a complete retention-grade audit system. Automated discovery, DNS/HTTP scanning, and outreach remain explicitly out of scope.
|
||||||
|
|||||||
+186
-183
@@ -1,219 +1,222 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
import argparse, hashlib, json, os, re, secrets, sqlite3, sys
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import secrets
|
|
||||||
import sqlite3
|
|
||||||
import sys
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from http.cookies import SimpleCookie
|
from http.cookies import SimpleCookie
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
if __package__ in (None, ""):
|
if __package__ in (None, ""):
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
from app.domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, score_business
|
from app.domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, score_business, normalize_domain, normalize_phone
|
||||||
else:
|
else:
|
||||||
from .domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, score_business
|
from .domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, score_business, normalize_domain, normalize_phone
|
||||||
|
|
||||||
ORGANIZATION_ID = "demo-tenant"
|
ORGANIZATION_ID = "demo-tenant"
|
||||||
SCHEMA = Path(__file__).resolve().parents[1] / "schema.sql"
|
SCHEMA = Path(__file__).resolve().parents[1] / "schema.sql"
|
||||||
SESSION_DAYS = 7
|
SESSION_DAYS = 7
|
||||||
PBKDF2_ITERATIONS = 300_000 # Development fallback: stdlib PBKDF2, not Argon2id.
|
PBKDF2_ITERATIONS = 300_000
|
||||||
MUTATING_ROLES = {"owner", "admin", "researcher"}
|
MUTATING_ROLES = {"owner", "admin", "researcher"}
|
||||||
|
CHILD_TABLES = {"contacts": ("name", "email", "phone", "title", "do_not_contact"), "domains": ("domain", "kind"), "websites": ("url", "website_class"), "evidence": ("kind", "url", "claim"), "notes": ("body",)}
|
||||||
|
|
||||||
def hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]:
|
def hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]:
|
||||||
salt = salt or secrets.token_bytes(16)
|
salt = salt or secrets.token_bytes(16); return hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITERATIONS).hex(), salt.hex()
|
||||||
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITERATIONS)
|
def verify_password(password, encoded_hash, encoded_salt):
|
||||||
return digest.hex(), salt.hex()
|
try: return secrets.compare_digest(hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(encoded_salt), PBKDF2_ITERATIONS).hex(), encoded_hash)
|
||||||
|
except (TypeError, ValueError): return False
|
||||||
|
|
||||||
def verify_password(password: str, encoded_hash: str, encoded_salt: str) -> bool:
|
|
||||||
try:
|
|
||||||
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(encoded_salt), PBKDF2_ITERATIONS).hex()
|
|
||||||
return secrets.compare_digest(digest, encoded_hash)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def connect(db_path: str) -> sqlite3.Connection:
|
def connect(db_path: str) -> sqlite3.Connection:
|
||||||
db = sqlite3.connect(db_path)
|
db = sqlite3.connect(db_path); db.row_factory = sqlite3.Row; db.execute("PRAGMA foreign_keys = ON"); db.executescript(SCHEMA.read_text())
|
||||||
db.row_factory = sqlite3.Row
|
# Upgrade databases created by Phase 1/2 without destroying data.
|
||||||
db.execute("PRAGMA foreign_keys = ON")
|
cols = {r[1] for r in db.execute("PRAGMA table_info(businesses)")}
|
||||||
db.executescript(SCHEMA.read_text())
|
for col, definition in (("verified", "INTEGER NOT NULL DEFAULT 0"), ("verified_at", "TEXT"), ("updated_at", "TEXT")):
|
||||||
db.execute("INSERT OR IGNORE INTO organizations (id, name) VALUES (?, ?)", (ORGANIZATION_ID, "Demo organization"))
|
if col not in cols: db.execute(f"ALTER TABLE businesses ADD COLUMN {col} {definition}")
|
||||||
|
db.execute("UPDATE businesses SET updated_at=COALESCE(updated_at,created_at) WHERE updated_at IS NULL")
|
||||||
|
db.execute("INSERT OR IGNORE INTO organizations (id,name) VALUES (?,?)", (ORGANIZATION_ID, "Demo organization"))
|
||||||
email, password = os.environ.get("BOOTSTRAP_ADMIN_EMAIL"), os.environ.get("BOOTSTRAP_ADMIN_PASSWORD")
|
email, password = os.environ.get("BOOTSTRAP_ADMIN_EMAIL"), os.environ.get("BOOTSTRAP_ADMIN_PASSWORD")
|
||||||
if email and password:
|
if email and password and not db.execute("SELECT id FROM users WHERE email=?", (email.strip().lower(),)).fetchone():
|
||||||
existing = db.execute("SELECT id FROM users WHERE email = ?", (email.strip().lower(),)).fetchone()
|
ph, salt = hash_password(password); db.execute("INSERT INTO users (organization_id,email,password_hash,password_salt,role) VALUES (?,?,?,?,?)", (ORGANIZATION_ID,email.strip().lower(),ph,salt,"owner"))
|
||||||
if not existing:
|
db.commit(); return db
|
||||||
password_hash, salt = hash_password(password)
|
|
||||||
db.execute("INSERT INTO users (organization_id,email,password_hash,password_salt,role) VALUES (?,?,?,?,?)", (ORGANIZATION_ID, email.strip().lower(), password_hash, salt, "owner"))
|
|
||||||
db.commit()
|
|
||||||
return db
|
|
||||||
|
|
||||||
|
def safe_value(value):
|
||||||
|
if isinstance(value, bytes): return value.decode("utf-8", "replace")
|
||||||
|
return value
|
||||||
|
|
||||||
def row_json(row: sqlite3.Row) -> dict:
|
def row_json(row):
|
||||||
result = dict(row)
|
result = {k: safe_value(v) for k, v in dict(row).items()}
|
||||||
result["score_factors"] = json.loads(result.pop("score_factors", "[]"))
|
if "score_factors" in result:
|
||||||
|
try: result["score_factors"] = json.loads(result["score_factors"] or "[]")
|
||||||
|
except (TypeError, ValueError): result["score_factors"] = []
|
||||||
|
for key in ("verified", "do_not_contact"):
|
||||||
|
if key in result: result[key] = bool(result[key])
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
class ApiHandler(BaseHTTPRequestHandler):
|
class ApiHandler(BaseHTTPRequestHandler):
|
||||||
server_version = "ProspectPlatform/0.1"
|
server_version = "ProspectPlatform/0.1"
|
||||||
|
def send_json(self, status, payload, extra_headers=None):
|
||||||
def send_json(self, status: int, payload: dict | list, extra_headers: dict[str, str] | None = None):
|
body = json.dumps(payload, sort_keys=True, default=str).encode(); self.send_response(status); self.send_header("Content-Type","application/json; charset=utf-8"); self.send_header("Access-Control-Allow-Origin",os.environ.get("CORS_ORIGINS","http://localhost:8080")); self.send_header("Access-Control-Allow-Credentials","true"); self.send_header("Access-Control-Allow-Methods","GET, POST, PATCH, OPTIONS"); self.send_header("Access-Control-Allow-Headers","Content-Type")
|
||||||
body = json.dumps(payload, sort_keys=True).encode("utf-8")
|
for k,v in (extra_headers or {}).items(): self.send_header(k,v)
|
||||||
self.send_response(status)
|
self.send_header("Content-Length",str(len(body))); self.end_headers(); self.wfile.write(body)
|
||||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
def read_json(self):
|
||||||
self.send_header("Access-Control-Allow-Origin", os.environ.get("CORS_ORIGINS", "http://localhost:8080"))
|
|
||||||
self.send_header("Access-Control-Allow-Credentials", "true")
|
|
||||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
||||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
||||||
for key, value in (extra_headers or {}).items(): self.send_header(key, value)
|
|
||||||
self.send_header("Content-Length", str(len(body)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body)
|
|
||||||
|
|
||||||
def read_json(self) -> dict:
|
|
||||||
try:
|
try:
|
||||||
length = int(self.headers.get("Content-Length", "0"))
|
value=json.loads(self.rfile.read(int(self.headers.get("Content-Length","0"))) or b"{}"); return value if isinstance(value,dict) else {}
|
||||||
value = json.loads(self.rfile.read(length) or b"{}")
|
except (ValueError,json.JSONDecodeError): return {}
|
||||||
return value if isinstance(value, dict) else {}
|
def db(self): return connect(getattr(self.server,"db_path"))
|
||||||
except (ValueError, json.JSONDecodeError):
|
def do_OPTIONS(self): self.send_response(204); self.send_header("Access-Control-Allow-Methods","GET, POST, PATCH, OPTIONS"); self.end_headers()
|
||||||
return {}
|
def session_user(self, db):
|
||||||
|
cookie=SimpleCookie(); cookie.load(self.headers.get("Cookie","")); token=cookie.get("session")
|
||||||
def db(self): return connect(getattr(self.server, "db_path"))
|
|
||||||
|
|
||||||
def do_OPTIONS(self):
|
|
||||||
self.send_response(204)
|
|
||||||
self.send_header("Access-Control-Allow-Origin", os.environ.get("CORS_ORIGINS", "http://localhost:8080"))
|
|
||||||
self.send_header("Access-Control-Allow-Credentials", "true")
|
|
||||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
||||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
def session_user(self, db: sqlite3.Connection):
|
|
||||||
cookie = SimpleCookie(); cookie.load(self.headers.get("Cookie", ""))
|
|
||||||
token = cookie.get("session")
|
|
||||||
if not token: return None
|
if not token: return None
|
||||||
token_hash = hashlib.sha256(token.value.encode()).hexdigest()
|
now=datetime.now(timezone.utc).replace(microsecond=0).isoformat(); h=hashlib.sha256(token.value.encode()).hexdigest()
|
||||||
now = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
return db.execute("SELECT u.id,u.email,u.role,u.organization_id FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_at>?",(h,now)).fetchone()
|
||||||
return db.execute("SELECT u.id, u.email, u.role, u.organization_id FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ? AND s.expires_at > ?", (token_hash, now)).fetchone()
|
def require_auth(self,db):
|
||||||
|
user=self.session_user(db)
|
||||||
def require_auth(self, db):
|
if not user: self.send_json(401,{"error":"unauthorized"}); return None
|
||||||
user = self.session_user(db)
|
|
||||||
if not user:
|
|
||||||
self.send_json(401, {"error": "unauthorized"})
|
|
||||||
return None
|
|
||||||
return user
|
return user
|
||||||
|
def auth_cookie(self,token,max_age): return f"session={token}; Max-Age={max_age}; Path=/; HttpOnly; SameSite=Lax"
|
||||||
def auth_cookie(self, token: str, max_age: int) -> str:
|
def audit(self, db, user, action, details=""):
|
||||||
return f"session={token}; Max-Age={max_age}; Path=/; HttpOnly; SameSite=Lax"
|
db.execute("INSERT INTO audit_log (organization_id,user_id,action,details) VALUES (?,?,?,?)",(user["organization_id"],user["id"],action,details))
|
||||||
|
def business(self, db, ident, org): return db.execute("SELECT * FROM businesses WHERE id=? AND organization_id=?",(ident,org)).fetchone()
|
||||||
|
def nested(self, db, bid, org):
|
||||||
|
result={"contacts":[],"domains":[],"websites":[],"evidence":[],"pipeline":[],"notes":[]}
|
||||||
|
tables={"contacts":"contacts","domains":"domains","websites":"websites","evidence":"evidence","pipeline":"pipeline_entries","notes":"notes"}
|
||||||
|
for key, table in tables.items():
|
||||||
|
result[key]=[row_json(r) for r in db.execute(f"SELECT * FROM {table} WHERE business_id=? AND organization_id=? ORDER BY id",(bid,org))]
|
||||||
|
return result
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
parsed = urlparse(self.path); path = parsed.path.rstrip("/")
|
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})
|
if path=="/api/v1/health/live": return self.send_json(200,{"status":"ok","organization_id":ORGANIZATION_ID})
|
||||||
db = self.db()
|
db=self.db()
|
||||||
try:
|
try:
|
||||||
user = self.require_auth(db)
|
user=self.require_auth(db)
|
||||||
if not user: return
|
if not user:return
|
||||||
org = user["organization_id"]
|
org=user["organization_id"]
|
||||||
if path == "/api/v1/auth/me": return self.send_json(200, {"id": user["id"], "email": user["email"], "role": user["role"], "organization_id": org})
|
if path=="/api/v1/auth/me": return self.send_json(200,{"id":user["id"],"email":user["email"],"role":user["role"],"organization_id":org})
|
||||||
if path == "/api/v1/admin/users":
|
if path=="/api/v1/admin/users":
|
||||||
if user["role"] not in {"owner", "admin"}: return self.send_json(403, {"error": "forbidden"})
|
if user["role"] not in {"owner","admin"}: return self.send_json(403,{"error":"forbidden"})
|
||||||
rows = db.execute("SELECT id,email,role,organization_id,created_at FROM users WHERE organization_id = ? ORDER BY id", (org,)).fetchall()
|
return self.send_json(200,{"items":[dict(r) for r in db.execute("SELECT id,email,role,organization_id,created_at FROM users WHERE organization_id=? ORDER BY id",(org,))]})
|
||||||
return self.send_json(200, {"items": [dict(r) for r in rows]})
|
if path=="/api/v1/dashboard/summary":
|
||||||
if path == "/api/v1/dashboard/summary":
|
row=db.execute("SELECT COUNT(*) businesses,COALESCE(AVG(score),0) average_score FROM businesses WHERE organization_id=?",(org,)).fetchone(); return self.send_json(200,{"organization_id":org,"businesses":row["businesses"],"average_score":round(row["average_score"],2),"suppressed":db.execute("SELECT COUNT(*) FROM suppressions WHERE organization_id=?",(org,)).fetchone()[0]})
|
||||||
row = db.execute("SELECT COUNT(*) AS businesses, COALESCE(AVG(score), 0) AS average_score FROM businesses WHERE organization_id = ?", (org,)).fetchone()
|
if path=="/api/v1/businesses": return self.list_businesses(db,org,parse_qs(parsed.query))
|
||||||
return self.send_json(200, {"organization_id": org, "businesses": row["businesses"], "average_score": round(row["average_score"], 2), "suppressed": db.execute("SELECT COUNT(*) FROM suppressions WHERE organization_id = ?", (org,)).fetchone()[0]})
|
|
||||||
if path == "/api/v1/businesses":
|
|
||||||
query = parse_qs(parsed.query).get("q", [""])[0].strip(); params = [org]
|
|
||||||
sql = "SELECT * FROM businesses WHERE organization_id = ?"
|
|
||||||
if query:
|
|
||||||
like = f"%{query}%"; sql += " AND (name LIKE ? OR website_domain LIKE ? OR email LIKE ?)"; params += [like] * 3
|
|
||||||
rows = db.execute(sql + " ORDER BY score DESC, id", params).fetchall()
|
|
||||||
return self.send_json(200, {"organization_id": org, "items": [row_json(r) for r in rows]})
|
|
||||||
if path.startswith("/api/v1/businesses/"):
|
if path.startswith("/api/v1/businesses/"):
|
||||||
ident = path.rsplit("/", 1)[1]
|
bits=path.split("/"); ident=bits[4] if len(bits)>4 else ""
|
||||||
if not ident.isdigit(): return self.send_json(404, {"error": "not_found"})
|
if not ident.isdigit(): return self.send_json(404,{"error":"not_found"})
|
||||||
row = db.execute("SELECT * FROM businesses WHERE id = ? AND organization_id = ?", (int(ident), org)).fetchone()
|
row=self.business(db,int(ident),org)
|
||||||
return self.send_json(200, row_json(row)) if row else self.send_json(404, {"error": "not_found"})
|
if not row:return self.send_json(404,{"error":"not_found"})
|
||||||
return self.send_json(404, {"error": "not_found"})
|
payload=row_json(row); payload.update(self.nested(db,int(ident),org)); return self.send_json(200,payload)
|
||||||
|
return self.send_json(404,{"error":"not_found"})
|
||||||
finally: db.close()
|
finally: db.close()
|
||||||
|
def list_businesses(self,db,org,query):
|
||||||
|
def number(name, default=None):
|
||||||
|
raw=query.get(name,[None])[0]
|
||||||
|
if raw is None:return default
|
||||||
|
try:return int(raw)
|
||||||
|
except ValueError: raise ValueError
|
||||||
|
try:
|
||||||
|
page=number("page",1); size=number("page_size",50); score=number("score_min",None); cursor=number("cursor",0)
|
||||||
|
except ValueError:return self.send_json(400,{"error":"invalid_pagination"})
|
||||||
|
if page<1 or size<1 or size>100 or cursor<0:return self.send_json(400,{"error":"invalid_pagination"})
|
||||||
|
params=[org]; where=["b.organization_id=?"]; q=query.get("q",[""])[0].strip(); website_class=query.get("website_class",[""])[0].strip(); stage=query.get("pipeline_stage",[""])[0].strip()
|
||||||
|
if score is not None: where.append("b.score>=?"); params.append(score)
|
||||||
|
if website_class: where.append("b.website_class=?"); params.append(website_class)
|
||||||
|
if q: where.append("(b.name LIKE ? OR b.website_domain LIKE ? OR b.email LIKE ?)"); params += [f"%{q}%"]*3
|
||||||
|
if stage: where.append("EXISTS (SELECT 1 FROM pipeline_entries p WHERE p.business_id=b.id AND p.organization_id=b.organization_id AND p.stage=?)"); params.append(stage)
|
||||||
|
offset=(number("cursor",0) or 0)+(page-1)*size
|
||||||
|
rows=db.execute("SELECT b.* FROM businesses b WHERE "+" AND ".join(where)+" ORDER BY b.score DESC,b.id LIMIT ? OFFSET ?",params+[size+1,offset]).fetchall(); more=len(rows)>size; rows=rows[:size]
|
||||||
|
return self.send_json(200,{"organization_id":org,"items":[row_json(r) for r in rows],"page":page,"page_size":size,"next_cursor":str(offset+size) if more else None})
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
path = urlparse(self.path).path.rstrip("/")
|
path=urlparse(self.path).path.rstrip("/")
|
||||||
if path == "/api/v1/auth/login": return self.login(self.read_json())
|
if path=="/api/v1/auth/login":return self.login(self.read_json())
|
||||||
db = self.db()
|
db=self.db()
|
||||||
try:
|
try:
|
||||||
user = self.require_auth(db)
|
user=self.require_auth(db)
|
||||||
if not user: return
|
if not user:return
|
||||||
if path == "/api/v1/auth/logout":
|
if path=="/api/v1/auth/logout":
|
||||||
cookie = SimpleCookie(); cookie.load(self.headers.get("Cookie", "")); token = cookie.get("session")
|
c=SimpleCookie();c.load(self.headers.get("Cookie",""));t=c.get("session");
|
||||||
if token: db.execute("DELETE FROM sessions WHERE token_hash = ?", (hashlib.sha256(token.value.encode()).hexdigest(),))
|
if t:db.execute("DELETE FROM sessions WHERE token_hash=?",(hashlib.sha256(t.value.encode()).hexdigest(),))
|
||||||
db.execute("INSERT INTO audit_log (organization_id,user_id,action) VALUES (?,?,?)", (user["organization_id"], user["id"], "logout")); db.commit()
|
self.audit(db,user,"logout");db.commit();return self.send_json(200,{"ok":True},{"Set-Cookie":self.auth_cookie("",0)})
|
||||||
return self.send_json(200, {"ok": True}, {"Set-Cookie": self.auth_cookie("", 0)})
|
if user["role"] not in MUTATING_ROLES:return self.send_json(403,{"error":"forbidden"})
|
||||||
if user["role"] not in MUTATING_ROLES: return self.send_json(403, {"error": "forbidden"})
|
payload=self.read_json(); org=user["organization_id"]
|
||||||
payload = self.read_json()
|
if path=="/api/v1/businesses":return self.create_business(payload,db,user)
|
||||||
if path == "/api/v1/businesses": return self.create_business(payload, db, user["organization_id"])
|
if path=="/api/v1/suppressions":return self.create_suppression(payload,db,user)
|
||||||
if path == "/api/v1/suppressions": return self.create_suppression(payload, db, user["organization_id"])
|
if path=="/api/v1/imports/preview":return self.preview_import(payload,db,org)
|
||||||
if path == "/api/v1/imports/preview": return self.preview_import(payload, db, user["organization_id"])
|
bits=path.split("/")
|
||||||
return self.send_json(404, {"error": "not_found"})
|
if len(bits)==7 and bits[:4]==["","api","v1","businesses"] and bits[5] in CHILD_TABLES and bits[6]=="": pass
|
||||||
finally: db.close()
|
if len(bits)==6 and bits[:4]==["","api","v1","businesses"] and bits[5] in CHILD_TABLES:return self.create_child(int(bits[4]) if bits[4].isdigit() else -1,bits[5],payload,db,user)
|
||||||
|
if len(bits)==6 and bits[:4]==["","api","v1","businesses"] and bits[5]=="verify":return self.verify_business(int(bits[4]) if bits[4].isdigit() else -1,payload,db,user)
|
||||||
def login(self, payload):
|
return self.send_json(404,{"error":"not_found"})
|
||||||
email = str(payload.get("email", "")).strip().lower(); password = str(payload.get("password", "")); db = self.db()
|
finally:db.close()
|
||||||
|
def do_PATCH(self):
|
||||||
|
path=urlparse(self.path).path.rstrip("/"); db=self.db()
|
||||||
try:
|
try:
|
||||||
user = db.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone()
|
user=self.require_auth(db)
|
||||||
if not user or not verify_password(password, user["password_hash"], user["password_salt"]): return self.send_json(401, {"error": "invalid_credentials"})
|
if not user:return
|
||||||
token = secrets.token_urlsafe(32); expires = datetime.now(timezone.utc) + timedelta(days=SESSION_DAYS)
|
if user["role"] not in MUTATING_ROLES:return self.send_json(403,{"error":"forbidden"})
|
||||||
db.execute("INSERT INTO sessions (user_id,token_hash,expires_at) VALUES (?,?,?)", (user["id"], hashlib.sha256(token.encode()).hexdigest(), expires.replace(microsecond=0).isoformat()))
|
bits=path.split("/")
|
||||||
db.execute("INSERT INTO audit_log (organization_id,user_id,action) VALUES (?,?,?)", (user["organization_id"], user["id"], "login")); db.commit()
|
if len(bits)==6 and bits[:4]==["","api","v1","businesses"] and bits[5]=="pipeline":return self.update_pipeline(int(bits[4]) if bits[4].isdigit() else -1,self.read_json(),db,user)
|
||||||
return self.send_json(200, {"id": user["id"], "email": user["email"], "role": user["role"], "organization_id": user["organization_id"]}, {"Set-Cookie": self.auth_cookie(token, int(timedelta(days=SESSION_DAYS).total_seconds()))})
|
return self.send_json(404,{"error":"not_found"})
|
||||||
finally: db.close()
|
finally:db.close()
|
||||||
|
def login(self,payload):
|
||||||
def create_business(self, payload, db, org):
|
db=self.db(); email=str(payload.get("email"," ")).strip().lower(); password=str(payload.get("password","")); user=db.execute("SELECT * FROM users WHERE email=?",(email,)).fetchone()
|
||||||
if not str(payload.get("name", "")).strip(): return self.send_json(400, {"error": "name_required"})
|
try:
|
||||||
business = normalize_business(payload); suppressions = [dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id = ?", (org,))]
|
if not user or not verify_password(password,user["password_hash"],user["password_salt"]):return self.send_json(401,{"error":"invalid_credentials"})
|
||||||
if is_suppressed(business, suppressions): return self.send_json(409, {"error": "suppressed"})
|
token=secrets.token_urlsafe(32); expires=datetime.now(timezone.utc)+timedelta(days=SESSION_DAYS);db.execute("INSERT INTO sessions(user_id,token_hash,expires_at) VALUES(?,?,?)",(user["id"],hashlib.sha256(token.encode()).hexdigest(),expires.replace(microsecond=0).isoformat()));self.audit(db,user,"login");db.commit();return self.send_json(200,{"id":user["id"],"email":user["email"],"role":user["role"],"organization_id":user["organization_id"]},{"Set-Cookie":self.auth_cookie(token,int(timedelta(days=SESSION_DAYS).total_seconds()))})
|
||||||
fields = [(c, business[c]) for c in ("website_domain", "email", "phone") if business[c]]
|
finally:db.close()
|
||||||
if fields and db.execute("SELECT id FROM businesses WHERE organization_id = ? AND (" + " OR ".join(f"{c} = ?" for c, _ in fields) + ")", [org] + [v for _, v in fields]).fetchone(): return self.send_json(409, {"error": "duplicate"})
|
def create_business(self,payload,db,user):
|
||||||
scored = score_business(business); cur = db.execute("INSERT INTO businesses (organization_id,name,website,website_domain,email,phone,description,score,score_version,score_factors,website_class) VALUES (?,?,?,?,?,?,?,?,?,?,?)", (org,business["name"],business["website"],business["website_domain"],business["email"],business["phone"],str(business.get("description", "")),scored["score"],scored["score_version"],json.dumps(scored["factors"]),scored["website_class"]))
|
org=user["organization_id"]
|
||||||
db.commit(); return self.send_json(201, row_json(db.execute("SELECT * FROM businesses WHERE id = ? AND organization_id = ?", (cur.lastrowid, org)).fetchone()))
|
if not str(payload.get("name","")).strip():return self.send_json(400,{"error":"name_required"})
|
||||||
|
b=normalize_business(payload); suppressions=[dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id=?",(org,))]
|
||||||
def create_suppression(self, payload, db, org):
|
if is_suppressed(b,suppressions):return self.send_json(409,{"error":"suppressed"})
|
||||||
kind, value = payload.get("kind"), str(payload.get("value", "")).strip().lower()
|
fields=[(c,b[c]) for c in ("website_domain","email","phone") if b[c]]
|
||||||
if kind not in {"email", "domain", "phone"} or not value: return self.send_json(400, {"error": "invalid_suppression"})
|
if fields and db.execute("SELECT id FROM businesses WHERE organization_id=? AND ("+" OR ".join(f"{c}=?" for c,_ in fields)+")",[org]+[v for _,v in fields]).fetchone():return self.send_json(409,{"error":"duplicate"})
|
||||||
try: db.execute("INSERT INTO suppressions (organization_id,kind,value) VALUES (?,?,?)", (org, kind, value)); db.commit()
|
scored=score_business(b);cur=db.execute("INSERT INTO businesses(organization_id,name,website,website_domain,email,phone,description,score,score_version,score_factors,website_class) VALUES(?,?,?,?,?,?,?,?,?,?,?)",(org,b["name"],b["website"],b["website_domain"],b["email"],b["phone"],str(b.get("description","")),scored["score"],scored["score_version"],json.dumps(scored["factors"]),scored["website_class"])); self.audit(db,user,"business.created",str(cur.lastrowid));db.commit();return self.send_json(201,row_json(db.execute("SELECT * FROM businesses WHERE id=?",(cur.lastrowid,)).fetchone()))
|
||||||
except sqlite3.IntegrityError: pass
|
def create_suppression(self,payload,db,user):
|
||||||
return self.send_json(201, dict(db.execute("SELECT * FROM suppressions WHERE organization_id = ? AND kind = ? AND value = ?", (org, kind, value)).fetchone()))
|
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"})
|
||||||
def preview_import(self, payload, db, org):
|
try:db.execute("INSERT INTO suppressions(organization_id,kind,value) VALUES(?,?,?)",(user["organization_id"],kind,value))
|
||||||
rows = payload.get("rows", [])
|
except sqlite3.IntegrityError:pass
|
||||||
if not isinstance(rows, list): return self.send_json(400, {"error": "rows_required"})
|
self.audit(db,user,"suppression.created",kind);db.commit();return self.send_json(201,dict(db.execute("SELECT * FROM suppressions WHERE organization_id=? AND kind=? AND value=?",(user["organization_id"],kind,value)).fetchone()))
|
||||||
normalized = deduplicate_businesses([r for r in rows if isinstance(r, dict) and str(r.get("name", "")).strip()])
|
def child_business(self,db,bid,user):return self.business(db,bid,user["organization_id"])
|
||||||
suppressions = [dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id = ?", (org,))]; existing = [row_json(r) for r in db.execute("SELECT * FROM businesses WHERE organization_id = ?", (org,))]; seen = set(); accepted, duplicate, suppressed = [], 0, 0
|
def create_child(self,bid,table,payload,db,user):
|
||||||
existing_keys = {deduplication_key(x) for x in existing}
|
if not self.child_business(db,bid,user):return self.send_json(404,{"error":"not_found"})
|
||||||
|
if table=="contacts":
|
||||||
|
email=str(payload.get("email","")).strip().lower(); phone=normalize_phone(payload.get("phone"));
|
||||||
|
if email and not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$",email):return self.send_json(400,{"error":"invalid_contact"})
|
||||||
|
suppressed=is_suppressed({"email":email,"phone":phone},[dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id=?",(user["organization_id"],))]); values=(str(payload.get("name","")).strip(),email,phone,str(payload.get("title","")).strip(),int(bool(payload.get("do_not_contact"))) or int(suppressed))
|
||||||
|
elif table=="domains":
|
||||||
|
value=normalize_domain(payload.get("domain"));
|
||||||
|
if not value:return self.send_json(400,{"error":"invalid_domain"})
|
||||||
|
values=(value,str(payload.get("kind","other")).strip() or "other")
|
||||||
|
elif table=="websites":
|
||||||
|
value=str(payload.get("url","")).strip();
|
||||||
|
if not urlparse(value).scheme or not urlparse(value).netloc:return self.send_json(400,{"error":"invalid_website"})
|
||||||
|
values=(value,str(payload.get("website_class","business_site")).strip() or "business_site")
|
||||||
|
elif table=="evidence":
|
||||||
|
if not str(payload.get("kind","")).strip():return self.send_json(400,{"error":"invalid_evidence"})
|
||||||
|
values=(str(payload["kind"]).strip(),str(payload.get("url","")).strip(),str(payload.get("claim","")).strip())
|
||||||
|
else:
|
||||||
|
if not str(payload.get("body","")).strip():return self.send_json(400,{"error":"body_required"})
|
||||||
|
values=(str(payload["body"]).strip(),)
|
||||||
|
columns=CHILD_TABLES[table]; db.execute(f"INSERT INTO {table}(business_id,organization_id,{','.join(columns)}) VALUES(?, ?, {','.join('?' for _ in columns)})",(bid,user["organization_id"])+values); rid=db.execute("SELECT last_insert_rowid()").fetchone()[0];self.audit(db,user,f"{table}.created",str(rid));db.commit();return self.send_json(201,row_json(db.execute(f"SELECT * FROM {table} WHERE id=?",(rid,)).fetchone()))
|
||||||
|
def update_pipeline(self,bid,payload,db,user):
|
||||||
|
if not self.child_business(db,bid,user) or not str(payload.get("stage","")).strip():return self.send_json(404 if not self.child_business(db,bid,user) else 400,{"error":"not_found" if not self.child_business(db,bid,user) else "stage_required"})
|
||||||
|
stage=str(payload["stage"]).strip();status=str(payload.get("status","active")).strip() or "active";db.execute("INSERT INTO pipeline_entries(business_id,organization_id,stage,status) VALUES(?,?,?,?)",(bid,user["organization_id"],stage,status));rid=db.execute("SELECT last_insert_rowid()").fetchone()[0];self.audit(db,user,"pipeline.updated",stage);db.commit();return self.send_json(200,row_json(db.execute("SELECT * FROM pipeline_entries WHERE id=?",(rid,)).fetchone()))
|
||||||
|
def verify_business(self,bid,payload,db,user):
|
||||||
|
if not self.child_business(db,bid,user):return self.send_json(404,{"error":"not_found"})
|
||||||
|
verified=bool(payload.get("verified",True)); now=datetime.now(timezone.utc).replace(microsecond=0).isoformat();db.execute("UPDATE businesses SET verified=?,verified_at=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND organization_id=?",(int(verified),now if verified else None,bid,user["organization_id"]));self.audit(db,user,"business.verified",str(verified));db.commit();row=self.business(db,bid,user["organization_id"]);return self.send_json(200,row_json(row))
|
||||||
|
def preview_import(self,payload,db,org):
|
||||||
|
rows=payload.get("rows",[])
|
||||||
|
if not isinstance(rows,list):return self.send_json(400,{"error":"rows_required"})
|
||||||
|
normalized=deduplicate_businesses([r for r in rows if isinstance(r,dict) and str(r.get("name","")).strip()]); suppressions=[dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id=?",(org,))];existing=[row_json(r) for r in db.execute("SELECT * FROM businesses WHERE organization_id=?",(org,))];seen=set();accepted=[];suppressed=0;existing_keys={deduplication_key(x) for x in existing}
|
||||||
for b in normalized:
|
for b in normalized:
|
||||||
key = deduplication_key(b)
|
key=deduplication_key(b)
|
||||||
if is_suppressed(b, suppressions): suppressed += 1
|
if is_suppressed(b,suppressions):suppressed+=1
|
||||||
elif key in existing_keys or key in seen: duplicate += 1
|
elif key in existing_keys or key in seen:continue
|
||||||
else: seen.add(key); accepted.append(b)
|
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})
|
return self.send_json(200,{"accepted":len(accepted),"duplicates":len(rows)-len(normalized)+len(normalized)-len(accepted)-suppressed,"suppressed":suppressed,"rows":accepted})
|
||||||
|
def log_message(self,*_):pass
|
||||||
|
|
||||||
def log_message(self, *_): pass
|
def create_server(host="127.0.0.1",port=8000,db_path="prospects.db"):
|
||||||
|
server=ThreadingHTTPServer((host,port),ApiHandler);server.db_path=db_path;connect(db_path).close();return server
|
||||||
|
if __name__=="__main__":
|
||||||
def create_server(host="127.0.0.1", port=8000, db_path="prospects.db"):
|
parser=argparse.ArgumentParser();parser.add_argument("--host",default="127.0.0.1");parser.add_argument("--port",type=int,default=int(os.environ.get("PROSPECT_API_PORT","8000")));parser.add_argument("--db",default=os.environ.get("PROSPECT_API_DB","prospects.db"));args=parser.parse_args();server=create_server(args.host,args.port,args.db);print(f"Prospect API listening on http://{args.host}:{args.port}",flush=True)
|
||||||
server = ThreadingHTTPServer((host, port), ApiHandler); setattr(server, "db_path", db_path); connect(db_path).close(); return server
|
try:server.serve_forever()
|
||||||
|
except KeyboardInterrupt:pass
|
||||||
|
finally:server.server_close()
|
||||||
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()
|
|
||||||
|
|||||||
+61
-45
@@ -1,66 +1,82 @@
|
|||||||
PRAGMA foreign_keys = ON;
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS organizations (
|
CREATE TABLE IF NOT EXISTS organizations (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY, name TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
name TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT, organization_id TEXT NOT NULL REFERENCES organizations(id), email TEXT NOT NULL UNIQUE,
|
||||||
organization_id TEXT NOT NULL REFERENCES organizations(id),
|
password_hash TEXT NOT NULL, password_salt TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('viewer','owner','admin','researcher')),
|
||||||
email TEXT NOT NULL UNIQUE,
|
|
||||||
password_hash TEXT NOT NULL,
|
|
||||||
password_salt TEXT NOT NULL,
|
|
||||||
role TEXT NOT NULL CHECK(role IN ('viewer','owner','admin','researcher')),
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_org ON users(organization_id);
|
CREATE INDEX IF NOT EXISTS idx_users_org ON users(organization_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
token_hash TEXT NOT NULL UNIQUE,
|
|
||||||
expires_at TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token_hash);
|
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token_hash);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS audit_log (
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT, organization_id TEXT REFERENCES organizations(id), user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
organization_id TEXT REFERENCES organizations(id),
|
action TEXT NOT NULL, details TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
action TEXT NOT NULL,
|
|
||||||
details TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_org ON audit_log(organization_id);
|
CREATE INDEX IF NOT EXISTS idx_audit_org ON audit_log(organization_id,created_at);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS businesses (
|
CREATE TABLE IF NOT EXISTS businesses (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
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 '',
|
||||||
organization_id TEXT NOT NULL,
|
email TEXT NOT NULL DEFAULT '', phone TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '', score INTEGER NOT NULL DEFAULT 0,
|
||||||
name TEXT NOT NULL,
|
score_version TEXT NOT NULL DEFAULT 'mvp-1', score_factors TEXT NOT NULL DEFAULT '[]', website_class TEXT NOT NULL DEFAULT 'missing',
|
||||||
website TEXT NOT NULL DEFAULT '',
|
verified INTEGER NOT NULL DEFAULT 0, verified_at TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
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 INDEX IF NOT EXISTS idx_businesses_org ON businesses(organization_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_businesses_score ON businesses(organization_id,score DESC,id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_businesses_class ON businesses(organization_id,website_class);
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_business_domain ON businesses(organization_id, website_domain) WHERE website_domain <> '';
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_business_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_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 UNIQUE INDEX IF NOT EXISTS uq_business_phone ON businesses(organization_id, phone) WHERE phone <> '';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS suppressions (
|
CREATE TABLE IF NOT EXISTS suppressions (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT, organization_id TEXT NOT NULL, kind TEXT NOT NULL CHECK(kind IN ('email','domain','phone')),
|
||||||
organization_id TEXT NOT NULL,
|
value TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(organization_id, kind, value)
|
||||||
kind TEXT NOT NULL CHECK(kind IN ('email','domain','phone')),
|
|
||||||
value TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(organization_id, kind, value)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS business_identifiers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
organization_id TEXT NOT NULL REFERENCES organizations(id), kind TEXT NOT NULL, value TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
organization_id TEXT NOT NULL REFERENCES organizations(id), name TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '', phone TEXT NOT NULL DEFAULT '',
|
||||||
|
title TEXT NOT NULL DEFAULT '', do_not_contact INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS domains (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
organization_id TEXT NOT NULL REFERENCES organizations(id), domain TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'other', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS websites (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
organization_id TEXT NOT NULL REFERENCES organizations(id), url TEXT NOT NULL, website_class TEXT NOT NULL DEFAULT 'business_site', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS evidence (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
organization_id TEXT NOT NULL REFERENCES organizations(id), kind TEXT NOT NULL, url TEXT NOT NULL DEFAULT '', claim TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS pipeline_entries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
organization_id TEXT NOT NULL REFERENCES organizations(id), stage TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS interactions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
organization_id TEXT NOT NULL REFERENCES organizations(id), kind TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS notes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
organization_id TEXT NOT NULL REFERENCES organizations(id), body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contacts_business ON contacts(business_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contacts_org_business ON contacts(organization_id,business_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_identifiers_business ON business_identifiers(business_id,kind);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_domains_business ON domains(business_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_websites_business ON websites(business_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_evidence_business ON evidence(business_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pipeline_business ON pipeline_entries(business_id,created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pipeline_stage ON pipeline_entries(organization_id,stage);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notes_business ON notes(business_id,created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_interactions_business ON interactions(business_id,created_at);
|
||||||
|
|||||||
@@ -101,6 +101,58 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
self.assertEqual(status, 409)
|
self.assertEqual(status, 409)
|
||||||
self.assertEqual(response["error"], "suppressed")
|
self.assertEqual(response["error"], "suppressed")
|
||||||
|
|
||||||
|
def test_business_detail_contains_nested_phase_three_resources_and_mutations_audit(self):
|
||||||
|
status, business = self.request("POST", "/api/v1/businesses", {"name": "Nested Co", "website": "https://nested.test"})
|
||||||
|
self.assertEqual(status, 201)
|
||||||
|
bid = business["id"]
|
||||||
|
for path, payload in [
|
||||||
|
("contacts", {"name": "Jane", "email": "jane@nested.test"}),
|
||||||
|
("domains", {"domain": "nested.test", "kind": "primary"}),
|
||||||
|
("websites", {"url": "https://nested.test", "website_class": "business_site"}),
|
||||||
|
("evidence", {"kind": "source", "url": "https://source.test", "claim": "Founded 2020"}),
|
||||||
|
("notes", {"body": "Call next week"}),
|
||||||
|
]:
|
||||||
|
self.assertEqual(self.request("POST", f"/api/v1/businesses/{bid}/{path}", payload)[0], 201)
|
||||||
|
self.assertEqual(self.request("PATCH", f"/api/v1/businesses/{bid}/pipeline", {"stage": "qualified"})[0], 200)
|
||||||
|
self.assertEqual(self.request("POST", f"/api/v1/businesses/{bid}/verify", {"verified": True})[0], 200)
|
||||||
|
status, detail = self.request("GET", f"/api/v1/businesses/{bid}")
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
for key in ("contacts", "domains", "websites", "evidence", "pipeline", "notes"):
|
||||||
|
self.assertEqual(len(detail[key]), 1, key)
|
||||||
|
self.assertTrue(detail["verified"])
|
||||||
|
db = sqlite3.connect(self.db_path)
|
||||||
|
self.assertGreaterEqual(db.execute("SELECT COUNT(*) FROM audit_log WHERE organization_id='demo-tenant'").fetchone()[0], 8)
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def test_suppressed_contact_is_do_not_contact(self):
|
||||||
|
self.assertEqual(self.request("POST", "/api/v1/suppressions", {"kind": "email", "value": "blocked@co.test"})[0], 201)
|
||||||
|
_, business = self.request("POST", "/api/v1/businesses", {"name": "Contact Co"})
|
||||||
|
status, contact = self.request("POST", f"/api/v1/businesses/{business['id']}/contacts", {"email": "blocked@co.test"})
|
||||||
|
self.assertEqual(status, 201)
|
||||||
|
self.assertTrue(contact["do_not_contact"])
|
||||||
|
|
||||||
|
def test_business_list_pagination_and_filters(self):
|
||||||
|
for name, website in [("Alpha", "https://alpha.test"), ("Beta", "https://beta.test"), ("Gamma", "https://gamma.test")]:
|
||||||
|
self.assertEqual(self.request("POST", "/api/v1/businesses", {"name": name, "website": website})[0], 201)
|
||||||
|
status, page = self.request("GET", "/api/v1/businesses?page=1&page_size=2&score_min=20&q=Alpha")
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual([x["name"] for x in page["items"]], ["Alpha"])
|
||||||
|
status, invalid = self.request("GET", "/api/v1/businesses?page_size=0")
|
||||||
|
self.assertEqual(status, 400)
|
||||||
|
self.assertEqual(invalid["error"], "invalid_pagination")
|
||||||
|
|
||||||
|
def test_child_resources_are_tenant_scoped_and_validated(self):
|
||||||
|
_, business = self.request("POST", "/api/v1/businesses", {"name": "Private Co"})
|
||||||
|
self.assertEqual(self.request("POST", f"/api/v1/businesses/{business['id']}/contacts", {"email": "bad"})[0], 400)
|
||||||
|
password_hash, salt = hash_password("other-password")
|
||||||
|
db = sqlite3.connect(self.db_path)
|
||||||
|
db.execute("INSERT INTO organizations (id,name) VALUES (?,?)", ("other-tenant", "Other"))
|
||||||
|
db.execute("INSERT INTO users (organization_id,email,password_hash,password_salt,role) VALUES (?,?,?,?,?)", ("other-tenant", "other@example.test", password_hash, salt, "owner"))
|
||||||
|
db.commit(); db.close()
|
||||||
|
self.cookie = None
|
||||||
|
self.assertEqual(self.request("POST", "/api/v1/auth/login", {"email": "other@example.test", "password": "other-password"})[0], 200)
|
||||||
|
self.assertEqual(self.request("POST", f"/api/v1/businesses/{business['id']}/notes", {"body": "leak"})[0], 404)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+23
-12
@@ -1,6 +1,6 @@
|
|||||||
# ProspectOS web MVP
|
# ProspectOS web — Phase 3
|
||||||
|
|
||||||
Self-contained static frontend for the Prospect Platform API. There is no bundler or runtime dependency: serve this directory with any static HTTP server.
|
Self-contained static frontend for the Prospect Platform API. There is no bundler or runtime dependency: serve this directory with any static HTTP server. The UI supports a manual, tenant-scoped review workflow; it does not discover prospects, scan DNS/websites, or send outreach.
|
||||||
|
|
||||||
## Configure and run
|
## Configure and run
|
||||||
|
|
||||||
@@ -11,22 +11,33 @@ The API base is configurable before `app.js` runs:
|
|||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
```
|
```
|
||||||
|
|
||||||
If not set, the UI uses `localStorage.prospect_api_base` when present; otherwise it targets the same origin. The dashboard requires an authenticated API session and shows the login screen until `/api/v1/auth/me` succeeds. The API contract used by this page is the current MVP contract:
|
If not set, the UI uses `localStorage.prospect_api_base` when present; otherwise it targets the same origin. The dashboard requires an authenticated API session and shows the login screen until `/api/v1/auth/me` succeeds.
|
||||||
|
|
||||||
- `GET /api/v1/businesses` (optional filtering is performed client-side)
|
## Phase 3 UI contract
|
||||||
- `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.
|
- The explorer requests tenant-scoped business pages from `GET /api/v1/businesses` and sends bounded pagination plus supported search/score/status filters to the API. Filtering is not a substitute for server-side authorization.
|
||||||
|
- Selecting a row loads the tenant-scoped detail view, including child intelligence/evidence records, provenance/source labels, confidence/freshness, current pipeline state, notes, and relevant audit/activity context when available.
|
||||||
|
- Add prospect, add intelligence, change pipeline state, and add note are explicit manual actions. The API records the acting user and applies permission, tenant, validation, deduplication, and suppression rules server-side.
|
||||||
|
- Evidence labels describe stored observations and their provenance. The UI must not present them as the result of automated discovery, DNS lookup, website crawling, or verification unless a future approved integration explicitly supplies that evidence.
|
||||||
|
- Review and suppressed states remain safety states. The UI shows outreach as unavailable; there is no send button, message composer, sender, or outreach endpoint.
|
||||||
|
- The CSV control is preview-only and local to the browser. Selecting a file does not persist rows or send them to the API.
|
||||||
|
|
||||||
|
The API remains the source of truth for tenant isolation, pagination bounds, filters, pipeline transitions, notes, audit records, and suppression. See `apps/api/README.md` for the route contract.
|
||||||
|
|
||||||
## Browser verification
|
## Browser verification
|
||||||
|
|
||||||
1. Start the API from `apps/api` with `python3 app/main.py`.
|
1. Start the API from `apps/api` with `python3 app/main.py`.
|
||||||
2. Serve this directory: `python3 -m http.server 8080 --directory apps/web`.
|
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.
|
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`.
|
4. Sign in and confirm the header changes to **API connected**, tenant metrics populate, and the explorer renders a bounded page with search, score, status, and pagination controls.
|
||||||
5. 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.
|
5. Select a row and confirm the detail view keeps the business, child intelligence, evidence provenance, confidence/freshness, pipeline, notes, and audit context associated with that tenant.
|
||||||
6. Select a CSV and confirm a local, preview-only table appears without a network request.
|
6. Add or update only through the explicit manual controls. Confirm the refreshed detail/list state reflects the API response and that a viewer cannot mutate records.
|
||||||
7. Resize below 700px to verify the collapsible nav, stacked panels, and horizontally scrollable explorer table.
|
7. Confirm review and suppressed rows show **Outreach unavailable** with the appropriate reason. Confirm there is no outreach/send endpoint or button.
|
||||||
|
8. Select a CSV and confirm a local, preview-only table appears without a network request or persistence.
|
||||||
|
9. Resize below 700px to verify the collapsible nav, stacked panels, and horizontally scrollable explorer/detail content.
|
||||||
|
|
||||||
A zero-dependency static smoke page (`smoke-test.html`) checks the key DOM contract in an iframe and reports pass/fail.
|
A zero-dependency static smoke page (`smoke-test.html`) checks the key DOM contract in an iframe and reports pass/fail. Browser smoke coverage should treat pagination, detail child records, provenance rendering, pipeline/notes actions, and the disabled outreach state as separate checks.
|
||||||
|
|
||||||
|
## Remaining limitations
|
||||||
|
|
||||||
|
The static client has no background discovery, DNS/website scanner, enrichment scheduler, or outreach integration. It cannot make missing provenance authoritative and should display API-provided limitations rather than infer them. CSV preview is capped for display and is not an import workflow. Production deployment still requires the security and operations gates in `docs/SECURITY.md` and `docs/OPERATIONS.md`.
|
||||||
|
|||||||
+33
-63
@@ -3,72 +3,42 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
const API_BASE = (window.API_BASE || localStorage.getItem('prospect_api_base') || '').replace(/\/$/, '');
|
const API_BASE = (window.API_BASE || localStorage.getItem('prospect_api_base') || '').replace(/\/$/, '');
|
||||||
const endpoint = (path) => `${API_BASE}${path}`;
|
const endpoint = (path) => `${API_BASE}${path}`;
|
||||||
let prospects = [];
|
let prospects = [], selectedId = null, selectedDetail = null, currentUser = null;
|
||||||
let selectedId = null;
|
let page = 1, pageSize = 10, hasNextPage = false;
|
||||||
let currentUser = null;
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const esc = (value) => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
const esc = (value) => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
const scoreClass = (score) => score >= 80 ? 'high' : score >= 60 ? 'medium' : 'low';
|
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 statusOf = (p) => p.suppressed || p.status === 'suppressed' ? 'suppressed' : (p.verified || p.reviewed || p.status === 'reviewed' || p.reviewed_at ? 'reviewed' : 'review');
|
||||||
const freshness = (p) => {
|
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 raw = p.updated_at || p.last_checked_at || p.created_at;
|
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);
|
||||||
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('_',' '));
|
const labelFactor = (f) => ({named_business:'Named business',business_site:'Business site',email:'Email found',phone:'Phone found',description:'Description'}[f] || String(f).replaceAll('_',' '));
|
||||||
async function request(path, options = {}) {
|
async function request(path, options = {}) { const response=await fetch(endpoint(path),{...options,credentials:'include'}); if(response.status===401){showLogin('Your session has expired. Please sign in again.');throw new Error('unauthorized');} return response; }
|
||||||
const response = await fetch(endpoint(path), {...options, credentials:'include'});
|
async function jsonRequest(path, options = {}) { const res=await request(path,options); const body=await res.json().catch(()=>({})); if(!res.ok)throw new Error(body.error||body.message||'Request failed'); return body; }
|
||||||
if (response.status === 401) { showLogin('Your session has expired. Please sign in again.'); throw new Error('unauthorized'); }
|
function showLogin(message=''){currentUser=null;$('dashboardShell').hidden=true;$('loginScreen').hidden=false;$('loginMessage').textContent=message;$('loginMessage').className=`form-message${message?' error':''}`;}
|
||||||
return response;
|
function showDashboard(user){currentUser=user||{};const name=currentUser.name||currentUser.full_name||currentUser.email||'Workspace member';const role=currentUser.role||currentUser.roles?.[0]||'Member';$('userIdentity').textContent=`${name} · ${role}`;$('userAvatar').textContent=name.split(/\s+/).map(x=>x[0]).join('').slice(0,2).toUpperCase();$('loginScreen').hidden=true;$('dashboardShell').hidden=false;}
|
||||||
}
|
function renderMetrics(summary){const total=Number(summary?.businesses??summary?.total??prospects.length),high=prospects.filter(p=>scoreFor(p)>=80).length,review=prospects.filter(p=>statusOf(p)==='review').length,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 showLogin(message = '') {
|
function filterValues(){return {q:$('searchInput').value.trim(),score:$('scoreFilter').value,status:$('statusFilter').value,website_class:$('websiteClassFilter').value,pipeline_stage:$('pipelineFilter').value};}
|
||||||
currentUser = null;
|
function filtered(){const {q,score:sf,status:st,website_class:wc,pipeline_stage:ps}=filterValues();return prospects.filter(p=>{const s=scoreFor(p),text=`${p.name} ${p.website_domain||p.website||''} ${p.location||''}`.toLowerCase(),stage=p.pipeline_stage||p.pipeline?.stage||(Array.isArray(p.pipeline)?p.pipeline.at(-1)?.stage:'')||'new';return(!q||text.includes(q.toLowerCase()))&&(sf==='all'||(sf==='high'&&s>=80)||(sf==='medium'&&s>=60&&s<80)||(sf==='low'&&s<60))&&(st==='all'||statusOf(p)===st)&&(wc==='all'||(p.website_class||'missing')===wc)&&(ps==='all'||stage===ps);});}
|
||||||
$('dashboardShell').hidden = true; $('loginScreen').hidden = false;
|
function renderRows(){const rows=filtered();$('resultCount').textContent=`Showing ${rows.length} prospect${rows.length===1?'':'s'} · page ${page}`;$('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="${Number(p.id)===Number(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>';$('nextPageBtn').disabled=!hasNextPage;document.querySelectorAll('#prospectRows tr[data-id]').forEach(row=>row.addEventListener('click',()=>selectProspect(row.dataset.id)));}
|
||||||
$('loginMessage').textContent = message; $('loginMessage').className = `form-message${message ? ' error' : ''}`;
|
function listQuery(){const f=filterValues(),params=new URLSearchParams({page:String(page),page_size:String(pageSize)});if(f.q)params.set('q',f.q);if(f.website_class!=='all')params.set('website_class',f.website_class);if(f.pipeline_stage!=='all')params.set('pipeline_stage',f.pipeline_stage);return `?${params}`;}
|
||||||
}
|
async function loadData(){ $('apiStatus').textContent='● Connecting…';$('apiStatus').classList.remove('live');try{const [listRes,summaryRes]=await Promise.all([request(`/api/v1/businesses${listQuery()}`),request('/api/v1/dashboard/summary')]);if(!listRes.ok||!summaryRes.ok)throw new Error('API request failed');const list=await listRes.json(),summary=await summaryRes.json();prospects=Array.isArray(list)?list:(list.businesses||list.items||[]);hasNextPage=Boolean(list.has_next??list.next_page??list.next??list.next_cursor);renderMetrics(summary);$('apiStatus').textContent='● API connected';$('apiStatus').classList.add('live');}catch(error){if(error.message==='unauthorized')return;prospects=[];hasNextPage=false;renderMetrics(null);$('apiStatus').textContent='● API unavailable';}renderRows();if(selectedId)loadDetail(selectedId);}
|
||||||
function showDashboard(user) {
|
async function selectProspect(id){selectedId=Number(id);selectedDetail=null;renderRows();$('detailPanel').innerHTML='<div class="detail-loading" aria-live="polite">Loading prospect detail…</div>';await loadDetail(selectedId);}
|
||||||
currentUser = user || {};
|
async function loadDetail(id){try{const detail=await jsonRequest(`/api/v1/businesses/${encodeURIComponent(id)}`);selectedDetail=detail;const index=prospects.findIndex(p=>Number(p.id)===Number(id));if(index>=0)prospects[index]={...prospects[index],...detail};renderDetail(detail);}catch(error){if(error.message!=='unauthorized')$('detailPanel').innerHTML=`<div class="detail-error" role="alert"><h3>Unable to load detail</h3><p>${esc(error.message)}</p><button class="button ghost" id="retryDetailBtn" type="button">Try again</button></div>`;}}
|
||||||
const name = currentUser.name || currentUser.full_name || currentUser.email || 'Workspace member';
|
const listItems=(items,empty,label)=>Array.isArray(items)&&items.length?`<ul class="detail-list">${items.map(item=>`<li>${esc(typeof item==='string'?item:item[label]||item.value||item.name||JSON.stringify(item))}</li>`).join('')}</ul>`:`<p class="muted">${empty}</p>`;
|
||||||
const role = currentUser.role || currentUser.roles?.[0] || 'Member';
|
function renderDetail(p){const s=scoreFor(p),st=statusOf(p),f=freshness(p),factors=p.score_factors||p.factors||[],blocked=st==='review'||st==='suppressed',stage=p.pipeline_stage||p.pipeline?.stage||(Array.isArray(p.pipeline)?p.pipeline.at(-1)?.stage:'')||'new',contacts=p.contacts||[],domains=p.domains||[],websites=p.websites||[],evidence=p.evidence||p.evidence_timeline||[],notes=p.notes||[],review=p.review_status||p.review|| (st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1));$('detailPanel').innerHTML=`<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}">${esc(review)}</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>Pipeline stage</h4><form id="pipelineForm" class="inline-form"><select name="stage" aria-label="Pipeline stage"><option value="new" ${stage==='new'?'selected':''}>New</option><option value="qualified" ${stage==='qualified'?'selected':''}>Qualified</option><option value="review" ${stage==='review'?'selected':''}>Review</option><option value="suppressed" ${stage==='suppressed'?'selected':''}>Suppressed</option></select><button class="button ghost compact" type="submit">Save stage</button></form><p id="pipelineMessage" class="form-message" role="status"></p></div><div class="detail-block"><h4>Contacts <span class="count">${contacts.length}</span></h4>${listItems(contacts,'No contacts added.','email')}<form id="contactForm" class="compact-form"><input name="name" placeholder="Contact name" aria-label="Contact name"><input name="email" type="email" placeholder="Email" aria-label="Contact email" required><button class="button ghost compact" type="submit">Add contact</button></form><p id="contactMessage" class="form-message" role="status"></p></div><div class="detail-block"><h4>Domains & websites</h4>${listItems(domains,'No domains recorded.','domain')}${listItems(websites,'No websites recorded.','url')}</div><div class="detail-block"><h4>Evidence timeline</h4>${listItems(evidence,'No evidence events recorded.','description')}${factors.length?factors.map(x=>`<p class="evidence-line"><span>✓ ${esc(labelFactor(x))}</span><span class="confidence">${esc(p.confidence||'Medium')}</span></p>`).join(''):''}</div><div class="detail-block"><h4>Notes <span class="count">${notes.length}</span></h4>${listItems(notes,'No notes added.','body')}<form id="noteForm" class="compact-form"><textarea name="body" rows="2" placeholder="Add a review note…" required></textarea><button class="button ghost compact" type="submit">Add note</button></form><p id="noteMessage" class="form-message" role="status"></p></div><div class="detail-block"><h4>Review status</h4><p class="review-status">${esc(review)}</p>${st!=='suppressed'?'<button class="button primary compact" id="verifyBtn" type="button">Mark verified</button>':''}<p id="verifyMessage" class="form-message" role="status"></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>`:''}`;}
|
||||||
$('userIdentity').textContent = `${name} · ${role}`;
|
function message(id,text,error=false){const el=$(id);if(el){el.textContent=text;el.className=`form-message${error?' error':''}`;}}
|
||||||
$('userAvatar').textContent = name.split(/\s+/).map(x => x[0]).join('').slice(0,2).toUpperCase();
|
async function saveContact(form){const data=Object.fromEntries(new FormData(form).entries());if(!data.email.trim()){message('contactMessage','Email is required.',true);return;}try{await jsonRequest(`/api/v1/businesses/${selectedId}/contacts`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});message('contactMessage','Contact added.');await loadDetail(selectedId);}catch(e){if(e.message!=='unauthorized')message('contactMessage',e.message,true);}}
|
||||||
$('loginScreen').hidden = true; $('dashboardShell').hidden = false;
|
async function saveNote(form){const data=Object.fromEntries(new FormData(form).entries());if(!data.body.trim()){message('noteMessage','Note cannot be empty.',true);return;}try{await jsonRequest(`/api/v1/businesses/${selectedId}/notes`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});message('noteMessage','Note added.');await loadDetail(selectedId);}catch(e){if(e.message!=='unauthorized')message('noteMessage',e.message,true);}}
|
||||||
}
|
async function saveStage(form){const stage=new FormData(form).get('stage');if(!stage){message('pipelineMessage','Choose a pipeline stage.',true);return;}try{await jsonRequest(`/api/v1/businesses/${selectedId}/pipeline`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({stage})});message('pipelineMessage','Pipeline stage updated.');await loadDetail(selectedId);await loadData();}catch(e){if(e.message!=='unauthorized')message('pipelineMessage',e.message,true);}}
|
||||||
function renderMetrics(summary) {
|
async function verify(){try{await jsonRequest(`/api/v1/businesses/${selectedId}/verify`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({verified:true})});message('verifyMessage','Prospect marked verified.');await loadDetail(selectedId);await loadData();}catch(e){if(e.message!=='unauthorized')message('verifyMessage',e.message,true);}}
|
||||||
const total = Number(summary?.businesses ?? summary?.total ?? prospects.length);
|
async function addProspect(event){event.preventDefault();const data=Object.fromEntries(new FormData(event.currentTarget).entries());const msg=$('formMessage');try{const body=await jsonRequest('/api/v1/businesses',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});prospects.unshift(body);msg.textContent='Added to review queue.';event.currentTarget.reset();renderMetrics(null);renderRows();}catch(e){if(e.message!=='unauthorized'){msg.textContent=e.message;msg.className='form-message error';}}}
|
||||||
const high = prospects.filter(p => scoreFor(p) >= 80).length;
|
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])));}
|
||||||
const review = prospects.filter(p => statusOf(p) === 'review').length;
|
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>`;}
|
||||||
const fresh = prospects.length ? Math.round(prospects.filter(p => freshness(p).cls === 'good').length / prospects.length * 100) : 0;
|
async function login(event){event.preventDefault();const form=event.currentTarget,messageEl=$('loginMessage'),data=Object.fromEntries(new FormData(form).entries());messageEl.textContent='Signing in…';messageEl.className='form-message';try{const res=await fetch(endpoint('/api/v1/auth/login'),{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify(data)});const body=await res.json().catch(()=>({}));if(!res.ok)throw new Error(body.error||'Invalid email or password.');await bootstrap();}catch(e){if(e.message!=='unauthorized'){messageEl.textContent=e.message;messageEl.className='form-message error';}}}
|
||||||
$('metricTotal').textContent = total; $('heroCount').textContent = `${total} prospects`;
|
async function logout(){try{await fetch(endpoint('/api/v1/auth/logout'),{method:'POST',credentials:'include'});}finally{showLogin('You have been signed out.');$('loginForm').reset();}}
|
||||||
$('metricReview').textContent = summary?.needs_review ?? review; $('metricHigh').textContent = summary?.high_fit ?? high; $('metricFresh').textContent = `${summary?.freshness_under_7d ?? fresh}%`;
|
async function bootstrap(){try{const res=await fetch(endpoint('/api/v1/auth/me'),{credentials:'include'});if(res.status===401){showLogin();return;}if(!res.ok)throw new Error('Could not verify session.');const user=await res.json();showDashboard(user.user||user);await loadData();}catch(e){if(e.message!=='unauthorized')showLogin('Unable to connect to the workspace. Try again.');}}
|
||||||
}
|
document.addEventListener('submit',e=>{if(e.target.id==='contactForm')saveContact(e.target);if(e.target.id==='noteForm')saveNote(e.target);if(e.target.id==='pipelineForm')saveStage(e.target);});
|
||||||
function filtered() {
|
document.addEventListener('click',e=>{if(e.target.id==='verifyBtn')verify();if(e.target.id==='retryDetailBtn'&&selectedId)loadDetail(selectedId);});
|
||||||
const q = $('searchInput').value.trim().toLowerCase(), sf = $('scoreFilter').value, st = $('statusFilter').value;
|
$('loginForm').addEventListener('submit',login);$('logoutBtn').addEventListener('click',logout);$('searchInput').addEventListener('input',()=>{page=1;renderRows();});['scoreFilter','statusFilter','websiteClassFilter','pipelineFilter'].forEach(id=>$(id).addEventListener('change',()=>{page=1;loadData();}));$('pageSize').addEventListener('change',e=>{pageSize=Number(e.target.value);page=1;loadData();});$('nextPageBtn').addEventListener('click',()=>{if(hasNextPage){page+=1;loadData();}});$('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()));
|
||||||
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='● Connecting…'; $('apiStatus').classList.remove('live');
|
|
||||||
try { const [listRes, summaryRes] = await Promise.all([request('/api/v1/businesses'), request('/api/v1/dashboard/summary')]); if (!listRes.ok || !summaryRes.ok) throw new Error('API request failed'); const list=await listRes.json(), summary=await summaryRes.json(); prospects=Array.isArray(list)?list:(list.businesses||list.items||[]); renderMetrics(summary); $('apiStatus').textContent='● API connected'; $('apiStatus').classList.add('live'); } catch (error) { if (error.message === 'unauthorized') { return; } prospects=[]; renderMetrics(null); $('apiStatus').textContent='● API unavailable'; }
|
|
||||||
renderRows();
|
|
||||||
}
|
|
||||||
async function addProspect(event) { event.preventDefault(); const data=Object.fromEntries(new FormData(event.currentTarget).entries()); const msg=$('formMessage'); try { const res=await request('/api/v1/businesses',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)}); const body=await res.json(); if(!res.ok) throw new Error(body.error||'Could not add prospect'); prospects.unshift(body); msg.textContent='Added to review queue.'; event.currentTarget.reset(); renderMetrics(null); renderRows(); } catch(e) { if(e.message !== 'unauthorized') { msg.textContent=e.message; msg.className='form-message error'; } } }
|
|
||||||
function parseCsv(text) { const lines=text.trim().split(/\r?\n/).filter(Boolean), cells=line=>line.match(/("[^"]*(?:""[^"]*)*"|[^,]+)(?=,|$)/g)?.map(x=>x.replace(/^"|"$/g,'').replaceAll('""','"'))||[]; if(!lines.length)return []; const headers=cells(lines[0]); return lines.slice(1,11).map(l=>Object.fromEntries(cells(l).map((v,i)=>[headers[i]||`column_${i+1}`,v]))); }
|
|
||||||
function renderCsv(rows) { if(!rows.length){$('csvPreview').innerHTML='<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>`; }
|
|
||||||
async function login(event) { event.preventDefault(); const form=event.currentTarget, message=$('loginMessage'); const data=Object.fromEntries(new FormData(form).entries()); message.textContent='Signing in…'; message.className='form-message'; try { const res=await fetch(endpoint('/api/v1/auth/login'),{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify(data)}); const body=await res.json().catch(()=>({})); if(!res.ok) throw new Error(body.error || 'Invalid email or password.'); await bootstrap(); } catch(e) { if(e.message !== 'unauthorized') { message.textContent=e.message; message.className='form-message error'; } } }
|
|
||||||
async function logout() { try { await fetch(endpoint('/api/v1/auth/logout'),{method:'POST',credentials:'include'}); } finally { showLogin('You have been signed out.'); $('loginForm').reset(); } }
|
|
||||||
async function bootstrap() { try { const res=await fetch(endpoint('/api/v1/auth/me'),{credentials:'include'}); if(res.status===401) { showLogin(); return; } if(!res.ok) throw new Error('Could not verify session.'); const user=await res.json(); showDashboard(user.user || user); await loadData(); } catch(e) { if(e.message !== 'unauthorized') showLogin('Unable to connect to the workspace. Try again.'); } }
|
|
||||||
$('loginForm').addEventListener('submit',login); $('logoutBtn').addEventListener('click',logout); $('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()));
|
|
||||||
bootstrap();
|
bootstrap();
|
||||||
})();
|
})();
|
||||||
|
|||||||
+2
-2
@@ -45,8 +45,8 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="workspace-grid" id="explorer">
|
<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="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="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><select id="websiteClassFilter" aria-label="Filter by website class"><option value="all">All website classes</option><option value="business_site">Business site</option><option value="social_profile">Social profile</option><option value="missing">Missing website</option></select><select id="pipelineFilter" aria-label="Filter by pipeline stage"><option value="all">All pipeline stages</option><option value="new">New</option><option value="qualified">Qualified</option><option value="review">Review</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-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><label class="page-size">Rows <select id="pageSize" aria-label="Page size"><option>10</option><option>25</option><option>50</option></select></label><button class="button ghost compact" id="nextPageBtn" type="button">Next page →</button></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 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>
|
</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>
|
<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>
|
||||||
|
|||||||
@@ -6,14 +6,19 @@
|
|||||||
<iframe id="app" src="index.html" hidden></iframe>
|
<iframe id="app" src="index.html" hidden></iframe>
|
||||||
<script>
|
<script>
|
||||||
const frame=document.querySelector('#app');
|
const frame=document.querySelector('#app');
|
||||||
frame.onload=async()=>{const d=frame.contentDocument; const js=await fetch('app.js').then(r=>r.text()); const checks=[
|
frame.onload=async()=>{const d=frame.contentDocument;const js=await fetch('app.js').then(r=>r.text());const checks=[
|
||||||
['Login screen',()=>!!d.querySelector('#loginScreen')],
|
['Login screen',()=>!!d.querySelector('#loginScreen')],
|
||||||
['Email/password login fields',()=>!!d.querySelector('#loginEmail')&&!!d.querySelector('#loginPassword')],
|
['Email/password login fields',()=>!!d.querySelector('#loginEmail')&&!!d.querySelector('#loginPassword')],
|
||||||
['Dashboard starts protected',()=>d.querySelector('#dashboardShell').hidden],
|
['Dashboard starts protected',()=>d.querySelector('#dashboardShell').hidden],
|
||||||
['Logout and user display',()=>!!d.querySelector('#logoutBtn')&&!!d.querySelector('#userIdentity')],
|
['Logout and user display',()=>!!d.querySelector('#logoutBtn')&&!!d.querySelector('#userIdentity')],
|
||||||
|
['Explorer pagination and filters',()=>!!d.querySelector('#pageSize')&&!!d.querySelector('#nextPageBtn')&&!!d.querySelector('#websiteClassFilter')&&!!d.querySelector('#pipelineFilter')],
|
||||||
|
['Detail panel contract',()=>!!d.querySelector('#detailPanel')&&js.includes('/api/v1/businesses/${encodeURIComponent(id)}')&&js.includes('contacts')&&js.includes('evidence_timeline')&&js.includes('pipeline_stage')],
|
||||||
|
['Manual detail controls',()=>js.includes('contactForm')&&js.includes('noteForm')&&js.includes('pipelineForm')&&js.includes('verifyBtn')],
|
||||||
['All API requests include cookies',()=>[...js.matchAll(/fetch\([^;]+/g)].every(m=>m[0].includes("credentials:'include'"))],
|
['All API requests include cookies',()=>[...js.matchAll(/fetch\([^;]+/g)].every(m=>m[0].includes("credentials:'include'"))],
|
||||||
|
['Detail writes use authenticated request helper',()=>['/contacts','/notes','/pipeline','/verify'].every(path=>js.includes(path)&&js.includes('jsonRequest'))],
|
||||||
|
['Validation and error states',()=>js.includes('Email is required.')&&js.includes('Note cannot be empty.')&&js.includes('detail-error')&&js.includes('role="alert"')],
|
||||||
['No hardcoded credentials or demo fallback',()=>!js.includes('demoProspects')&&!/password.{0,30}['\"][^'\"]+['\"]/.test(js)],
|
['No hardcoded credentials or demo fallback',()=>!js.includes('demoProspects')&&!/password.{0,30}['\"][^'\"]+['\"]/.test(js)],
|
||||||
['Safety copy and blocked outreach preserved',()=>js.includes('disabled-action')&&js.includes('Suppressed records cannot be contacted.')&&js.includes('Review this prospect before outreach is available.')],
|
['Safety copy and blocked outreach preserved',()=>js.includes('disabled-action')&&js.includes('Suppressed records cannot be contacted.')&&js.includes('Review this prospect before outreach is available.')],
|
||||||
['No outreach/send controls',()=>!Array.from(d.querySelectorAll('button')).some(x=>/outreach|send/i.test(x.textContent)&&!x.disabled)]
|
['No outreach/send controls',()=>!Array.from(d.querySelectorAll('button')).some(x=>/outreach|send/i.test(x.textContent)&&!x.disabled)]
|
||||||
]; let passed=0; document.querySelector('#checks').innerHTML=checks.map(([name,test])=>{const ok=test();if(ok)passed++;return `<li class="${ok?'pass':'fail'}">${ok?'PASS':'FAIL'} — ${name}</li>`}).join(''); document.querySelector('#summary').textContent=`${passed}/${checks.length} checks passed`;};
|
];let passed=0;document.querySelector('#checks').innerHTML=checks.map(([name,test])=>{const ok=test();if(ok)passed++;return `<li class="${ok?'pass':'fail'}">${ok?'PASS':'FAIL'} — ${name}</li>`}).join('');document.querySelector('#summary').textContent=`${passed}/${checks.length} checks passed`;};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+26
-10
@@ -18,19 +18,33 @@ The expected health endpoints are:
|
|||||||
|
|
||||||
A service is ready only when Compose reports `healthy`; container running status alone is insufficient. Health checks call public liveness endpoints and must remain unauthenticated—do not add a session requirement to `/api/v1/health/live` or `/healthz`. The Compose environment explicitly carries `AUTOMATED_OUTREACH_ENABLED=false` as an operational safety setting.
|
A service is ready only when Compose reports `healthy`; container running status alone is insufficient. Health checks call public liveness endpoints and must remain unauthenticated—do not add a session requirement to `/api/v1/health/live` or `/healthz`. The Compose environment explicitly carries `AUTOMATED_OUTREACH_ENABLED=false` as an operational safety setting.
|
||||||
|
|
||||||
|
## Phase 3 workflow operations
|
||||||
|
|
||||||
|
Phase 3 is a human-operated prospect workflow. Operators manually create a business, add child intelligence/evidence observations with their provenance, review the detail page, add notes, and move the prospect through the permitted pipeline states. The API records the acting user and tenant on state-changing actions and exposes bounded activity/audit history where configured.
|
||||||
|
|
||||||
|
- Treat source/provenance fields as required lineage for manual evidence: retain the source reference or label, captured/observed time, and confidence/context supplied by the operator.
|
||||||
|
- Review pagination metadata and filters when investigating a list. Never infer that a page is the complete tenant dataset, and never use a UI filter as proof of authorization.
|
||||||
|
- Investigate a missing detail or child record as a possible tenant/parent scope issue before retrying with alternate IDs. Cross-tenant IDs are expected to return not found.
|
||||||
|
- Pipeline state is coordination metadata only. Suppressed records remain blocked, and no state enables outreach.
|
||||||
|
- Notes may contain sensitive information. Limit access and avoid copying secrets, credentials, or unnecessary personal data into notes or audit details.
|
||||||
|
- Audit/activity records are operational evidence of changes, not a replacement for a production-grade immutable audit service.
|
||||||
|
|
||||||
|
There is no automated discovery job, DNS/website scanner, enrichment worker, or outreach worker to monitor in this release. CSV is preview-only; do not describe a preview as an import or assume that rows were persisted.
|
||||||
|
|
||||||
## Configuration and deployment
|
## Configuration and deployment
|
||||||
|
|
||||||
Copy `.env.example` for local development. Production values must be supplied by the deployment environment, never committed. `BOOTSTRAP_ADMIN_EMAIL` and `BOOTSTRAP_ADMIN_PASSWORD` are optional API environment variables for first-run admin provisioning only; set them together through a secret store or protected deployment environment, remove them immediately after successful bootstrap, and rotate the password. Do not put real values in Compose files, CI variables visible to logs, images, or committed `.env` files.
|
Copy `.env.example` for local development. Production values must be supplied by the deployment environment, never committed. `BOOTSTRAP_ADMIN_EMAIL` and `BOOTSTRAP_ADMIN_PASSWORD` are optional API environment variables for first-run admin provisioning only; set them together through a secret store or protected deployment environment, remove them immediately after successful bootstrap, and rotate the password. Do not put real values in Compose files, CI variables visible to logs, images, or committed `.env` files.
|
||||||
|
|
||||||
For production, use Argon2id for password hashing and require MFA for administrator accounts. Configure TLS before enabling `Secure` session cookies. Local Compose uses HTTP, so browser testing of the production `Secure` cookie behavior requires an HTTPS staging environment. Treat session cookies as bearer credentials: protect state-changing routes with CSRF controls, expire/revoke sessions, and never print cookie values in logs.
|
For production, use Argon2id for password hashing and require MFA for administrator accounts. Configure TLS before enabling `Secure` session cookies. Local Compose uses HTTP, so browser testing of production `Secure` cookie behavior requires an HTTPS staging environment. Treat session cookies as bearer credentials: protect state-changing routes with CSRF controls, expire/revoke sessions, and never print cookie values in logs.
|
||||||
|
|
||||||
Before deployment:
|
Before deployment:
|
||||||
|
|
||||||
1. Run `docker compose config` and review the rendered configuration (optional bootstrap values should be empty in CI and local validation).
|
1. Run `docker compose config` and review the rendered configuration; optional bootstrap values should be empty in CI and local validation.
|
||||||
2. Build from a reviewed commit and scan the resulting images.
|
2. Build from a reviewed commit and scan the resulting images.
|
||||||
3. Restrict host/network exposure at the ingress/firewall.
|
3. Restrict host/network exposure at the ingress/firewall.
|
||||||
4. Verify both unauthenticated health checks and review logs for unexpected errors or sensitive data.
|
4. Verify both unauthenticated health checks and review logs for unexpected errors, cross-tenant errors, or sensitive data.
|
||||||
5. Record the image digest and configuration revision for rollback.
|
5. Exercise tenant-scoped list/detail/child routes with bounded pagination and filters, and verify that notes/pipeline changes appear in the intended tenant's audit trail only.
|
||||||
|
6. Record the image digest and configuration revision for rollback.
|
||||||
|
|
||||||
## Data, backups, and retention
|
## Data, backups, and retention
|
||||||
|
|
||||||
@@ -40,7 +54,7 @@ Any older files under `infrastructure/docker/` are not referenced by Compose and
|
|||||||
|
|
||||||
Inspect the volume with `docker volume inspect prospect-platform-api-data`; do not treat a local Docker volume as a backup.
|
Inspect the volume 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.
|
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 business, child intelligence, notes, provenance, and audit data with encryption and access controls, test a restore into an isolated environment, and document the result. Define retention/deletion rules that cover source references and notes as well as contact fields.
|
||||||
|
|
||||||
Recommended starting policy for a future production data store:
|
Recommended starting policy for a future production data store:
|
||||||
|
|
||||||
@@ -56,18 +70,20 @@ Do not run `docker compose down -v` on a data-bearing environment: it removes th
|
|||||||
|
|
||||||
- **Unhealthy API:** inspect `docker compose logs api`, verify port binding and resource availability, then restart with `docker compose restart api` if appropriate.
|
- **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`.
|
- **Unhealthy web:** inspect `docker compose logs web`; confirm port `8080` is available and the image contains `/healthz`.
|
||||||
|
- **Missing or inconsistent detail:** preserve the request identifiers/log context, verify the authenticated tenant and parent-child association, and do not retry by guessing another tenant's ID.
|
||||||
|
- **Audit gap:** stop the affected mutation workflow, preserve the database/log evidence, and investigate before allowing operators to rely on the history.
|
||||||
- **Build failure:** run `docker compose build --no-cache` from a reviewed checkout and check Docker daemon/network status.
|
- **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.
|
- **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
|
## 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.
|
Adding Postgres, Redis, workers, schedulers, discovery adapters, or scanners requires explicit readiness checks, migrations, queue durability/idempotency, secret injection, network segmentation, metrics/alerts, backup/restore procedures, provenance/source governance, and an operational owner. Do not add them as an implicit Compose dependency: this MVP is intentionally runnable without external Postgres or Redis, and no automated discovery or outreach may be inferred from the scaling path.
|
||||||
|
|
||||||
## Incident checklist
|
## Incident checklist
|
||||||
|
|
||||||
1. Record time, affected service, image/config revision, and observed health state.
|
1. Record time, affected service, image/config revision, and observed health state.
|
||||||
2. Preserve relevant logs without exporting secrets or unnecessary contact data.
|
2. Preserve relevant logs and audit records 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.
|
3. Stop or isolate the affected service if data loss, unauthorized access, SSRF, provenance tampering, or unexpected outreach is suspected.
|
||||||
4. Rotate exposed credentials through the secret manager.
|
4. Rotate exposed credentials through the secret manager.
|
||||||
5. Validate recovery with health checks and a targeted smoke test.
|
5. Validate recovery with health checks and a targeted tenant-isolation/detail smoke test.
|
||||||
6. Document root cause, corrective action, and any retention/suppression impact.
|
6. Document root cause, corrective action, and any retention/suppression or audit impact.
|
||||||
|
|||||||
+16
-11
@@ -2,30 +2,35 @@
|
|||||||
|
|
||||||
## Current safety boundary
|
## 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.
|
- **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.
|
||||||
|
- Phase 3 intelligence is **manual and provenance-first**. Operators enter child intelligence/evidence records; the platform does not perform automated prospect discovery, DNS resolution, website/HTTP scanning, or external enrichment.
|
||||||
|
- Every business, child record, note, pipeline transition, and audit/activity read or write must be constrained to the authenticated user's organization. A child identifier must never bypass the parent/tenant check. Cross-tenant misses should be indistinguishable from an absent record.
|
||||||
|
- Evidence provenance (source reference/label, captured or observed time, actor, and confidence where supported) is data lineage, not proof that the platform independently verified the source. Do not fabricate provenance or silently upgrade an observation to a verified fact.
|
||||||
|
- List and child-record APIs use bounded pagination and server-side filters. Bounds must be enforced before query execution and filters must be combined with the tenant predicate; never use client-side filtering as an authorization control.
|
||||||
|
- Pipeline and notes are collaboration metadata. A reviewed pipeline state does not authorize contact, and suppression takes precedence over every other state. Audit entries should capture actor, tenant, target, action, timestamp, and safe details without secrets or unnecessary contact data.
|
||||||
- No credentials are committed. `.env.example` contains non-secret names and local defaults only.
|
- No credentials are committed. `.env.example` contains non-secret names and local defaults only.
|
||||||
- Authentication uses server-side sessions for browser clients. The session identifier is carried in an `HttpOnly` cookie; logout/revocation must invalidate the server-side session. Health endpoints are deliberately public and must remain usable without a session.
|
- Authentication uses server-side sessions for browser clients. The session identifier is carried in an `HttpOnly` cookie; logout/revocation must invalidate the server-side session. Health endpoints are deliberately public and must remain usable without a session.
|
||||||
- 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.
|
- 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 remains a small MVP security boundary. Authentication/session handling does not by itself provide authorization, CSRF protection, rate limiting, MFA, or a complete audit log.
|
|
||||||
|
|
||||||
## Known limitations before production
|
## Known limitations before production
|
||||||
|
|
||||||
1. **Password storage:** production passwords must be hashed with Argon2id using a reviewed cost/memory/parallelism policy. Never store plaintext or reversible passwords, and never log bootstrap credentials. Rehash on login when the policy changes.
|
1. **Password storage:** production passwords must be hashed with Argon2id using a reviewed cost/memory/parallelism policy. Never store plaintext or reversible passwords, and never log bootstrap credentials. Rehash on login when the policy changes.
|
||||||
2. **MFA:** require phishing-resistant or TOTP MFA for administrator accounts in production, including the bootstrap admin before granting ongoing administrative access. Define recovery, enrollment, reset, and revocation procedures; do not treat a password-only bootstrap as production-ready.
|
2. **MFA:** require phishing-resistant or TOTP MFA for administrator accounts in production, including the bootstrap admin before granting ongoing administrative access. Define recovery, enrollment, reset, and revocation procedures; do not treat a password-only bootstrap as production-ready.
|
||||||
3. **Authentication and authorization:** enforce authorization server-side on every protected route, rotate/regenerate sessions at login and privilege changes, expire idle/absolute sessions, revoke on logout/password reset, and test tenant isolation. The bootstrap variables are one-time provisioning inputs, not a standing authentication mechanism.
|
3. **Authentication and authorization:** enforce authorization server-side on every protected route, including every child-record, note, pipeline, and audit route. Rotate/regenerate sessions at login and privilege changes, expire idle/absolute sessions, revoke on logout/password reset, and test tenant isolation.
|
||||||
4. **Cookies and CSRF:** use `HttpOnly`, `Secure` (production HTTPS), and an appropriate `SameSite` policy. `Secure` cookies cannot be exercised over the local HTTP Compose URLs, and `SameSite` is defense-in-depth—not a complete CSRF control. Browser state-changing endpoints require CSRF tokens (or a rigorously reviewed equivalent); do not rely on CORS or cookie flags alone.
|
4. **Cookies and CSRF:** use `HttpOnly`, `Secure` (production HTTPS), and an appropriate `SameSite` policy. Browser state-changing endpoints require CSRF tokens (or a rigorously reviewed equivalent); do not rely on CORS or cookie flags alone.
|
||||||
5. **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.
|
5. **SSRF and future scanners:** no scanner is enabled in this release. If a future approved feature fetches a URL, 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.
|
||||||
6. **Input/output safety:** validate schema and content types, bound request sizes, parameterize database queries, escape output, and avoid logging contact data or secrets.
|
6. **Input/output safety:** validate schema and content types, bound request and note/evidence sizes, parameterize database queries, escape output, and reject unsafe provenance URLs or markup. Treat operator-entered notes and sources as untrusted data.
|
||||||
7. **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. Remove bootstrap variables after first-run provisioning.
|
7. **Audit and retention:** the current audit/activity behavior is an MVP trail, not an immutable compliance log. Define append-only guarantees, retention, redaction/deletion rules, access controls, alerting, and export procedures before production.
|
||||||
8. **Transport and perimeter:** terminate TLS at a trusted ingress, restrict exposed ports, add network policy, and place admin surfaces behind appropriate access controls.
|
8. **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. Remove bootstrap variables after first-run provisioning.
|
||||||
9. **Data protection:** define retention and deletion rules for prospect/contact data, restrict volume access, encrypt backups, and maintain an access/audit trail.
|
9. **Transport and perimeter:** terminate TLS at a trusted ingress, restrict exposed ports, add network policy, and place admin surfaces behind appropriate access controls.
|
||||||
|
10. **Data protection:** define retention and deletion rules for prospect/contact data and provenance, restrict volume access, encrypt backups, and maintain a tested access/audit trail.
|
||||||
|
|
||||||
## Source and contact policy
|
## 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.
|
Treat manually supplied business information, notes, and source references 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 or a reviewed pipeline state. Any future outreach or discovery feature requires explicit product/legal and security review and must remain off by default.
|
||||||
|
|
||||||
## CI/dependency hygiene
|
## 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 may validate Compose with empty optional bootstrap variables and call unauthenticated health checks; it is not a substitute for Argon2id parameter review, MFA testing, or a security assessment.
|
Pin or review base-image and dependency updates, scan images before release, use least-privilege GitHub tokens, and avoid printing environment values. CI may validate Compose with empty optional bootstrap variables and call unauthenticated health checks; it is not a substitute for authorization/tenant-isolation tests, provenance policy review, Argon2id parameter review, MFA testing, or a security assessment.
|
||||||
|
|
||||||
Report vulnerabilities privately to the repository maintainers; do not include live credentials or personal data in an issue.
|
Report vulnerabilities privately to the repository maintainers; do not include live credentials or personal data in an issue.
|
||||||
|
|||||||
Reference in New Issue
Block a user