From 7250f6d52133a3e579aafda7c1b79f51b45d28ea Mon Sep 17 00:00:00 2001 From: Marco0300 Date: Sun, 6 Sep 2026 17:16:09 +0200 Subject: [PATCH] deploy ProspectOS workspace final --- .env.example | 15 + README.md | 55 ++ api/Dockerfile | 1 + api/server.py | 1062 +++++++++++++++++++++++++++++++++++--- docker-compose.yml | 10 + scripts/backup_sqlite.py | 35 ++ tests/test_app.py | 16 + web/app.js | 32 +- web/index.html | 2 +- web/style.css | 4 + 10 files changed, 1151 insertions(+), 81 deletions(-) create mode 100644 scripts/backup_sqlite.py diff --git a/.env.example b/.env.example index b6f37c6..e54c2ec 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,21 @@ BROWSER_USE_LLM_TIMEOUT_SECONDS=90 ANONYMIZED_TELEMETRY=false LOG_RETENTION=20000 +# Authentication and workspace ownership. Leave OWNER_* blank to use the +# first-run setup screen; setting them seeds the owner on an unattended start. +AUTH_REQUIRED=true +AUTH_SESSION_TTL_SECONDS=604800 +AUTH_COOKIE_SECURE=false +WORKSPACE_NAME=ProspectOS workspace +OWNER_EMAIL= +OWNER_PASSWORD= +OWNER_NAME=Workspace owner + +# Verified online backups. The API retains the newest N files. +BACKUP_DIR=/data/backups +BACKUP_RETENTION=14 +BACKUP_INTERVAL_HOURS=24 + # AI provider for analysis and tailored drafts: nous or custom. AI_PROVIDER=custom # For Nous, use https://portal.nousresearch.com to create an API key; diff --git a/README.md b/README.md index ac38733..f19d0b4 100644 --- a/README.md +++ b/README.md @@ -225,3 +225,58 @@ This project is packaged for personal research. Public availability does not override a website's terms, robots controls, or applicable privacy law; keep searches small, use the worker only on data you are permitted to collect, and do not turn the review screen into unattended outreach. + +## Workspace accounts and operational controls + +Authentication is enabled by default. On a fresh database, open the app and +complete the first-run owner setup. For unattended deployments, set +`OWNER_EMAIL`, `OWNER_PASSWORD` (8+ characters), `OWNER_NAME`, and +`WORKSPACE_NAME` in `.env`; the owner is created automatically only when no +user exists. Sessions are server-side, cookies are HTTP-only, and mutating +browser requests require a per-session CSRF token. Failed sign-ins are +temporarily rate-limited after five consecutive failures. + +Roles are deliberately small: + +| Role | Capabilities | +|---|---| +| Viewer | Read catalogue and runs | +| Reviewer | Viewer + review status, notes, and follow-up tasks | +| Researcher | Reviewer + discovery runs, retries, cancellation, and assignments | +| Admin | Researcher + users, workspace name, backups, and audit trail | +| Owner | Admin, and cannot be removed as the last active owner | + +The **Users** page manages workspace members. A prospect drawer contains its +assignee, notes, tasks, review status, and score/AI assessment history. Every +workspace-scoped API query checks the signed-in organisation before returning +data. + +## Backups and recovery + +The app keeps the existing SQLite catalogue and performs additive schema +migrations; it does not delete or rewrite existing prospects. Use **Users → +Verified backups → Create backup** or run this inside the app container: + +```bash +python scripts/backup_sqlite.py +``` + +Backups use SQLite's online backup API, run `PRAGMA integrity_check`, and keep +the newest `BACKUP_RETENTION` files in `BACKUP_DIR` (default `/data/backups`). +The background scheduler creates one automatically every `BACKUP_INTERVAL_HOURS` +(default 24; set it to `0` to disable automatic creation). +Include the `prospect_data` volume in server backups. A scheduled or interrupted +run is marked `recovery_required` after 30 minutes without a heartbeat. The +Runs page can show its persisted event timeline and retry a failed/recoverable +run as a new attempt, preserving the original run. Active jobs can be cancelled +and the worker records the cancellation in the event stream. + +The audit trail records sign-ins, user and workspace changes, assignments, +review decisions, notes, tasks, AI assessments, message generation, run +retries/cancellations, settings, suppressions, and backup actions. Operational +logs remain available under **Logs**; credentials and authorization values are +redacted and retention is bounded by `LOG_RETENTION`. + +This release intentionally keeps SQLite for safety. PostgreSQL migration can +be added later as a separate, tested data-migration project once the current +workspace and backup workflow is stable. diff --git a/api/Dockerfile b/api/Dockerfile index b604800..e7a8e48 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -5,6 +5,7 @@ WORKDIR /app COPY api/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY api/server.py api/intelligence.py api/browser_investigator.py ./ +COPY scripts /app/scripts COPY web /app/web RUN useradd --system --create-home app && mkdir -p /data && chown -R app:app /app /data USER app diff --git a/api/server.py b/api/server.py index 5c1b6e4..95fe658 100644 --- a/api/server.py +++ b/api/server.py @@ -9,11 +9,14 @@ an explicit fallback (DISCOVERY_BACKEND=browser) for local testing. from __future__ import annotations import csv +import base64 import hashlib +import hmac import io import json import os import re +import secrets import sqlite3 import threading import time @@ -22,6 +25,7 @@ import urllib.request from datetime import datetime, timezone from html.parser import HTMLParser from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from http.cookies import SimpleCookie from pathlib import Path from typing import Any, Callable from urllib.parse import parse_qs, quote_plus, unquote, urlparse @@ -56,6 +60,20 @@ AI_PROVIDER = os.environ.get("AI_PROVIDER", "custom").strip().lower() # requests instead of relying on that default. OUTBOUND_USER_AGENT = os.environ.get("OUTBOUND_USER_AGENT", "ProspectOS/2.0 (+personal-use)").strip() or "ProspectOS/2.0 (+personal-use)" BROWSER_USE_ENABLED = os.environ.get("BROWSER_USE_ENABLED", "1").strip().lower() not in {"0", "false", "no", "off"} +AUTH_REQUIRED = os.environ.get("AUTH_REQUIRED", "true").strip().lower() not in {"0", "false", "no", "off"} +AUTH_COOKIE_NAME = "prospect_session" +AUTH_SESSION_TTL_SECONDS = max(3600, min(60 * 60 * 24 * 30, int(os.environ.get("AUTH_SESSION_TTL_SECONDS", str(60 * 60 * 24 * 7))))) +AUTH_COOKIE_SECURE = os.environ.get("AUTH_COOKIE_SECURE", "false").strip().lower() not in {"0", "false", "no", "off"} +WORKSPACE_NAME = os.environ.get("WORKSPACE_NAME", "ProspectOS workspace").strip() or "ProspectOS workspace" +BACKUP_DIR = os.environ.get("BACKUP_DIR", "").strip() +try: + BACKUP_RETENTION = max(1, min(100, int(os.environ.get("BACKUP_RETENTION", "14")))) +except (TypeError, ValueError): + BACKUP_RETENTION = 14 +try: + BACKUP_INTERVAL_HOURS = max(0, min(168, int(os.environ.get("BACKUP_INTERVAL_HOURS", "24")))) +except (TypeError, ValueError): + BACKUP_INTERVAL_HOURS = 24 NOUS_BASE_URL = "https://inference-api.nousresearch.com/v1" NOUS_FREE_MODELS = [ {"id": "meituan/longcat-2.0:free", "label": "Meituan · LongCat 2.0", "free": True}, @@ -74,6 +92,49 @@ _schema_lock = threading.Lock() _schema_path: str | None = None +def password_hash(password: str) -> str: + """Hash a password with the standard-library scrypt implementation.""" + value = str(password or "") + if len(value) < 8: + raise ValueError("password_must_be_at_least_8_characters") + salt = secrets.token_bytes(16) + digest = hashlib.scrypt(value.encode("utf-8"), salt=salt, n=16_384, r=8, p=1, dklen=32) + return "scrypt$16384$8$1$" + base64.urlsafe_b64encode(salt).decode("ascii") + "$" + base64.urlsafe_b64encode(digest).decode("ascii") + + +def password_matches(password: str, stored: str) -> bool: + try: + scheme, n, r, p, salt_text, digest_text = str(stored or "").split("$", 5) + if scheme != "scrypt": + return False + salt = base64.urlsafe_b64decode(salt_text.encode("ascii")) + expected = base64.urlsafe_b64decode(digest_text.encode("ascii")) + actual = hashlib.scrypt(str(password or "").encode("utf-8"), salt=salt, n=int(n), r=int(r), p=int(p), dklen=len(expected)) + return hmac.compare_digest(actual, expected) + except (TypeError, ValueError, base64.binascii.Error): + return False + + +def _session_hash(token: str) -> str: + return hashlib.sha256(str(token).encode("utf-8")).hexdigest() + + +def public_auth_path(path: str) -> bool: + return path in {"/api/health", "/api/config", "/api/auth/status", "/api/auth/login", "/api/auth/setup"} + + +def user_view(row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]: + value = dict(row) + for secret in ("password_hash", "failed_login_count", "locked_until"): + value.pop(secret, None) + value["active"] = bool(value.get("active", True)) + return value + + +def workspace_view(row: sqlite3.Row | dict[str, Any] | None) -> dict[str, Any] | None: + return dict(row) if row else None + + def _saved_settings() -> dict[str, str]: """Read persisted runtime settings, falling back cleanly during startup.""" try: @@ -257,7 +318,8 @@ def _redact_log_value(value: Any, depth: int = 0) -> Any: def record_log(kind: str, event: str, *, level: str = "info", method: str = "", path: str = "", status: int | None = None, duration_ms: int | None = None, details: Any = None, - error: str = "", run_id: int | None = None, prospect_id: int | None = None) -> None: + error: str = "", run_id: int | None = None, prospect_id: int | None = None, + organization_id: int | None = None, user_id: int | None = None) -> None: """Persist one bounded operational event; logging must never break a request.""" try: safe_kind = str(kind or "system").strip().lower()[:30] or "system" @@ -266,11 +328,18 @@ def record_log(kind: str, event: str, *, level: str = "info", method: str = "", encoded = json.dumps(safe_details, ensure_ascii=False, default=str)[:20_000] conn = db() try: + resolved_org = organization_id + if resolved_org is None and prospect_id is not None: + lookup = conn.execute("SELECT organization_id FROM prospects WHERE id=?", (prospect_id,)).fetchone() + resolved_org = lookup[0] if lookup else None + if resolved_org is None and run_id is not None: + lookup = conn.execute("SELECT organization_id FROM runs WHERE id=?", (run_id,)).fetchone() + resolved_org = lookup[0] if lookup else None cur = conn.execute( - """INSERT INTO logs(created_at,kind,level,event,method,path,status,duration_ms,run_id,prospect_id,details,error) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""", + """INSERT INTO logs(created_at,kind,level,event,method,path,status,duration_ms,run_id,prospect_id,details,error,organization_id,user_id) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (now(), safe_kind, safe_level, str(event or "event")[:240], str(method or "")[:12], - str(path or "")[:500], status, duration_ms, run_id, prospect_id, encoded, str(error or "")[:2_000]), + str(path or "")[:500], status, duration_ms, run_id, prospect_id, encoded, str(error or "")[:2_000], int(resolved_org or 1), user_id), ) # Retain enough history for investigation without allowing a busy # browser session to grow the SQLite volume indefinitely. @@ -302,8 +371,53 @@ def db() -> sqlite3.Connection: if _schema_path == DB_PATH: _schema_lock.release() return conn + conn.execute("""CREATE TABLE IF NOT EXISTS organizations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )""") + conn.execute("""CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + organization_id INTEGER NOT NULL, + email TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'reviewer', + active INTEGER NOT NULL DEFAULT 1, + failed_login_count INTEGER NOT NULL DEFAULT 0, + locked_until TEXT NOT NULL DEFAULT '', + last_login_at TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(organization_id, email) + )""") + conn.execute("""CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + csrf_token TEXT NOT NULL, + created_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + ip_address TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '' + )""") + conn.execute("""CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT NOT NULL DEFAULT '' + )""") + conn.execute("INSERT OR IGNORE INTO organizations(id,name,created_at,updated_at) VALUES(1,?,?,?)", (WORKSPACE_NAME, now(), now())) conn.execute("""CREATE TABLE IF NOT EXISTS prospects ( id INTEGER PRIMARY KEY AUTOINCREMENT, + organization_id INTEGER NOT NULL DEFAULT 1, + created_by INTEGER, + assigned_user_id INTEGER, + updated_at TEXT NOT NULL DEFAULT '', name TEXT NOT NULL, website TEXT NOT NULL DEFAULT '', domain TEXT NOT NULL UNIQUE, @@ -353,6 +467,8 @@ def db() -> sqlite3.Connection: )""") conn.execute("""CREATE TABLE IF NOT EXISTS runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, + organization_id INTEGER NOT NULL DEFAULT 1, + created_by INTEGER, query TEXT NOT NULL, location TEXT NOT NULL, requested INTEGER NOT NULL, @@ -366,10 +482,18 @@ def db() -> sqlite3.Connection: stage TEXT NOT NULL DEFAULT 'queued', progress INTEGER NOT NULL DEFAULT 0, completed_at TEXT NOT NULL DEFAULT '', - request_json TEXT NOT NULL DEFAULT '{}' + request_json TEXT NOT NULL DEFAULT '{}', + attempts INTEGER NOT NULL DEFAULT 1, + last_heartbeat_at TEXT NOT NULL DEFAULT '', + cancel_requested INTEGER NOT NULL DEFAULT 0, + retry_of INTEGER, + failure_count INTEGER NOT NULL DEFAULT 0, + recovery_state TEXT NOT NULL DEFAULT '' )""") conn.execute("""CREATE TABLE IF NOT EXISTS suppressions ( id INTEGER PRIMARY KEY AUTOINCREMENT, + organization_id INTEGER NOT NULL DEFAULT 1, + created_by INTEGER, kind TEXT NOT NULL, value TEXT NOT NULL, reason TEXT NOT NULL DEFAULT '', @@ -377,7 +501,7 @@ def db() -> sqlite3.Connection: UNIQUE(kind, value) )""") conn.execute("""CREATE TABLE IF NOT EXISTS schedules ( - id INTEGER PRIMARY KEY AUTOINCREMENT, category TEXT NOT NULL, keywords TEXT NOT NULL, + id INTEGER PRIMARY KEY AUTOINCREMENT, organization_id INTEGER NOT NULL DEFAULT 1, created_by INTEGER, category TEXT NOT NULL, keywords TEXT NOT NULL, location TEXT NOT NULL DEFAULT 'South Africa', max_results INTEGER NOT NULL DEFAULT 25, interval_hours INTEGER NOT NULL DEFAULT 24, enabled INTEGER NOT NULL DEFAULT 1, next_run_at TEXT NOT NULL, last_run_id INTEGER, created_at TEXT NOT NULL @@ -387,6 +511,7 @@ def db() -> sqlite3.Connection: )""") conn.execute("""CREATE TABLE IF NOT EXISTS website_candidates ( id INTEGER PRIMARY KEY AUTOINCREMENT, + organization_id INTEGER NOT NULL DEFAULT 1, prospect_id INTEGER NOT NULL, url TEXT NOT NULL, domain TEXT NOT NULL, @@ -400,6 +525,8 @@ def db() -> sqlite3.Connection: )""") conn.execute("""CREATE TABLE IF NOT EXISTS logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, + organization_id INTEGER NOT NULL DEFAULT 1, + user_id INTEGER, created_at TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'system', level TEXT NOT NULL DEFAULT 'info', @@ -415,6 +542,8 @@ def db() -> sqlite3.Connection: )""") conn.execute("""CREATE TABLE IF NOT EXISTS promotions ( id INTEGER PRIMARY KEY AUTOINCREMENT, + organization_id INTEGER NOT NULL DEFAULT 1, + created_by INTEGER, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', terms TEXT NOT NULL DEFAULT '', @@ -424,10 +553,82 @@ def db() -> sqlite3.Connection: created_at TEXT NOT NULL, updated_at TEXT NOT NULL )""") + conn.execute("""CREATE TABLE IF NOT EXISTS prospect_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + organization_id INTEGER NOT NULL DEFAULT 1, + prospect_id INTEGER NOT NULL, + user_id INTEGER, + body TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )""") + conn.execute("""CREATE TABLE IF NOT EXISTS prospect_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + organization_id INTEGER NOT NULL DEFAULT 1, + prospect_id INTEGER NOT NULL, + created_by INTEGER, + assigned_user_id INTEGER, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + due_at TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'open', + completed_at TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )""") + conn.execute("""CREATE TABLE IF NOT EXISTS score_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + organization_id INTEGER NOT NULL DEFAULT 1, + prospect_id INTEGER NOT NULL, + user_id INTEGER, + score_type TEXT NOT NULL DEFAULT 'opportunity', + score INTEGER NOT NULL DEFAULT 0, + reasons TEXT NOT NULL DEFAULT '[]', + model TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL + )""") + conn.execute("""CREATE TABLE IF NOT EXISTS ai_assessment_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + organization_id INTEGER NOT NULL DEFAULT 1, + prospect_id INTEGER NOT NULL, + user_id INTEGER, + probability INTEGER NOT NULL DEFAULT 0, + recommendation TEXT NOT NULL DEFAULT '', + reasoning TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL + )""") + conn.execute("""CREATE TABLE IF NOT EXISTS audit_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + organization_id INTEGER NOT NULL DEFAULT 1, + user_id INTEGER, + action TEXT NOT NULL, + entity_type TEXT NOT NULL DEFAULT '', + entity_id INTEGER, + details TEXT NOT NULL DEFAULT '{}', + ip_address TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL + )""") + conn.execute("""CREATE TABLE IF NOT EXISTS run_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + organization_id INTEGER NOT NULL DEFAULT 1, + run_id INTEGER NOT NULL, + user_id INTEGER, + stage TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT '', + progress INTEGER NOT NULL DEFAULT 0, + message TEXT NOT NULL DEFAULT '', + details TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL + )""") # Migrate databases created by v1-v5 without destroying the user's # catalogue. SQLite permits adding nullable/defaulted columns in place. migrations = { + "organization_id": "INTEGER NOT NULL DEFAULT 1", + "created_by": "INTEGER", + "assigned_user_id": "INTEGER", + "updated_at": "TEXT NOT NULL DEFAULT ''", "source": "TEXT NOT NULL DEFAULT 'google_maps'", "source_id": "TEXT NOT NULL DEFAULT ''", "category": "TEXT NOT NULL DEFAULT ''", @@ -473,17 +674,44 @@ def db() -> sqlite3.Connection: if name not in existing: conn.execute(f"ALTER TABLE prospects ADD COLUMN {name} {ddl}") run_migrations = { + "organization_id": "INTEGER NOT NULL DEFAULT 1", + "created_by": "INTEGER", "source": "TEXT NOT NULL DEFAULT 'google_maps'", "external_job_id": "TEXT NOT NULL DEFAULT ''", "stage": "TEXT NOT NULL DEFAULT 'queued'", "progress": "INTEGER NOT NULL DEFAULT 0", "completed_at": "TEXT NOT NULL DEFAULT ''", "request_json": "TEXT NOT NULL DEFAULT '{}'", + "attempts": "INTEGER NOT NULL DEFAULT 1", + "last_heartbeat_at": "TEXT NOT NULL DEFAULT ''", + "cancel_requested": "INTEGER NOT NULL DEFAULT 0", + "retry_of": "INTEGER", + "failure_count": "INTEGER NOT NULL DEFAULT 0", + "recovery_state": "TEXT NOT NULL DEFAULT ''", } existing_runs = {row[1] for row in conn.execute("PRAGMA table_info(runs)")} for name, ddl in run_migrations.items(): if name not in existing_runs: conn.execute(f"ALTER TABLE runs ADD COLUMN {name} {ddl}") + for table, fields in { + "suppressions": {"organization_id": "INTEGER NOT NULL DEFAULT 1", "created_by": "INTEGER"}, + "schedules": {"organization_id": "INTEGER NOT NULL DEFAULT 1", "created_by": "INTEGER"}, + "website_candidates": {"organization_id": "INTEGER NOT NULL DEFAULT 1"}, + "logs": {"organization_id": "INTEGER NOT NULL DEFAULT 1", "user_id": "INTEGER"}, + "promotions": {"organization_id": "INTEGER NOT NULL DEFAULT 1", "created_by": "INTEGER"}, + "prospect_notes": {"organization_id": "INTEGER NOT NULL DEFAULT 1"}, + "prospect_tasks": {"organization_id": "INTEGER NOT NULL DEFAULT 1"}, + "score_history": {"organization_id": "INTEGER NOT NULL DEFAULT 1"}, + "ai_assessment_history": {"organization_id": "INTEGER NOT NULL DEFAULT 1"}, + "audit_events": {"organization_id": "INTEGER NOT NULL DEFAULT 1"}, + "run_events": {"organization_id": "INTEGER NOT NULL DEFAULT 1"}, + }.items(): + existing_fields = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")} + for name, ddl in fields.items(): + if name not in existing_fields: + conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {ddl}") + for table in ("prospects", "runs", "suppressions", "schedules", "website_candidates", "logs", "promotions", "prospect_notes", "prospect_tasks", "score_history", "ai_assessment_history", "audit_events", "run_events"): + conn.execute(f"UPDATE {table} SET organization_id=1 WHERE organization_id IS NULL OR organization_id=0") conn.execute("CREATE INDEX IF NOT EXISTS idx_prospects_review ON prospects(review_status)") conn.execute("CREATE INDEX IF NOT EXISTS idx_prospects_score ON prospects(opportunity_score DESC)") conn.execute("CREATE INDEX IF NOT EXISTS idx_runs_created ON runs(id DESC)") @@ -491,21 +719,267 @@ def db() -> sqlite3.Connection: conn.execute("CREATE INDEX IF NOT EXISTS idx_logs_created ON logs(id DESC)") conn.execute("CREATE INDEX IF NOT EXISTS idx_logs_kind ON logs(kind, id DESC)") conn.execute("CREATE INDEX IF NOT EXISTS idx_logs_level ON logs(level, id DESC)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_users_org ON users(organization_id, active)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token_hash)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_prospects_org_score ON prospects(organization_id, opportunity_score DESC)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_prospects_assigned ON prospects(organization_id, assigned_user_id)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_notes_prospect ON prospect_notes(organization_id, prospect_id, id DESC)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_prospect ON prospect_tasks(organization_id, prospect_id, status, due_at)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_score_history_prospect ON score_history(organization_id, prospect_id, id DESC)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_ai_history_prospect ON ai_assessment_history(organization_id, prospect_id, id DESC)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_audit_org_created ON audit_events(organization_id, id DESC)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_run_events_run ON run_events(organization_id, run_id, id DESC)") conn.commit() _schema_path = DB_PATH _schema_lock.release() return conn +ROLE_ORDER = {"viewer": 10, "reviewer": 20, "researcher": 30, "admin": 40, "owner": 50} +ALLOWED_ROLES = set(ROLE_ORDER) + + +def _future_iso(seconds: int) -> str: + return datetime.fromtimestamp(time.time() + int(seconds), timezone.utc).replace(microsecond=0).isoformat() + + +def _expired(timestamp: str) -> bool: + try: + value = datetime.fromisoformat(str(timestamp).replace("Z", "+00:00")) + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value <= datetime.now(timezone.utc) + except (TypeError, ValueError): + return True + + +def default_workspace_id() -> int: + conn = db() + try: + row = conn.execute("SELECT id FROM organizations ORDER BY id LIMIT 1").fetchone() + return int(row[0]) if row else 1 + finally: + conn.close() + + +def ensure_owner_from_env() -> None: + """Optionally seed the first owner for unattended Docker deployments.""" + email = os.environ.get("OWNER_EMAIL", "").strip().lower() + password = os.environ.get("OWNER_PASSWORD", "") + if not email or not password: + return + conn = db() + try: + if conn.execute("SELECT 1 FROM users LIMIT 1").fetchone(): + return + name = os.environ.get("OWNER_NAME", "Workspace owner").strip()[:160] or "Workspace owner" + workspace = os.environ.get("WORKSPACE_NAME", WORKSPACE_NAME).strip()[:160] or WORKSPACE_NAME + conn.execute("UPDATE organizations SET name=?,updated_at=? WHERE id=1", (workspace, now())) + try: + hashed = password_hash(password) + except ValueError: + return + conn.execute("INSERT INTO users(organization_id,email,name,password_hash,role,active,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)", (1, email, name, hashed, "owner", 1, now(), now())) + conn.commit() + finally: + conn.close() + + +def authenticated_user(handler: BaseHTTPRequestHandler) -> dict[str, Any] | None: + """Resolve the signed-in user from the server-side session cookie.""" + if not AUTH_REQUIRED: + organization_id = default_workspace_id() + user = {"id": None, "organization_id": organization_id, "email": "legacy-local", "name": "Local owner", "role": "owner", "active": True} + handler._auth_user = user # type: ignore[attr-defined] + handler._organization_id = organization_id # type: ignore[attr-defined] + handler._user_id = None # type: ignore[attr-defined] + return user + cookie = SimpleCookie() + try: + cookie.load(handler.headers.get("Cookie", "")) + except Exception: + return None + token = cookie.get(AUTH_COOKIE_NAME) + if not token or not token.value: + return None + token_hash = _session_hash(token.value) + conn = db() + try: + row = conn.execute("""SELECT u.*,s.expires_at AS session_expires_at,s.csrf_token AS session_csrf_token,o.name AS organization_name,o.created_at AS organization_created_at + FROM sessions s JOIN users u ON u.id=s.user_id JOIN organizations o ON o.id=u.organization_id + WHERE s.token_hash=?""", (token_hash,)).fetchone() + if not row or not row["active"]: + return None + if _expired(row["session_expires_at"]): + conn.execute("DELETE FROM sessions WHERE token_hash=?", (token_hash,)); conn.commit() + return None + conn.execute("UPDATE sessions SET last_seen_at=? WHERE token_hash=?", (now(), token_hash)); conn.commit() + user = user_view(row) + user["organization"] = {"id": int(row["organization_id"]), "name": row["organization_name"]} + handler._session_token_hash = token_hash # type: ignore[attr-defined] + handler._csrf_token = str(row["session_csrf_token"] or "") # type: ignore[attr-defined] + handler._auth_user = user # type: ignore[attr-defined] + handler._organization_id = int(row["organization_id"]) # type: ignore[attr-defined] + handler._user_id = int(row["id"]) # type: ignore[attr-defined] + return user + finally: + conn.close() + + +def require_auth(handler: BaseHTTPRequestHandler, roles: set[str] | None = None) -> dict[str, Any] | None: + user = authenticated_user(handler) + if not user: + handler.send_json(401, {"error": "authentication_required"}) # type: ignore[attr-defined] + return None + if roles and str(user.get("role")) not in roles: + handler.send_json(403, {"error": "permission_denied", "required_roles": sorted(roles)}) # type: ignore[attr-defined] + return None + return user + + +def require_csrf(handler: BaseHTTPRequestHandler) -> bool: + """Require the per-session CSRF token for browser state changes.""" + if not AUTH_REQUIRED: + return True + expected = str(getattr(handler, "_csrf_token", "") or "") + provided = str(handler.headers.get("X-CSRF-Token", "") or "") + if expected and provided and hmac.compare_digest(expected, provided): + return True + handler.send_json(403, {"error": "csrf_token_required"}) # type: ignore[attr-defined] + return False + + +def same_org_user(conn: sqlite3.Connection, user_id: Any, organization_id: int) -> sqlite3.Row | None: + try: + value = int(user_id) + except (TypeError, ValueError): + return None + return conn.execute("SELECT id,email,name,role,active FROM users WHERE id=? AND organization_id=?", (value, organization_id)).fetchone() + + +def role_allows(user: dict[str, Any] | None, minimum: str) -> bool: + return bool(user and ROLE_ORDER.get(str(user.get("role")), 0) >= ROLE_ORDER.get(minimum, 999)) + + +def set_session_cookie(handler: BaseHTTPRequestHandler, token: str, max_age: int = AUTH_SESSION_TTL_SECONDS) -> None: + secure = "; Secure" if AUTH_COOKIE_SECURE or str(handler.headers.get("X-Forwarded-Proto", "")).lower() == "https" else "" + handler._set_cookie = f"{AUTH_COOKIE_NAME}={token}; Max-Age={int(max_age)}; Path=/; HttpOnly; SameSite=Lax{secure}" # type: ignore[attr-defined] + + +def clear_session_cookie(handler: BaseHTTPRequestHandler) -> None: + handler._set_cookie = f"{AUTH_COOKIE_NAME}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax" # type: ignore[attr-defined] + + +def create_session(handler: BaseHTTPRequestHandler, user_id: int) -> tuple[str, str]: + token = secrets.token_urlsafe(32) + csrf = secrets.token_urlsafe(24) + stamp = now() + conn = db() + try: + conn.execute("INSERT INTO sessions(user_id,token_hash,csrf_token,created_at,last_seen_at,expires_at,ip_address,user_agent) VALUES(?,?,?,?,?,?,?,?)", (user_id, _session_hash(token), csrf, stamp, stamp, _future_iso(AUTH_SESSION_TTL_SECONDS), str(handler.client_address[0] if handler.client_address else "")[:100], str(handler.headers.get("User-Agent", ""))[:300])) + conn.commit() + row = conn.execute("SELECT u.*,o.name AS organization_name FROM users u JOIN organizations o ON o.id=u.organization_id WHERE u.id=?", (user_id,)).fetchone() + finally: + conn.close() + handler._session_token_hash = _session_hash(token) # type: ignore[attr-defined] + handler._csrf_token = csrf # type: ignore[attr-defined] + if row: + value = user_view(row) + value["organization"] = {"id": int(row["organization_id"]), "name": row["organization_name"]} + handler._auth_user = value # type: ignore[attr-defined] + handler._organization_id = int(row["organization_id"]) # type: ignore[attr-defined] + handler._user_id = int(row["id"]) # type: ignore[attr-defined] + set_session_cookie(handler, token) + return token, csrf + + +def record_audit(action: str, *, organization_id: int | None = None, user_id: int | None = None, + entity_type: str = "", entity_id: int | None = None, details: Any = None, + ip_address: str = "") -> None: + try: + conn = db() + try: + encoded = json.dumps(_redact_log_value(details if details is not None else {}), ensure_ascii=False, default=str)[:20_000] + conn.execute("INSERT INTO audit_events(organization_id,user_id,action,entity_type,entity_id,details,ip_address,created_at) VALUES(?,?,?,?,?,?,?,?)", (int(organization_id or 1), user_id, str(action)[:200], str(entity_type)[:80], entity_id, encoded, str(ip_address)[:100], now())) + conn.commit() + finally: + conn.close() + except Exception: + return + + +def backup_directory() -> Path: + return Path(BACKUP_DIR or (Path(DB_PATH).parent / "backups")).resolve() + + +def list_backups() -> list[dict[str, Any]]: + directory = backup_directory() + if not directory.is_dir(): + return [] + items: list[dict[str, Any]] = [] + for path in sorted(directory.glob("prospects-*.db"), key=lambda item: item.stat().st_mtime, reverse=True): + try: + items.append({"name": path.name, "size_bytes": path.stat().st_size, "created_at": datetime.fromtimestamp(path.stat().st_mtime, timezone.utc).replace(microsecond=0).isoformat()}) + except OSError: + continue + return items + + +def create_backup() -> dict[str, Any]: + """Create an online SQLite backup and verify it before returning.""" + directory = backup_directory() + directory.mkdir(parents=True, exist_ok=True) + filename = "prospects-" + datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + ".db" + target = directory / filename + source = sqlite3.connect(DB_PATH, timeout=30) + destination = sqlite3.connect(str(target), timeout=30) + try: + source.backup(destination) + result = destination.execute("PRAGMA integrity_check").fetchone()[0] + if str(result).lower() != "ok": + raise RuntimeError("backup_integrity_check_failed") + destination.commit() + finally: + destination.close(); source.close() + backups = sorted(directory.glob("prospects-*.db"), key=lambda item: item.stat().st_mtime, reverse=True) + for old in backups[BACKUP_RETENTION:]: + try: old.unlink() + except OSError: pass + return {"name": filename, "size_bytes": target.stat().st_size, "created_at": datetime.fromtimestamp(target.stat().st_mtime, timezone.utc).replace(microsecond=0).isoformat()} + + +def maybe_auto_backup() -> None: + """Create a verified backup at most once per configured interval.""" + if not BACKUP_INTERVAL_HOURS: + return + items = list_backups() + if items: + try: + latest = datetime.fromisoformat(str(items[0]["created_at"]).replace("Z", "+00:00")) + if (datetime.now(timezone.utc) - latest).total_seconds() < BACKUP_INTERVAL_HOURS * 3600: + return + except (TypeError, ValueError): + pass + try: + backup = create_backup() + record_log("system", "backup_auto_created", details=backup) + except Exception as exc: + record_log("system", "backup_auto_failed", level="error", error=str(exc)[:500]) + + def recover_stale_runs() -> None: """Recover abandoned runs once at process startup, not on every request.""" conn = db() try: - conn.execute("""UPDATE runs SET status='failed', stage='failed', + stale = conn.execute("""SELECT id,organization_id,attempts FROM runs WHERE status IN ('queued','running') + AND julianday(CASE WHEN last_heartbeat_at='' THEN created_at ELSE last_heartbeat_at END) < julianday('now','-30 minutes')""").fetchall() + conn.execute("""UPDATE runs SET status='failed', stage='recovery_required', recovery_state='retryable', failure_count=failure_count+1, error=CASE WHEN error='' THEN 'stale_run_recovered' ELSE error END, completed_at=CASE WHEN completed_at='' THEN ? ELSE completed_at END WHERE status IN ('queued','running') - AND julianday(created_at) < julianday('now','-30 minutes')""", (now(),)) + AND julianday(CASE WHEN last_heartbeat_at='' THEN created_at ELSE last_heartbeat_at END) < julianday('now','-30 minutes')""", (now(),)) + for row in stale: + conn.execute("INSERT INTO run_events(organization_id,run_id,stage,status,progress,message,details,created_at) VALUES(?,?,?,?,?,?,?,?)", (row["organization_id"], row["id"], "recovery_required", "failed", 100, "Run recovered after an interrupted worker", json.dumps({"attempts": row["attempts"]}), now())) conn.commit() finally: conn.close() @@ -521,6 +995,8 @@ def persist_website_candidates(prospect_id: int, candidates: list[dict[str, Any] """ conn = db() try: + prospect = conn.execute("SELECT organization_id FROM prospects WHERE id=?", (prospect_id,)).fetchone() + organization_id = int(prospect["organization_id"] if prospect else 1) for candidate in candidates: url = str(candidate.get("url") or "").strip() domain = str(candidate.get("domain") or clean_domain(url)).lower().strip() @@ -532,19 +1008,19 @@ def persist_website_candidates(prospect_id: int, candidates: list[dict[str, Any] raw = dict(candidate) source = str(candidate.get("source") or "browser_use").strip()[:80] or "browser_use" conn.execute( - """INSERT INTO website_candidates(prospect_id,url,domain,confidence,evidence,source,status,discovered_at,raw_json) - VALUES(?,?,?,?,?,?,?,?,?) + """INSERT INTO website_candidates(organization_id,prospect_id,url,domain,confidence,evidence,source,status,discovered_at,raw_json) + VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(prospect_id,domain) DO UPDATE SET url=excluded.url, confidence=MAX(website_candidates.confidence, excluded.confidence), evidence=excluded.evidence, source=excluded.source, discovered_at=excluded.discovered_at, raw_json=excluded.raw_json, status=CASE WHEN website_candidates.status='accepted' THEN 'accepted' ELSE 'candidate' END""", - (prospect_id, url[:1000], domain[:255], max(0, min(100, as_int(candidate.get("confidence")))), + (organization_id, prospect_id, url[:1000], domain[:255], max(0, min(100, as_int(candidate.get("confidence")))), json.dumps(evidence[:12], ensure_ascii=False), source, "candidate", now(), json.dumps(raw, ensure_ascii=False, default=str)[:100_000]), ) conn.commit() - rows = conn.execute("SELECT * FROM website_candidates WHERE prospect_id=? ORDER BY confidence DESC, id DESC", (prospect_id,)).fetchall() + rows = conn.execute("SELECT * FROM website_candidates WHERE prospect_id=? AND organization_id=? ORDER BY confidence DESC, id DESC", (prospect_id, organization_id)).fetchall() return [dict(item) for item in rows] finally: conn.close() @@ -1205,14 +1681,18 @@ def validate_payload(payload: dict[str, Any]) -> tuple[str, str, str, int, bool, def update_run(run_id: int, **fields: Any) -> None: if not fields: return - allowed = {"found", "added", "status", "error", "source", "external_job_id", "stage", "progress", "completed_at"} + allowed = {"found", "added", "status", "error", "source", "external_job_id", "stage", "progress", "completed_at", "attempts", "last_heartbeat_at", "cancel_requested", "failure_count", "recovery_state"} safe = {key: value for key, value in fields.items() if key in allowed} if not safe: return + safe.setdefault("last_heartbeat_at", now()) assignments = ", ".join(f"{key}=?" for key in safe) conn = db() try: + row = conn.execute("SELECT organization_id,status,stage,progress FROM runs WHERE id=?", (run_id,)).fetchone() conn.execute(f"UPDATE runs SET {assignments} WHERE id=?", (*safe.values(), run_id)) + if row and any(key in safe for key in ("stage", "status", "progress", "error")): + conn.execute("INSERT INTO run_events(organization_id,run_id,stage,status,progress,message,details,created_at) VALUES(?,?,?,?,?,?,?,?)", (row["organization_id"], run_id, str(safe.get("stage", row["stage"])), str(safe.get("status", row["status"])), int(safe.get("progress", row["progress"]) or 0), str(safe.get("error") or ""), json.dumps({key: value for key, value in safe.items() if key != "last_heartbeat_at"}, ensure_ascii=False, default=str)[:20_000], now())) conn.commit() finally: conn.close() @@ -1222,6 +1702,15 @@ def update_run(run_id: int, **fields: Any) -> None: error=str(safe.get("error") or "")) +def run_cancel_requested(run_id: int) -> bool: + conn = db() + try: + row = conn.execute("SELECT cancel_requested FROM runs WHERE id=?", (run_id,)).fetchone() + return bool(row and row[0]) + finally: + conn.close() + + def _json_list(value: Any) -> list[Any]: try: parsed = json.loads(str(value or "[]")) @@ -1284,8 +1773,11 @@ def insert_catalogue(run_id: int, query: str, raw_results: list[dict[str, Any]], conn = db() added = 0 try: - suppressed_domains = {str(row[0]).lower() for row in conn.execute("SELECT value FROM suppressions WHERE kind='domain'")} - suppressed_emails = {str(row[0]).lower() for row in conn.execute("SELECT value FROM suppressions WHERE kind='email'")} + run_row = conn.execute("SELECT organization_id,created_by FROM runs WHERE id=?", (run_id,)).fetchone() + organization_id = int(run_row["organization_id"] if run_row else 1) + created_by = run_row["created_by"] if run_row else None + suppressed_domains = {str(row[0]).lower() for row in conn.execute("SELECT value FROM suppressions WHERE organization_id=? AND kind='domain'", (organization_id,))} + suppressed_emails = {str(row[0]).lower() for row in conn.execute("SELECT value FROM suppressions WHERE organization_id=? AND kind='email'", (organization_id,))} total = len(mapped) for index, item in enumerate(mapped, 1): # A hostile or slow public site must not invalidate an otherwise @@ -1309,14 +1801,14 @@ def insert_catalogue(run_id: int, query: str, raw_results: list[dict[str, Any]], continue try: cur = conn.execute("""INSERT OR IGNORE INTO prospects( - name,website,domain,description,query,discovered_at,source,source_id, + organization_id,created_by,name,website,domain,description,query,discovered_at,source,source_id, category,search_category,address,phone,emails,rating,review_count,maps_url,latitude, longitude,listing_status,website_status,website_http_status,website_final_url, website_latency_ms,website_https,website_title,website_technology,website_has_viewport, website_tls_expires_at,website_checked_at,website_error,email_evidence,score_reasons, ai_probability,ai_recommendation,ai_reasoning,ai_checked_at,ai_subject,ai_message,ai_offer,opportunity_score,raw_json - ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( - item["name"], item["website"], item["domain"], item["description"], item["query"], now(), + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( + organization_id, created_by, item["name"], item["website"], item["domain"], item["description"], item["query"], now(), item["source"], item["source_id"], item["category"], item.get("search_category", ""), item["address"], item["phone"], item["emails"], item["rating"], item["review_count"], item["maps_url"], item["latitude"], item["longitude"], item.get("listing_status", ""), item.get("website_status", "unknown"), item.get("website_http_status"), @@ -1332,8 +1824,10 @@ def insert_catalogue(run_id: int, query: str, raw_results: list[dict[str, Any]], continue if cur.rowcount: added += 1 + prospect_id = int(cur.lastrowid) + conn.execute("INSERT INTO score_history(organization_id,prospect_id,user_id,score_type,score,reasons,model,created_at) VALUES(?,?,?,?,?,?,?,?)", (organization_id, prospect_id, created_by, "opportunity", int(item.get("opportunity_score") or 0), item.get("score_reasons", "[]"), "deterministic", now())) conn.commit() - record_log("system", "prospect_added", run_id=run_id, details={"name": item.get("name"), "domain": item.get("domain"), "score": item.get("opportunity_score")}) + record_log("system", "prospect_added", run_id=run_id, prospect_id=prospect_id, organization_id=organization_id, user_id=created_by, details={"name": item.get("name"), "domain": item.get("domain"), "score": item.get("opportunity_score")}) else: conn.commit() record_log("system", "prospect_duplicate", run_id=run_id, details={"name": item.get("name"), "domain": item.get("domain")}) @@ -1363,6 +1857,10 @@ def run_discovery(run_id: int, payload: dict[str, Any]) -> None: else: update_run(run_id, stage="browser_search", progress=20) raw_results = google_search(query, requested, run_id=run_id) + if run_cancel_requested(run_id): + update_run(run_id, status="cancelled", stage="cancelled", progress=100, completed_at=now(), error="cancelled_by_user") + record_log("search", "discovery_cancelled", run_id=run_id) + return update_run(run_id, stage="website_analysis", progress=76, found=len(raw_results)) def enrichment_progress(index: int, total: int) -> None: @@ -1370,6 +1868,10 @@ def run_discovery(run_id: int, payload: dict[str, Any]) -> None: update_run(run_id, stage="website_analysis", progress=76 + int(index * 19 / total), found=index) results, added = insert_catalogue(run_id, query, raw_results, requested, enrichment_progress, search_category=category) + if run_cancel_requested(run_id): + update_run(run_id, status="cancelled", stage="cancelled", progress=100, completed_at=now(), error="cancelled_by_user") + record_log("search", "discovery_cancelled", run_id=run_id) + return update_run(run_id, found=len(results), added=added, status="succeeded", stage="complete", progress=100, completed_at=now(), error="") record_log("search", "discovery_completed", run_id=run_id, details={"found": len(results), "added": added, "backend": backend_mode()}) except Exception as exc: @@ -1378,7 +1880,7 @@ def run_discovery(run_id: int, payload: dict[str, Any]) -> None: record_log("search", "discovery_failed", level="error", run_id=run_id, details={"backend": backend_mode()}, error=message) -def create_discovery(payload: dict[str, Any]) -> dict[str, Any]: +def create_discovery(payload: dict[str, Any], organization_id: int | None = None, created_by: int | None = None, retry_of: int | None = None) -> dict[str, Any]: category, query, location, requested, include_email, extra_reviews = validate_payload(payload) request_json = json.dumps({ "category": category, @@ -1390,9 +1892,11 @@ def create_discovery(payload: dict[str, Any]) -> dict[str, Any]: }, ensure_ascii=False) conn = db() try: - cur = conn.execute("""INSERT INTO runs(query,location,requested,status,created_at,source,stage,request_json) - VALUES(?,?,?,?,?,?,?,?)""", (query, location, requested, "queued", now(), backend_mode(), "queued", request_json)) + org = int(organization_id or 1) + cur = conn.execute("""INSERT INTO runs(organization_id,created_by,query,location,requested,status,created_at,source,stage,request_json,attempts,last_heartbeat_at,retry_of) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""", (org, created_by, query, location, requested, "queued", now(), backend_mode(), "queued", request_json, 1, now(), retry_of)) run_id = int(cur.lastrowid) + conn.execute("INSERT INTO run_events(organization_id,run_id,user_id,stage,status,progress,message,details,created_at) VALUES(?,?,?,?,?,?,?,?,?)", (org, run_id, created_by, "queued", "queued", 0, "Run queued", "{}", now())) conn.commit() finally: conn.close() @@ -1404,10 +1908,11 @@ def create_discovery(payload: dict[str, Any]) -> dict[str, Any]: def scheduler_loop() -> None: while True: try: + maybe_auto_backup() conn = db(); due = conn.execute("SELECT * FROM schedules WHERE enabled=1 AND next_run_at<=?", (now(),)).fetchall() for row in due: payload = {"category": row["category"], "keywords": row["keywords"], "location": row["location"], "max_results": row["max_results"], "extra_reviews": False} - run = create_discovery(payload) + run = create_discovery(payload, organization_id=int(row["organization_id"] or 1), created_by=row["created_by"]) conn.execute("UPDATE schedules SET next_run_at=?,last_run_id=? WHERE id=?", (datetime.now(timezone.utc).replace(microsecond=0).timestamp() and datetime.fromtimestamp(datetime.now(timezone.utc).timestamp()+int(row["interval_hours"])*3600,timezone.utc).isoformat(), run["id"], row["id"])) conn.commit(); conn.close() except Exception: @@ -1638,6 +2143,10 @@ class Handler(BaseHTTPRequestHandler): self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Cache-Control", "no-store") + pending_cookie = getattr(self, "_set_cookie", "") + if pending_cookie: + self.send_header("Set-Cookie", pending_cookie) + self._set_cookie = "" self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) @@ -1649,6 +2158,7 @@ class Handler(BaseHTTPRequestHandler): path=urlparse(self.path).path, status=status, duration_ms=int((time.monotonic() - getattr(self, "_request_started_at", time.monotonic())) * 1000), details={"response_status": status}, error=str(error_value or "")[:500], + organization_id=getattr(self, "_organization_id", 1), user_id=getattr(self, "_user_id", None), ) def read_json(self) -> dict[str, Any]: @@ -1663,6 +2173,25 @@ class Handler(BaseHTTPRequestHandler): if not parsed.path.startswith("/api/"): self.serve_static(parsed.path) return + if parsed.path == "/api/auth/status": + user = authenticated_user(self) + conn = db() + try: + setup_required = not bool(conn.execute("SELECT 1 FROM users WHERE active=1 LIMIT 1").fetchone()) + workspace = conn.execute("SELECT * FROM organizations ORDER BY id LIMIT 1").fetchone() + return self.send_json(200, {"authenticated": bool(user), "setup_required": setup_required, "user": user_view(user) if user else None, "workspace": workspace_view(workspace)}) + finally: + conn.close() + if parsed.path == "/api/auth/setup": + conn = db() + try: + return self.send_json(200, {"setup_required": not bool(conn.execute("SELECT 1 FROM users WHERE active=1 LIMIT 1").fetchone())}) + finally: + conn.close() + if not public_auth_path(parsed.path) and not require_auth(self): + return + if parsed.path == "/api/auth/me": + return self.send_json(200, {"user": getattr(self, "_auth_user", None), "csrf_token": getattr(self, "_csrf_token", "")}) conn = db() try: if parsed.path == "/api/health": @@ -1702,8 +2231,8 @@ class Handler(BaseHTTPRequestHandler): limit = max(1, min(500, int(params.get("limit", ["200"])[0]))) except (TypeError, ValueError): limit = 200 - clauses: list[str] = [] - values: list[Any] = [] + clauses: list[str] = ["organization_id=?"] + values: list[Any] = [getattr(self, "_organization_id", 1)] if kind and kind != "all": clauses.append("kind=?"); values.append(kind) if level and level != "all": @@ -1722,8 +2251,8 @@ class Handler(BaseHTTPRequestHandler): item["details"] = {"raw": str(item.get("details") or "")[:20_000]} items.append(item) total = int(conn.execute("SELECT COUNT(*) FROM logs" + where, tuple(values)).fetchone()[0]) - counts = {str(row[0]): int(row[1]) for row in conn.execute("SELECT kind,COUNT(*) FROM logs GROUP BY kind")} - error_count = int(conn.execute("SELECT COUNT(*) FROM logs WHERE level='error'").fetchone()[0]) + counts = {str(row[0]): int(row[1]) for row in conn.execute("SELECT kind,COUNT(*) FROM logs WHERE organization_id=? GROUP BY kind", (getattr(self, "_organization_id", 1),))} + error_count = int(conn.execute("SELECT COUNT(*) FROM logs WHERE organization_id=? AND level='error'", (getattr(self, "_organization_id", 1),)).fetchone()[0]) counts["errors"] = error_count return self.send_json(200, {"items": items, "total": total, "counts": counts}) if parsed.path == "/api/settings": @@ -1749,7 +2278,7 @@ class Handler(BaseHTTPRequestHandler): params = parse_qs(parsed.query) q = (params.get("q", [""])[0]).strip().lower() status = params.get("status", [""])[0].strip().lower() - rows = conn.execute("SELECT * FROM prospects ORDER BY opportunity_score DESC, id DESC").fetchall() + rows = conn.execute("SELECT * FROM prospects WHERE organization_id=? ORDER BY opportunity_score DESC, id DESC", (getattr(self, "_organization_id", 1),)).fetchall() items = [] for row in rows: haystack = " ".join(str(row[key] or "") for key in ("name", "domain", "description", "address", "category", "search_category", "phone", "emails")).lower() @@ -1761,50 +2290,103 @@ class Handler(BaseHTTPRequestHandler): return self.send_json(200, {"items": items, "total": len(items)}) if parsed.path == "/api/export.csv": params = parse_qs(parsed.query); q = params.get("q", [""])[0].strip().lower() - rows = [dict(r) for r in conn.execute("SELECT * FROM prospects ORDER BY opportunity_score DESC, id DESC")] + rows = [dict(r) for r in conn.execute("SELECT * FROM prospects WHERE organization_id=? ORDER BY opportunity_score DESC, id DESC", (getattr(self, "_organization_id", 1),))] if q: rows = [r for r in rows if q in " ".join(str(r.get(k) or "") for k in ("name","domain","address","category","search_category","emails")).lower()] out = io.StringIO(); fields = ["id","name","search_category","category","address","phone","emails","domain","website","website_status","opportunity_score","score_reasons","review_status","last_seen"] writer = csv.DictWriter(out, fieldnames=fields); writer.writeheader(); writer.writerows({k:r.get(k) for k in fields} for r in rows) body = out.getvalue().encode(); self.send_response(200); self.send_header("Content-Type","text/csv; charset=utf-8"); self.send_header("Content-Disposition","attachment; filename=prospects.csv"); self.send_header("Content-Length",str(len(body))); self.end_headers(); self.wfile.write(body); return if parsed.path == "/api/suppressions": - return self.send_json(200, {"items": [dict(r) for r in conn.execute("SELECT * FROM suppressions ORDER BY id DESC")]}) + return self.send_json(200, {"items": [dict(r) for r in conn.execute("SELECT * FROM suppressions WHERE organization_id=? ORDER BY id DESC", (getattr(self, "_organization_id", 1),))]}) if parsed.path == "/api/schedules": - return self.send_json(200, {"items": [dict(r) for r in conn.execute("SELECT * FROM schedules ORDER BY id DESC")]}) + return self.send_json(200, {"items": [dict(r) for r in conn.execute("SELECT * FROM schedules WHERE organization_id=? ORDER BY id DESC", (getattr(self, "_organization_id", 1),))]}) if parsed.path == "/api/promotions": - rows = conn.execute("SELECT * FROM promotions ORDER BY active DESC, updated_at DESC, id DESC").fetchall() + rows = conn.execute("SELECT * FROM promotions WHERE organization_id=? ORDER BY active DESC, updated_at DESC, id DESC", (getattr(self, "_organization_id", 1),)).fetchall() return self.send_json(200, {"items": [dict(row) for row in rows]}) + if parsed.path == "/api/workspace": + organization_id = getattr(self, "_organization_id", 1) + workspace = conn.execute("SELECT * FROM organizations WHERE id=?", (organization_id,)).fetchone() + counts = {"users": int(conn.execute("SELECT COUNT(*) FROM users WHERE organization_id=?", (organization_id,)).fetchone()[0]), "prospects": int(conn.execute("SELECT COUNT(*) FROM prospects WHERE organization_id=?", (organization_id,)).fetchone()[0])} + return self.send_json(200, {"workspace": workspace_view(workspace), "counts": counts}) + if parsed.path == "/api/users": + rows = conn.execute("SELECT id,organization_id,email,name,role,active,last_login_at,created_at,updated_at FROM users WHERE organization_id=? ORDER BY name,email", (getattr(self, "_organization_id", 1),)).fetchall() + return self.send_json(200, {"items": [user_view(row) for row in rows]}) + if parsed.path == "/api/audit": + if not role_allows(getattr(self, "_auth_user", None), "admin"): + return self.send_json(403, {"error": "permission_denied"}) + params = parse_qs(parsed.query) + try: + limit = max(1, min(500, int(params.get("limit", ["200"])[0]))) + except (TypeError, ValueError): + limit = 200 + rows = conn.execute("""SELECT a.*,u.email AS user_email,u.name AS user_name + FROM audit_events a LEFT JOIN users u ON u.id=a.user_id + WHERE a.organization_id=? ORDER BY a.id DESC LIMIT ?""", (getattr(self, "_organization_id", 1), limit)).fetchall() + items = [] + for row in rows: + item = dict(row) + try: item["details"] = json.loads(item.get("details") or "{}") + except (TypeError, ValueError, json.JSONDecodeError): pass + items.append(item) + return self.send_json(200, {"items": items}) + if parsed.path == "/api/backups": + if not role_allows(getattr(self, "_auth_user", None), "admin"): + return self.send_json(403, {"error": "permission_denied"}) + return self.send_json(200, {"items": list_backups()}) match = re.fullmatch(r"/api/prospects/(\d+)/website-candidates", parsed.path) if match: prospect_id = int(match.group(1)) - if not conn.execute("SELECT 1 FROM prospects WHERE id=?", (prospect_id,)).fetchone(): + if not conn.execute("SELECT 1 FROM prospects WHERE id=? AND organization_id=?", (prospect_id, getattr(self, "_organization_id", 1))).fetchone(): return self.send_json(404, {"error": "not_found"}) - rows = conn.execute("SELECT * FROM website_candidates WHERE prospect_id=? ORDER BY confidence DESC, id DESC", (prospect_id,)).fetchall() + rows = conn.execute("SELECT * FROM website_candidates WHERE prospect_id=? AND organization_id=? ORDER BY confidence DESC, id DESC", (prospect_id, getattr(self, "_organization_id", 1))).fetchall() return self.send_json(200, {"items": [dict(row) for row in rows]}) + match = re.fullmatch(r"/api/prospects/(\d+)/(notes|tasks|history)", parsed.path) + if match: + prospect_id, resource, organization_id = int(match.group(1)), match.group(2), getattr(self, "_organization_id", 1) + if not conn.execute("SELECT 1 FROM prospects WHERE id=? AND organization_id=?", (prospect_id, organization_id)).fetchone(): + return self.send_json(404, {"error": "not_found"}) + if resource == "notes": + rows = conn.execute("""SELECT n.*,u.email AS user_email,u.name AS user_name + FROM prospect_notes n LEFT JOIN users u ON u.id=n.user_id + WHERE n.prospect_id=? AND n.organization_id=? ORDER BY n.id DESC""", (prospect_id, organization_id)).fetchall() + return self.send_json(200, {"items": [dict(row) for row in rows]}) + if resource == "tasks": + rows = conn.execute("""SELECT t.*,u.email AS assigned_email,c.email AS created_by_email + FROM prospect_tasks t LEFT JOIN users u ON u.id=t.assigned_user_id LEFT JOIN users c ON c.id=t.created_by + WHERE t.prospect_id=? AND t.organization_id=? ORDER BY CASE t.status WHEN 'open' THEN 0 ELSE 1 END,t.due_at,t.id DESC""", (prospect_id, organization_id)).fetchall() + return self.send_json(200, {"items": [dict(row) for row in rows]}) + scores = [dict(row) for row in conn.execute("SELECT * FROM score_history WHERE prospect_id=? AND organization_id=? ORDER BY id DESC LIMIT 100", (prospect_id, organization_id)).fetchall()] + assessments = [dict(row) for row in conn.execute("SELECT * FROM ai_assessment_history WHERE prospect_id=? AND organization_id=? ORDER BY id DESC LIMIT 100", (prospect_id, organization_id)).fetchall()] + return self.send_json(200, {"score_history": scores, "ai_history": assessments}) match = re.fullmatch(r"/api/prospects/(\d+)/domains", parsed.path) if match: - row = conn.execute("SELECT * FROM prospects WHERE id=?", (int(match.group(1)),)).fetchone() + row = conn.execute("SELECT * FROM prospects WHERE id=? AND organization_id=?", (int(match.group(1)), getattr(self, "_organization_id", 1))).fetchone() if not row: return self.send_json(404, {"error":"not_found"}) return self.send_json(200, {"items": domain_candidates(row["name"], row["address"], row["category"])}) match = re.fullmatch(r"/api/prospects/(\d+)", parsed.path) if match: - row = conn.execute("SELECT * FROM prospects WHERE id=?", (int(match.group(1)),)).fetchone() + row = conn.execute("SELECT * FROM prospects WHERE id=? AND organization_id=?", (int(match.group(1)), getattr(self, "_organization_id", 1))).fetchone() if not row: return self.send_json(404, {"error":"not_found"}) return self.send_json(200, dict(row)) if parsed.path == "/api/runs": - rows = [dict(row) for row in conn.execute("SELECT * FROM runs ORDER BY id DESC LIMIT 50")] + rows = [dict(row) for row in conn.execute("SELECT * FROM runs WHERE organization_id=? ORDER BY id DESC LIMIT 50", (getattr(self, "_organization_id", 1),))] return self.send_json(200, {"items": rows}) match = re.fullmatch(r"/api/runs/(\d+)", parsed.path) if match: - row = conn.execute("SELECT * FROM runs WHERE id=?", (int(match.group(1)),)).fetchone() + row = conn.execute("SELECT * FROM runs WHERE id=? AND organization_id=?", (int(match.group(1)), getattr(self, "_organization_id", 1))).fetchone() if not row: return self.send_json(404, {"error": "not_found"}) return self.send_json(200, dict(row)) + match = re.fullmatch(r"/api/runs/(\d+)/events", parsed.path) + if match: + rows = conn.execute("SELECT * FROM run_events WHERE run_id=? AND organization_id=? ORDER BY id DESC LIMIT 200", (int(match.group(1)), getattr(self, "_organization_id", 1))).fetchall() + return self.send_json(200, {"items": [dict(row) for row in rows]}) if parsed.path == "/api/stats": - total = conn.execute("SELECT COUNT(*) FROM prospects").fetchone()[0] - pending = conn.execute("SELECT COUNT(*) FROM prospects WHERE review_status='pending'").fetchone()[0] - approved = conn.execute("SELECT COUNT(*) FROM prospects WHERE review_status='approved'").fetchone()[0] - contacted = conn.execute("SELECT COUNT(*) FROM prospects WHERE review_status='contacted'").fetchone()[0] - running = conn.execute("SELECT COUNT(*) FROM runs WHERE status IN ('queued','running')").fetchone()[0] + organization_id = getattr(self, "_organization_id", 1) + total = conn.execute("SELECT COUNT(*) FROM prospects WHERE organization_id=?", (organization_id,)).fetchone()[0] + pending = conn.execute("SELECT COUNT(*) FROM prospects WHERE organization_id=? AND review_status='pending'", (organization_id,)).fetchone()[0] + approved = conn.execute("SELECT COUNT(*) FROM prospects WHERE organization_id=? AND review_status='approved'", (organization_id,)).fetchone()[0] + contacted = conn.execute("SELECT COUNT(*) FROM prospects WHERE organization_id=? AND review_status='contacted'", (organization_id,)).fetchone()[0] + running = conn.execute("SELECT COUNT(*) FROM runs WHERE organization_id=? AND status IN ('queued','running')", (organization_id,)).fetchone()[0] return self.send_json(200, {"total": total, "pending": pending, "approved": approved, "contacted": contacted, "running": running}) return self.send_json(404, {"error": "not_found"}) finally: @@ -1833,7 +2415,88 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(content) def do_POST(self) -> None: - if urlparse(self.path).path == "/api/logs": + path = urlparse(self.path).path + if path == "/api/auth/setup": + conn: sqlite3.Connection | None = None + try: + payload = self.read_json() + conn = db() + if conn.execute("SELECT 1 FROM users WHERE active=1 LIMIT 1").fetchone(): + return self.send_json(409, {"error": "owner_already_configured"}) + email = str(payload.get("email") or "").strip().lower() + name = str(payload.get("name") or "").strip()[:160] or "Workspace owner" + password = str(payload.get("password") or "") + workspace_name = str(payload.get("workspace_name") or WORKSPACE_NAME).strip()[:160] or WORKSPACE_NAME + if not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", email): + return self.send_json(400, {"error": "valid_email_required"}) + hashed = password_hash(password) + stamp = now() + conn.execute("UPDATE organizations SET name=?,updated_at=? WHERE id=1", (workspace_name, stamp)) + cur = conn.execute("INSERT INTO users(organization_id,email,name,password_hash,role,active,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)", (1, email, name, hashed, "owner", 1, stamp, stamp)) + conn.commit() + user_id = int(cur.lastrowid) + conn.close(); conn = None + _, csrf = create_session(self, user_id) + user = getattr(self, "_auth_user", None) + record_audit("owner_setup_completed", organization_id=1, user_id=user_id, entity_type="user", entity_id=user_id, ip_address=str(self.client_address[0] if self.client_address else "")) + return self.send_json(201, {"authenticated": True, "user": user, "csrf_token": csrf}) + except sqlite3.IntegrityError: + return self.send_json(409, {"error": "email_already_registered"}) + except (ValueError, TypeError, json.JSONDecodeError) as exc: + return self.send_json(400, {"error": str(exc)}) + finally: + if conn is not None: + conn.close() + if path == "/api/auth/login": + conn: sqlite3.Connection | None = None + try: + payload = self.read_json() + email = str(payload.get("email") or "").strip().lower() + password = str(payload.get("password") or "") + conn = db() + row = conn.execute("SELECT * FROM users WHERE email=? ORDER BY id LIMIT 1", (email,)).fetchone() + if not row or not row["active"] or (row["locked_until"] and not _expired(row["locked_until"])): + return self.send_json(401, {"error": "invalid_email_or_password"}) + if not password_matches(password, row["password_hash"]): + failures = int(row["failed_login_count"] or 0) + 1 + lock_until = _future_iso(900) if failures >= 5 else "" + conn.execute("UPDATE users SET failed_login_count=?,locked_until=?,updated_at=? WHERE id=?", (failures, lock_until, now(), row["id"])) + conn.commit() + return self.send_json(401, {"error": "invalid_email_or_password"}) + conn.execute("UPDATE users SET failed_login_count=0,locked_until='',last_login_at=?,updated_at=? WHERE id=?", (now(), now(), row["id"])) + conn.commit() + user_id = int(row["id"]) + conn.close(); conn = None + _, csrf = create_session(self, user_id) + user = getattr(self, "_auth_user", None) + record_audit("login_succeeded", organization_id=int(row["organization_id"]), user_id=user_id, entity_type="user", entity_id=user_id, ip_address=str(self.client_address[0] if self.client_address else "")) + return self.send_json(200, {"authenticated": True, "user": user, "csrf_token": csrf}) + except (ValueError, TypeError, json.JSONDecodeError) as exc: + return self.send_json(400, {"error": str(exc)}) + finally: + if conn is not None: + conn.close() + if path == "/api/auth/logout": + user = require_auth(self) + if not user: + return + if not require_csrf(self): + return + token_hash = getattr(self, "_session_token_hash", "") + if token_hash: + conn = db() + try: + conn.execute("DELETE FROM sessions WHERE token_hash=?", (token_hash,)); conn.commit() + finally: + conn.close() + record_audit("logout", organization_id=int(user["organization_id"]), user_id=user.get("id"), entity_type="user", entity_id=user.get("id"), ip_address=str(self.client_address[0] if self.client_address else "")) + clear_session_cookie(self) + return self.send_json(200, {"logged_out": True}) + if not public_auth_path(path) and not require_auth(self): + return + if path not in {"/api/auth/login", "/api/auth/setup"} and not require_csrf(self): + return + if path == "/api/logs": try: payload = self.read_json() action = str(payload.get("action") or "").strip() @@ -1844,11 +2507,66 @@ class Handler(BaseHTTPRequestHandler): level = "info" details = payload.get("details") if isinstance(payload.get("details"), (dict, list, str, int, float, bool)) else {} record_log("action", action, level=level, method="CLIENT", path=str(payload.get("path") or "")[:500], details=details, - error=str(payload.get("error") or "")[:500]) + error=str(payload.get("error") or "")[:500], organization_id=getattr(self, "_organization_id", 1), user_id=getattr(self, "_user_id", None)) return self.send_json(202, {"logged": True}) except (ValueError, TypeError, json.JSONDecodeError) as exc: return self.send_json(400, {"error": str(exc)}) + if path == "/api/users": + if not role_allows(getattr(self, "_auth_user", None), "admin"): + return self.send_json(403, {"error": "permission_denied"}) + conn: sqlite3.Connection | None = None + try: + payload = self.read_json() + email = str(payload.get("email") or "").strip().lower() + name = str(payload.get("name") or "").strip()[:160] + password = str(payload.get("password") or "") + role = str(payload.get("role") or "reviewer").strip().lower() + if not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", email): + return self.send_json(400, {"error": "valid_email_required"}) + if role not in ALLOWED_ROLES or role == "owner": + return self.send_json(400, {"error": "invalid_role"}) + if not name: + return self.send_json(400, {"error": "name_required"}) + hashed = password_hash(password) + organization_id = int(getattr(self, "_organization_id", 1)); stamp = now() + conn = db() + cur = conn.execute("INSERT INTO users(organization_id,email,name,password_hash,role,active,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)", (organization_id, email, name, hashed, role, 1, stamp, stamp)) + conn.commit(); user_id = int(cur.lastrowid) + row = conn.execute("SELECT id,organization_id,email,name,role,active,last_login_at,created_at,updated_at FROM users WHERE id=?", (user_id,)).fetchone() + record_audit("user_created", organization_id=organization_id, user_id=getattr(self, "_user_id", None), entity_type="user", entity_id=user_id, details={"email": email, "role": role}) + return self.send_json(201, user_view(row)) + except sqlite3.IntegrityError: + return self.send_json(409, {"error": "email_already_registered"}) + except (ValueError, TypeError, json.JSONDecodeError) as exc: + return self.send_json(400, {"error": str(exc)}) + finally: + if conn is not None: conn.close() + if path == "/api/backups": + if not role_allows(getattr(self, "_auth_user", None), "admin"): + return self.send_json(403, {"error": "permission_denied"}) + try: + backup = create_backup() + record_audit("backup_created", organization_id=getattr(self, "_organization_id", 1), user_id=getattr(self, "_user_id", None), entity_type="backup", details=backup) + return self.send_json(201, backup) + except Exception as exc: + record_log("system", "backup_failed", level="error", organization_id=getattr(self, "_organization_id", 1), user_id=getattr(self, "_user_id", None), error=str(exc)[:500]) + return self.send_json(500, {"error": "backup_failed", "detail": str(exc)[:300]}) + workspace_patch = path == "/api/workspace" + if workspace_patch: + if not role_allows(getattr(self, "_auth_user", None), "admin"): + return self.send_json(403, {"error": "permission_denied"}) + try: + payload = self.read_json(); name = str(payload.get("name") or "").strip()[:160] + if not name: return self.send_json(400, {"error": "workspace_name_required"}) + conn = db(); conn.execute("UPDATE organizations SET name=?,updated_at=? WHERE id=?", (name, now(), getattr(self, "_organization_id", 1))); conn.commit() + row = conn.execute("SELECT * FROM organizations WHERE id=?", (getattr(self, "_organization_id", 1),)).fetchone(); conn.close() + record_audit("workspace_updated", organization_id=getattr(self, "_organization_id", 1), user_id=getattr(self, "_user_id", None), entity_type="workspace", entity_id=getattr(self, "_organization_id", 1), details={"name": name}) + return self.send_json(200, {"workspace": workspace_view(row)}) + except Exception as exc: + return self.send_json(400, {"error": str(exc)}) if self.path == "/api/settings": + if not role_allows(getattr(self, "_auth_user", None), "admin"): + return self.send_json(403, {"error": "permission_denied"}) try: payload = self.read_json() ai = payload.get("ai") if isinstance(payload.get("ai"), dict) else {} @@ -1902,6 +2620,7 @@ class Handler(BaseHTTPRequestHandler): conn.commit() conn.close() record_log("action", "settings_updated", details={"keys": saved_keys}) + record_audit("settings_updated", organization_id=getattr(self, "_organization_id", 1), user_id=getattr(self, "_user_id", None), entity_type="settings", details={"keys": saved_keys}) ai_runtime = ai_runtime_settings() return self.send_json(200, { "saved": True, @@ -1914,7 +2633,100 @@ class Handler(BaseHTTPRequestHandler): }) except Exception as exc: return self.send_json(400, {"error": str(exc)}) + assignment_match = re.fullmatch(r"/api/prospects/(\d+)/assignment", path) + if assignment_match: + if not role_allows(getattr(self, "_auth_user", None), "researcher"): + return self.send_json(403, {"error": "permission_denied"}) + conn: sqlite3.Connection | None = None + try: + prospect_id = int(assignment_match.group(1)); payload = self.read_json(); assignee = payload.get("assigned_user_id") + organization_id = int(getattr(self, "_organization_id", 1)); conn = db() + prospect = conn.execute("SELECT id FROM prospects WHERE id=? AND organization_id=?", (prospect_id, organization_id)).fetchone() + if not prospect: return self.send_json(404, {"error": "not_found"}) + if assignee in (None, "", 0, "0"): assigned_id = None + else: + assigned = same_org_user(conn, assignee, organization_id) + if not assigned or not assigned["active"]: return self.send_json(400, {"error": "assigned_user_not_found"}) + assigned_id = int(assigned["id"]) + conn.execute("UPDATE prospects SET assigned_user_id=?,updated_at=? WHERE id=? AND organization_id=?", (assigned_id, now(), prospect_id, organization_id)); conn.commit() + row = conn.execute("SELECT * FROM prospects WHERE id=? AND organization_id=?", (prospect_id, getattr(self, "_organization_id", 1))).fetchone() + record_audit("prospect_assigned", organization_id=organization_id, user_id=getattr(self, "_user_id", None), entity_type="prospect", entity_id=prospect_id, details={"assigned_user_id": assigned_id}) + return self.send_json(200, dict(row)) + except (ValueError, TypeError, json.JSONDecodeError) as exc: return self.send_json(400, {"error": str(exc)}) + finally: + if conn is not None: conn.close() + note_match = re.fullmatch(r"/api/prospects/(\d+)/notes", path) + if note_match: + if not role_allows(getattr(self, "_auth_user", None), "reviewer"): return self.send_json(403, {"error": "permission_denied"}) + conn: sqlite3.Connection | None = None + try: + prospect_id = int(note_match.group(1)); payload = self.read_json(); body = str(payload.get("body") or "").strip()[:10_000] + if not body: return self.send_json(400, {"error": "note_body_required"}) + organization_id = int(getattr(self, "_organization_id", 1)); conn = db() + if not conn.execute("SELECT 1 FROM prospects WHERE id=? AND organization_id=?", (prospect_id, organization_id)).fetchone(): return self.send_json(404, {"error": "not_found"}) + stamp = now(); cur = conn.execute("INSERT INTO prospect_notes(organization_id,prospect_id,user_id,body,created_at,updated_at) VALUES(?,?,?,?,?,?)", (organization_id, prospect_id, getattr(self, "_user_id", None), body, stamp, stamp)); conn.commit() + row = conn.execute("SELECT * FROM prospect_notes WHERE id=?", (cur.lastrowid,)).fetchone() + record_audit("prospect_note_created", organization_id=organization_id, user_id=getattr(self, "_user_id", None), entity_type="prospect", entity_id=prospect_id) + return self.send_json(201, dict(row)) + except (ValueError, TypeError, json.JSONDecodeError) as exc: return self.send_json(400, {"error": str(exc)}) + finally: + if conn is not None: conn.close() + task_match = re.fullmatch(r"/api/prospects/(\d+)/tasks", path) + if task_match: + if not role_allows(getattr(self, "_auth_user", None), "reviewer"): return self.send_json(403, {"error": "permission_denied"}) + conn: sqlite3.Connection | None = None + try: + prospect_id = int(task_match.group(1)); payload = self.read_json(); title = str(payload.get("title") or "").strip()[:240] + if not title: return self.send_json(400, {"error": "task_title_required"}) + organization_id = int(getattr(self, "_organization_id", 1)); conn = db() + if not conn.execute("SELECT 1 FROM prospects WHERE id=? AND organization_id=?", (prospect_id, organization_id)).fetchone(): return self.send_json(404, {"error": "not_found"}) + assigned_id = payload.get("assigned_user_id") + if assigned_id not in (None, "", 0, "0"): + assigned = same_org_user(conn, assigned_id, organization_id) + if not assigned or not assigned["active"]: return self.send_json(400, {"error": "assigned_user_not_found"}) + assigned_id = int(assigned["id"]) + else: assigned_id = None + status = str(payload.get("status") or "open").strip().lower() + if status not in {"open", "in_progress", "done", "cancelled"}: status = "open" + stamp = now(); cur = conn.execute("INSERT INTO prospect_tasks(organization_id,prospect_id,created_by,assigned_user_id,title,description,due_at,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)", (organization_id, prospect_id, getattr(self, "_user_id", None), assigned_id, title, str(payload.get("description") or "").strip()[:10_000], str(payload.get("due_at") or "").strip()[:60], status, stamp, stamp)); conn.commit() + row = conn.execute("SELECT * FROM prospect_tasks WHERE id=?", (cur.lastrowid,)).fetchone() + record_audit("prospect_task_created", organization_id=organization_id, user_id=getattr(self, "_user_id", None), entity_type="prospect", entity_id=prospect_id, details={"task_id": int(cur.lastrowid)}) + return self.send_json(201, dict(row)) + except (ValueError, TypeError, json.JSONDecodeError) as exc: return self.send_json(400, {"error": str(exc)}) + finally: + if conn is not None: conn.close() + retry_match = re.fullmatch(r"/api/runs/(\d+)/retry", path) + if retry_match: + if not role_allows(getattr(self, "_auth_user", None), "researcher"): return self.send_json(403, {"error": "permission_denied"}) + conn: sqlite3.Connection | None = None + try: + run_id = int(retry_match.group(1)); organization_id = int(getattr(self, "_organization_id", 1)); conn = db(); row = conn.execute("SELECT * FROM runs WHERE id=? AND organization_id=?", (run_id, organization_id)).fetchone() + if not row: return self.send_json(404, {"error": "not_found"}) + if row["status"] not in {"failed", "cancelled"} and row["recovery_state"] != "retryable": return self.send_json(400, {"error": "run_not_retryable"}) + try: payload = json.loads(row["request_json"] or "{}") + except (TypeError, ValueError, json.JSONDecodeError): return self.send_json(400, {"error": "run_request_missing"}) + conn.close(); conn = None + result = create_discovery(payload, organization_id=organization_id, created_by=getattr(self, "_user_id", None), retry_of=run_id) + record_audit("run_retried", organization_id=organization_id, user_id=getattr(self, "_user_id", None), entity_type="run", entity_id=run_id, details={"new_run_id": result["id"]}) + return self.send_json(202, result) + except (ValueError, TypeError, json.JSONDecodeError) as exc: return self.send_json(400, {"error": str(exc)}) + finally: + if conn is not None: conn.close() + cancel_match = re.fullmatch(r"/api/runs/(\d+)/cancel", path) + if cancel_match: + if not role_allows(getattr(self, "_auth_user", None), "researcher"): return self.send_json(403, {"error": "permission_denied"}) + run_id = int(cancel_match.group(1)); conn = db() + try: + row = conn.execute("SELECT * FROM runs WHERE id=? AND organization_id=?", (run_id, getattr(self, "_organization_id", 1))).fetchone() + if not row: return self.send_json(404, {"error": "not_found"}) + if row["status"] in {"succeeded", "failed", "cancelled"}: return self.send_json(400, {"error": "run_not_active"}) + conn.execute("UPDATE runs SET cancel_requested=1 WHERE id=? AND organization_id=?", (run_id, getattr(self, "_organization_id", 1))); conn.commit() + record_audit("run_cancel_requested", organization_id=getattr(self, "_organization_id", 1), user_id=getattr(self, "_user_id", None), entity_type="run", entity_id=run_id) + return self.send_json(202, {"id": run_id, "cancel_requested": True}) + finally: conn.close() if urlparse(self.path).path == "/api/company-profile/learn": + if not role_allows(getattr(self, "_auth_user", None), "admin"): + return self.send_json(403, {"error": "permission_denied"}) conn: sqlite3.Connection | None = None try: payload = self.read_json() @@ -1936,6 +2748,8 @@ class Handler(BaseHTTPRequestHandler): if conn is not None: conn.close() if self.path == "/api/promotions": + if not role_allows(getattr(self, "_auth_user", None), "admin"): + return self.send_json(403, {"error": "permission_denied"}) conn: sqlite3.Connection | None = None try: payload = self.read_json() @@ -1946,10 +2760,10 @@ class Handler(BaseHTTPRequestHandler): active = 0 if str(payload.get("active", True)).strip().lower() in {"0", "false", "no", "off"} else 1 stamp = now() conn = db() - cur = conn.execute("INSERT INTO promotions(name,description,terms,starts_at,ends_at,active,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)", (name, description, str(payload.get("terms") or "").strip()[:2_000], str(payload.get("starts_at") or "").strip()[:40], str(payload.get("ends_at") or "").strip()[:40], active, stamp, stamp)) + cur = conn.execute("INSERT INTO promotions(organization_id,created_by,name,description,terms,starts_at,ends_at,active,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)", (getattr(self, "_organization_id", 1), getattr(self, "_user_id", None), name, description, str(payload.get("terms") or "").strip()[:2_000], str(payload.get("starts_at") or "").strip()[:40], str(payload.get("ends_at") or "").strip()[:40], active, stamp, stamp)) conn.commit() row = conn.execute("SELECT * FROM promotions WHERE id=?", (cur.lastrowid,)).fetchone() - record_log("action", "promotion_created", details={"promotion_id": int(cur.lastrowid), "name": name}) + record_audit("promotion_created", organization_id=getattr(self, "_organization_id", 1), user_id=getattr(self, "_user_id", None), entity_type="promotion", entity_id=int(cur.lastrowid), details={"name": name}) return self.send_json(201, dict(row)) except (ValueError, TypeError, json.JSONDecodeError) as exc: return self.send_json(400, {"error": str(exc)}) @@ -1977,10 +2791,12 @@ class Handler(BaseHTTPRequestHandler): return self.send_json(502, {"error": "ai_test_failed:" + str(exc)[:200]}) candidates_match = re.fullmatch(r"/api/prospects/(\d+)/website-candidates", urlparse(self.path).path) if candidates_match: + if not role_allows(getattr(self, "_auth_user", None), "researcher"): + return self.send_json(403, {"error": "permission_denied"}) prospect_id = int(candidates_match.group(1)) conn = db() try: - row = conn.execute("SELECT * FROM prospects WHERE id=?", (prospect_id,)).fetchone() + row = conn.execute("SELECT * FROM prospects WHERE id=? AND organization_id=?", (prospect_id, getattr(self, "_organization_id", 1))).fetchone() finally: conn.close() if not row: @@ -2049,19 +2865,25 @@ class Handler(BaseHTTPRequestHandler): pass assess_match = re.fullmatch(r"/api/prospects/(\d+)/assess", urlparse(self.path).path) if assess_match: + if not role_allows(getattr(self, "_auth_user", None), "reviewer"): + return self.send_json(403, {"error": "permission_denied"}) conn = None try: conn = db(); prospect_id = int(assess_match.group(1)) - row = conn.execute("SELECT * FROM prospects WHERE id=?", (prospect_id,)).fetchone() + row = conn.execute("SELECT * FROM prospects WHERE id=? AND organization_id=?", (prospect_id, getattr(self, "_organization_id", 1))).fetchone() if not row: return self.send_json(404, {"error": "not_found"}) assessment = ai_assess_prospect(dict(row)) stamp = now() conn.execute( - "UPDATE prospects SET ai_probability=?,ai_recommendation=?,ai_reasoning=?,ai_checked_at=? WHERE id=?", - (assessment["ai_probability"], assessment["ai_recommendation"], assessment["ai_reasoning"], stamp, prospect_id), + "UPDATE prospects SET ai_probability=?,ai_recommendation=?,ai_reasoning=?,ai_checked_at=?,updated_at=? WHERE id=? AND organization_id=?", + (assessment["ai_probability"], assessment["ai_recommendation"], assessment["ai_reasoning"], stamp, stamp, prospect_id, getattr(self, "_organization_id", 1)), ) + runtime = ai_runtime_settings() + conn.execute("INSERT INTO ai_assessment_history(organization_id,prospect_id,user_id,probability,recommendation,reasoning,model,created_at) VALUES(?,?,?,?,?,?,?,?)", (getattr(self, "_organization_id", 1), prospect_id, getattr(self, "_user_id", None), int(assessment["ai_probability"] or 0), assessment["ai_recommendation"], assessment["ai_reasoning"], runtime["model"], stamp)) + conn.execute("INSERT INTO score_history(organization_id,prospect_id,user_id,score_type,score,reasons,model,created_at) VALUES(?,?,?,?,?,?,?,?)", (getattr(self, "_organization_id", 1), prospect_id, getattr(self, "_user_id", None), "ai", int(assessment["ai_probability"] or 0), json.dumps([assessment["ai_reasoning"]], ensure_ascii=False), runtime["model"], stamp)) conn.commit() + record_audit("prospect_ai_assessed", organization_id=getattr(self, "_organization_id", 1), user_id=getattr(self, "_user_id", None), entity_type="prospect", entity_id=prospect_id, details={"probability": assessment["ai_probability"], "recommendation": assessment["ai_recommendation"]}) return self.send_json(200, {**assessment, "ai_checked_at": stamp, "prospect_id": prospect_id}) except RuntimeError as exc: return self.send_json(503, {"error": str(exc)}) @@ -2072,9 +2894,11 @@ class Handler(BaseHTTPRequestHandler): conn.close() message_match = re.fullmatch(r"/api/prospects/(\d+)/message", urlparse(self.path).path) if message_match: + if not role_allows(getattr(self, "_auth_user", None), "reviewer"): + return self.send_json(403, {"error": "permission_denied"}) conn = None try: - conn = db(); row = conn.execute("SELECT * FROM prospects WHERE id=?", (int(message_match.group(1)),)).fetchone() + conn = db(); row = conn.execute("SELECT * FROM prospects WHERE id=? AND organization_id=?", (int(message_match.group(1)), getattr(self, "_organization_id", 1))).fetchone() if not row: return self.send_json(404, {"error":"not_found"}) payload = self.read_json(); offer = str(payload.get("offer") or "").strip() if not offer: return self.send_json(400, {"error":"offer_required"}) @@ -2085,7 +2909,7 @@ class Handler(BaseHTTPRequestHandler): promotion_id = int(promotion_id) except (TypeError, ValueError): return self.send_json(400, {"error": "invalid_promotion_id"}) - promotion_row = conn.execute("SELECT * FROM promotions WHERE id=?", (promotion_id,)).fetchone() + promotion_row = conn.execute("SELECT * FROM promotions WHERE id=? AND organization_id=?", (promotion_id, getattr(self, "_organization_id", 1))).fetchone() if not promotion_row: return self.send_json(404, {"error": "promotion_not_found"}) if not promotion_row["active"]: @@ -2094,7 +2918,8 @@ class Handler(BaseHTTPRequestHandler): else: promotion_id = None draft = ai_generate_message(dict(row), offer, tone=str(payload.get("tone") or "warm"), goal=str(payload.get("goal") or "conversation"), promotion=promotion) - stamp = now(); conn.execute("UPDATE prospects SET ai_subject=?,ai_message=?,ai_offer=?,ai_promotion_id=?,ai_reasoning=?,ai_unknowns=?,ai_evidence_ids=?,ai_follow_up=?,ai_checked_at=? WHERE id=?", (draft["subject"],draft["message"],offer,promotion_id,draft["rationale"],json.dumps(draft.get("unknowns") or [], ensure_ascii=False),json.dumps(draft.get("evidence_used") or [], ensure_ascii=False),draft.get("follow_up") or "",stamp,int(message_match.group(1)))); conn.commit() + stamp = now(); conn.execute("UPDATE prospects SET ai_subject=?,ai_message=?,ai_offer=?,ai_promotion_id=?,ai_reasoning=?,ai_unknowns=?,ai_evidence_ids=?,ai_follow_up=?,ai_checked_at=?,updated_at=? WHERE id=? AND organization_id=?", (draft["subject"],draft["message"],offer,promotion_id,draft["rationale"],json.dumps(draft.get("unknowns") or [], ensure_ascii=False),json.dumps(draft.get("evidence_used") or [], ensure_ascii=False),draft.get("follow_up") or "",stamp,stamp,int(message_match.group(1)),getattr(self, "_organization_id", 1))); conn.commit() + record_audit("prospect_message_generated", organization_id=getattr(self, "_organization_id", 1), user_id=getattr(self, "_user_id", None), entity_type="prospect", entity_id=int(message_match.group(1)), details={"promotion_id": promotion_id}) return self.send_json(200, {**draft,"ai_checked_at":stamp,"prospect_id":int(message_match.group(1))}) except RuntimeError as exc: return self.send_json(503, {"error":str(exc)}) except (ValueError, TypeError, json.JSONDecodeError) as exc: return self.send_json(400, {"error":str(exc)}) @@ -2102,31 +2927,39 @@ class Handler(BaseHTTPRequestHandler): if conn is not None: conn.close() scan_match = re.fullmatch(r"/api/prospects/(\d+)/scan", urlparse(self.path).path) if scan_match: + if not role_allows(getattr(self, "_auth_user", None), "reviewer"): + return self.send_json(403, {"error": "permission_denied"}) conn = None try: - conn=db(); row=conn.execute("SELECT * FROM prospects WHERE id=?",(int(scan_match.group(1)),)).fetchone() + conn=db(); row=conn.execute("SELECT * FROM prospects WHERE id=? AND organization_id=?",(int(scan_match.group(1)), getattr(self, "_organization_id", 1))).fetchone() if not row: return self.send_json(404,{"error":"not_found"}) updated=enrich_place(dict(row)); cols=["website_status","website_http_status","website_final_url","website_latency_ms","website_https","website_title","website_technology","website_has_viewport","website_tls_expires_at","website_checked_at","website_error","emails","email_evidence","score_reasons","opportunity_score"] - conn.execute("UPDATE prospects SET "+",".join(c+"=?" for c in cols)+" WHERE id=?",tuple(updated.get(c) for c in cols)+(int(scan_match.group(1)),)); conn.commit(); return self.send_json(200,dict(conn.execute("SELECT * FROM prospects WHERE id=?",(int(scan_match.group(1)),)).fetchone())) + prospect_id = int(scan_match.group(1)); conn.execute("UPDATE prospects SET "+",".join(c+"=?" for c in cols)+",updated_at=? WHERE id=? AND organization_id=?",tuple(updated.get(c) for c in cols)+(now(), prospect_id, getattr(self, "_organization_id", 1))); conn.execute("INSERT INTO score_history(organization_id,prospect_id,user_id,score_type,score,reasons,model,created_at) VALUES(?,?,?,?,?,?,?,?)", (getattr(self, "_organization_id", 1), prospect_id, getattr(self, "_user_id", None), "opportunity", int(updated.get("opportunity_score") or 0), updated.get("score_reasons", "[]"), "website_scan", now())); conn.commit(); record_audit("prospect_scanned", organization_id=getattr(self, "_organization_id", 1), user_id=getattr(self, "_user_id", None), entity_type="prospect", entity_id=prospect_id); return self.send_json(200,dict(conn.execute("SELECT * FROM prospects WHERE id=? AND organization_id=?",(prospect_id, getattr(self, "_organization_id", 1))).fetchone())) except Exception as exc: return self.send_json(500,{"error":str(exc)}) finally: if conn is not None: conn.close() if self.path == "/api/suppressions": + if not role_allows(getattr(self, "_auth_user", None), "admin"): + return self.send_json(403, {"error": "permission_denied"}) try: p=self.read_json(); kind=str(p.get("kind","domain")).lower(); value=str(p.get("value","")).strip().lower() if kind not in {"domain","email"} or not value: return self.send_json(400,{"error":"kind_and_value_required"}) - conn=db(); conn.execute("INSERT OR IGNORE INTO suppressions(kind,value,reason,created_at) VALUES(?,?,?,?)",(kind,value,str(p.get("reason","manual"))[:200],now())); conn.commit(); row=conn.execute("SELECT * FROM suppressions WHERE kind=? AND value=?",(kind,value)).fetchone(); conn.close(); return self.send_json(201,dict(row)) + conn=db(); conn.execute("INSERT OR IGNORE INTO suppressions(organization_id,created_by,kind,value,reason,created_at) VALUES(?,?,?,?,?,?)",(getattr(self, "_organization_id", 1), getattr(self, "_user_id", None), kind,value,str(p.get("reason","manual"))[:200],now())); conn.commit(); row=conn.execute("SELECT * FROM suppressions WHERE organization_id=? AND kind=? AND value=?",(getattr(self, "_organization_id", 1),kind,value)).fetchone(); conn.close(); record_audit("suppression_created", organization_id=getattr(self, "_organization_id", 1), user_id=getattr(self, "_user_id", None), entity_type="suppression", entity_id=int(row["id"]) if row else None, details={"kind":kind,"value":value}); return self.send_json(201,dict(row)) except Exception as exc: return self.send_json(400,{"error":str(exc)}) if self.path == "/api/schedules": + if not role_allows(getattr(self, "_auth_user", None), "researcher"): + return self.send_json(403, {"error": "permission_denied"}) try: p=self.read_json(); category=str(p.get("category") or "").strip(); keywords=str(p.get("keywords") or "").strip(); location=str(p.get("location") or "South Africa").strip(); interval=max(1,min(168,int(p.get("interval_hours",24)))); max_results=max(1,min(MAX_RESULTS,int(p.get("max_results",25)))) if not category or not keywords: return self.send_json(400,{"error":"category_and_keywords_required"}) - next_at=datetime.fromtimestamp(datetime.now(timezone.utc).timestamp()+interval*3600,timezone.utc).isoformat(); conn=db(); cur=conn.execute("INSERT INTO schedules(category,keywords,location,max_results,interval_hours,enabled,next_run_at,created_at) VALUES(?,?,?,?,?,?,?,?)",(category,keywords,location,max_results,interval,1,next_at,now())); conn.commit(); row=conn.execute("SELECT * FROM schedules WHERE id=?",(cur.lastrowid,)).fetchone(); conn.close(); return self.send_json(201,dict(row)) + next_at=datetime.fromtimestamp(datetime.now(timezone.utc).timestamp()+interval*3600,timezone.utc).isoformat(); conn=db(); cur=conn.execute("INSERT INTO schedules(organization_id,created_by,category,keywords,location,max_results,interval_hours,enabled,next_run_at,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)",(getattr(self, "_organization_id", 1), getattr(self, "_user_id", None), category,keywords,location,max_results,interval,1,next_at,now())); conn.commit(); row=conn.execute("SELECT * FROM schedules WHERE id=?",(cur.lastrowid,)).fetchone(); conn.close(); return self.send_json(201,dict(row)) except Exception as exc: return self.send_json(400,{"error":str(exc)}) if self.path != "/api/discover": return self.send_json(404, {"error": "not_found"}) + if not role_allows(getattr(self, "_auth_user", None), "researcher"): + return self.send_json(403, {"error": "permission_denied"}) try: - result = create_discovery(self.read_json()) + result = create_discovery(self.read_json(), organization_id=getattr(self, "_organization_id", 1), created_by=getattr(self, "_user_id", None)) return self.send_json(202, result) except ValueError as exc: return self.send_json(400, {"error": str(exc)}) @@ -2134,13 +2967,80 @@ class Handler(BaseHTTPRequestHandler): return self.send_json(400, {"error": str(exc)}) def do_PATCH(self) -> None: + if not require_auth(self): + return + if not require_csrf(self): + return + user_match = re.fullmatch(r"/api/users/(\d+)", urlparse(self.path).path) + if user_match: + if not role_allows(getattr(self, "_auth_user", None), "admin"): return self.send_json(403, {"error": "permission_denied"}) + conn: sqlite3.Connection | None = None + try: + user_id = int(user_match.group(1)); payload = self.read_json(); organization_id = int(getattr(self, "_organization_id", 1)); conn = db() + existing = conn.execute("SELECT * FROM users WHERE id=? AND organization_id=?", (user_id, organization_id)).fetchone() + if not existing: return self.send_json(404, {"error": "not_found"}) + fields: dict[str, Any] = {} + if "name" in payload: + name = str(payload.get("name") or "").strip()[:160] + if not name: return self.send_json(400, {"error": "name_required"}) + fields["name"] = name + if "role" in payload: + role = str(payload.get("role") or "").strip().lower() + if role not in ALLOWED_ROLES or role == "owner": return self.send_json(400, {"error": "invalid_role"}) + fields["role"] = role + if "active" in payload: fields["active"] = 1 if bool(payload.get("active")) else 0 + if "password" in payload and str(payload.get("password") or ""): + fields["password_hash"] = password_hash(str(payload.get("password"))) + if existing["role"] == "owner" and (fields.get("role") != "owner" or fields.get("active") == 0): + owner_count = conn.execute("SELECT COUNT(*) FROM users WHERE organization_id=? AND role='owner' AND active=1", (organization_id,)).fetchone()[0] + if owner_count <= 1: return self.send_json(400, {"error": "last_owner_required"}) + if not fields: return self.send_json(400, {"error": "no_changes"}) + fields["updated_at"] = now(); conn.execute("UPDATE users SET " + ",".join(f"{key}=?" for key in fields) + " WHERE id=? AND organization_id=?", (*fields.values(), user_id, organization_id)); conn.commit() + row = conn.execute("SELECT id,organization_id,email,name,role,active,last_login_at,created_at,updated_at FROM users WHERE id=?", (user_id,)).fetchone() + record_audit("user_updated", organization_id=organization_id, user_id=getattr(self, "_user_id", None), entity_type="user", entity_id=user_id, details={"fields": list(fields)}) + return self.send_json(200, user_view(row)) + except (ValueError, TypeError, json.JSONDecodeError) as exc: return self.send_json(400, {"error": str(exc)}) + finally: + if conn is not None: conn.close() + task_match = re.fullmatch(r"/api/prospects/(\d+)/tasks/(\d+)", urlparse(self.path).path) + if task_match: + if not role_allows(getattr(self, "_auth_user", None), "reviewer"): return self.send_json(403, {"error": "permission_denied"}) + conn: sqlite3.Connection | None = None + try: + prospect_id, task_id = int(task_match.group(1)), int(task_match.group(2)); payload = self.read_json(); organization_id = int(getattr(self, "_organization_id", 1)); conn = db() + existing = conn.execute("SELECT * FROM prospect_tasks WHERE id=? AND prospect_id=? AND organization_id=?", (task_id, prospect_id, organization_id)).fetchone() + if not existing: return self.send_json(404, {"error": "not_found"}) + fields: dict[str, Any] = {} + for key in ("title", "description", "due_at"): + if key in payload: fields[key] = str(payload.get(key) or "").strip()[:10_000 if key != "title" else 240] + if "title" in fields and not fields["title"]: return self.send_json(400, {"error": "task_title_required"}) + if "status" in payload: + status = str(payload.get("status") or "open").strip().lower() + if status not in {"open", "in_progress", "done", "cancelled"}: return self.send_json(400, {"error": "invalid_task_status"}) + fields["status"] = status; fields["completed_at"] = now() if status == "done" else "" + if "assigned_user_id" in payload: + assignee = payload.get("assigned_user_id") + if assignee in (None, "", 0, "0"): fields["assigned_user_id"] = None + else: + assigned = same_org_user(conn, assignee, organization_id) + if not assigned or not assigned["active"]: return self.send_json(400, {"error": "assigned_user_not_found"}) + fields["assigned_user_id"] = int(assigned["id"]) + if not fields: return self.send_json(400, {"error": "no_changes"}) + fields["updated_at"] = now(); conn.execute("UPDATE prospect_tasks SET " + ",".join(f"{key}=?" for key in fields) + " WHERE id=?", (*fields.values(), task_id)); conn.commit() + row = conn.execute("SELECT * FROM prospect_tasks WHERE id=?", (task_id,)).fetchone(); record_audit("prospect_task_updated", organization_id=organization_id, user_id=getattr(self, "_user_id", None), entity_type="prospect", entity_id=prospect_id, details={"task_id": task_id, "fields": list(fields)}) + return self.send_json(200, dict(row)) + except (ValueError, TypeError, json.JSONDecodeError) as exc: return self.send_json(400, {"error": str(exc)}) + finally: + if conn is not None: conn.close() promotion_match = re.fullmatch(r"/api/promotions/(\d+)", urlparse(self.path).path) if promotion_match: + if not role_allows(getattr(self, "_auth_user", None), "admin"): + return self.send_json(403, {"error": "permission_denied"}) conn: sqlite3.Connection | None = None try: payload = self.read_json() conn = db() - existing = conn.execute("SELECT * FROM promotions WHERE id=?", (int(promotion_match.group(1)),)).fetchone() + existing = conn.execute("SELECT * FROM promotions WHERE id=? AND organization_id=?", (int(promotion_match.group(1)), getattr(self, "_organization_id", 1))).fetchone() if not existing: return self.send_json(404, {"error": "not_found"}) fields: dict[str, Any] = {} @@ -2155,10 +3055,10 @@ class Handler(BaseHTTPRequestHandler): fields["active"] = 0 if str(payload.get("active")).strip().lower() in {"0", "false", "no", "off"} else 1 fields["updated_at"] = now() assignments = ",".join(f"{key}=?" for key in fields) - conn.execute("UPDATE promotions SET " + assignments + " WHERE id=?", (*fields.values(), int(promotion_match.group(1)))) + conn.execute("UPDATE promotions SET " + assignments + " WHERE id=? AND organization_id=?", (*fields.values(), int(promotion_match.group(1)), getattr(self, "_organization_id", 1))) conn.commit() - row = conn.execute("SELECT * FROM promotions WHERE id=?", (int(promotion_match.group(1)),)).fetchone() - record_log("action", "promotion_updated", details={"promotion_id": int(promotion_match.group(1)), "fields": list(fields)}) + row = conn.execute("SELECT * FROM promotions WHERE id=? AND organization_id=?", (int(promotion_match.group(1)), getattr(self, "_organization_id", 1))).fetchone() + record_audit("promotion_updated", organization_id=getattr(self, "_organization_id", 1), user_id=getattr(self, "_user_id", None), entity_type="promotion", entity_id=int(promotion_match.group(1)), details={"fields": list(fields)}) return self.send_json(200, dict(row)) except (ValueError, TypeError, json.JSONDecodeError) as exc: return self.send_json(400, {"error": str(exc)}) @@ -2167,6 +3067,8 @@ class Handler(BaseHTTPRequestHandler): conn.close() candidate_match = re.fullmatch(r"/api/prospects/(\d+)/website-candidates/(\d+)", urlparse(self.path).path) if candidate_match: + if not role_allows(getattr(self, "_auth_user", None), "researcher"): + return self.send_json(403, {"error": "permission_denied"}) prospect_id, candidate_id = (int(candidate_match.group(1)), int(candidate_match.group(2))) conn: sqlite3.Connection | None = None try: @@ -2176,24 +3078,25 @@ class Handler(BaseHTTPRequestHandler): return self.send_json(400, {"error": "invalid_website_candidate_status"}) conn = db() candidate = conn.execute( - "SELECT * FROM website_candidates WHERE id=? AND prospect_id=?", - (candidate_id, prospect_id), + "SELECT * FROM website_candidates WHERE id=? AND prospect_id=? AND organization_id=?", + (candidate_id, prospect_id, getattr(self, "_organization_id", 1)), ).fetchone() - prospect = conn.execute("SELECT * FROM prospects WHERE id=?", (prospect_id,)).fetchone() + prospect = conn.execute("SELECT * FROM prospects WHERE id=? AND organization_id=?", (prospect_id, getattr(self, "_organization_id", 1))).fetchone() if not candidate or not prospect: return self.send_json(404, {"error": "not_found"}) if status != "accepted": conn.execute("UPDATE website_candidates SET status=? WHERE id=?", (status, candidate_id)) conn.commit() updated_candidate = conn.execute("SELECT * FROM website_candidates WHERE id=?", (candidate_id,)).fetchone() + record_audit("website_candidate_reviewed", organization_id=getattr(self, "_organization_id", 1), user_id=getattr(self, "_user_id", None), entity_type="prospect", entity_id=prospect_id, details={"candidate_id": candidate_id, "status": status}) return self.send_json(200, {"candidate": dict(updated_candidate)}) # The candidate is now a real catalogue domain. Check the # catalogue before scanning so SQLite's unique domain index # produces an actionable response instead of a generic 500. duplicate = conn.execute( - "SELECT id FROM prospects WHERE domain=? AND id<>?", - (str(candidate["domain"]).lower(), prospect_id), + "SELECT id FROM prospects WHERE domain=? AND id<>? AND organization_id=?", + (str(candidate["domain"]).lower(), prospect_id, getattr(self, "_organization_id", 1)), ).fetchone() if duplicate: return self.send_json(409, {"error": "domain_already_catalogued", "prospect_id": int(duplicate["id"])}) @@ -2227,6 +3130,7 @@ class Handler(BaseHTTPRequestHandler): conn.commit() updated_candidate = conn.execute("SELECT * FROM website_candidates WHERE id=?", (candidate_id,)).fetchone() updated_prospect = conn.execute("SELECT * FROM prospects WHERE id=?", (prospect_id,)).fetchone() + record_audit("website_candidate_accepted", organization_id=getattr(self, "_organization_id", 1), user_id=getattr(self, "_user_id", None), entity_type="prospect", entity_id=prospect_id, details={"candidate_id": candidate_id, "domain": candidate["domain"]}) return self.send_json(200, {"candidate": dict(updated_candidate), "prospect": dict(updated_prospect)}) except sqlite3.IntegrityError: if conn is not None: @@ -2242,6 +3146,8 @@ class Handler(BaseHTTPRequestHandler): match = re.fullmatch(r"/api/prospects/(\d+)/review", urlparse(self.path).path) if not match: return self.send_json(404, {"error": "not_found"}) + if not role_allows(getattr(self, "_auth_user", None), "reviewer"): + return self.send_json(403, {"error": "permission_denied"}) conn: sqlite3.Connection | None = None try: payload = self.read_json() @@ -2249,11 +3155,12 @@ class Handler(BaseHTTPRequestHandler): if status not in ALLOWED_REVIEW: return self.send_json(400, {"error": "invalid_review_status"}) conn = db() - cur = conn.execute("UPDATE prospects SET review_status=?,review_notes=? WHERE id=?", (status, str(payload.get("notes", ""))[:1000], int(match.group(1)))) + cur = conn.execute("UPDATE prospects SET review_status=?,review_notes=?,updated_at=? WHERE id=? AND organization_id=?", (status, str(payload.get("notes", ""))[:1000], now(), int(match.group(1)), getattr(self, "_organization_id", 1))) conn.commit() if not cur.rowcount: return self.send_json(404, {"error": "not_found"}) - row = conn.execute("SELECT * FROM prospects WHERE id=?", (int(match.group(1)),)).fetchone() + row = conn.execute("SELECT * FROM prospects WHERE id=? AND organization_id=?", (int(match.group(1)), getattr(self, "_organization_id", 1))).fetchone() + record_audit("prospect_review_updated", organization_id=getattr(self, "_organization_id", 1), user_id=getattr(self, "_user_id", None), entity_type="prospect", entity_id=int(match.group(1)), details={"status": status}) return self.send_json(200, dict(row)) except (ValueError, TypeError, json.JSONDecodeError) as exc: return self.send_json(400, {"error": str(exc)}) @@ -2262,21 +3169,29 @@ class Handler(BaseHTTPRequestHandler): conn.close() def do_DELETE(self) -> None: + if not require_auth(self): + return + if not require_csrf(self): + return promotion_match = re.fullmatch(r"/api/promotions/(\d+)", urlparse(self.path).path) if promotion_match: + if not role_allows(getattr(self, "_auth_user", None), "admin"): + return self.send_json(403, {"error": "permission_denied"}) conn = db() try: - cur = conn.execute("DELETE FROM promotions WHERE id=?", (int(promotion_match.group(1)),)) + cur = conn.execute("DELETE FROM promotions WHERE id=? AND organization_id=?", (int(promotion_match.group(1)), getattr(self, "_organization_id", 1))) conn.commit() - record_log("action", "promotion_deleted", details={"promotion_id": int(promotion_match.group(1))}) + record_audit("promotion_deleted", organization_id=getattr(self, "_organization_id", 1), user_id=getattr(self, "_user_id", None), entity_type="promotion", entity_id=int(promotion_match.group(1))) return self.send_json(200, {"deleted": bool(cur.rowcount)}) finally: conn.close() match = re.fullmatch(r"/api/suppressions/(\d+)", urlparse(self.path).path) if not match: return self.send_json(404, {"error":"not_found"}) + if not role_allows(getattr(self, "_auth_user", None), "admin"): + return self.send_json(403, {"error": "permission_denied"}) conn=db() try: - cur=conn.execute("DELETE FROM suppressions WHERE id=?",(int(match.group(1)),)); conn.commit() + cur=conn.execute("DELETE FROM suppressions WHERE id=? AND organization_id=?",(int(match.group(1)), getattr(self, "_organization_id", 1))); conn.commit() return self.send_json(200,{"deleted":bool(cur.rowcount)}) finally: conn.close() @@ -2285,6 +3200,7 @@ def serve() -> None: host = os.environ.get("HOST", "0.0.0.0") port = int(os.environ.get("PORT", "8000")) db() + ensure_owner_from_env() recover_stale_runs() threading.Thread(target=scheduler_loop, name="prospect-scheduler", daemon=True).start() print(f"Personal ProspectOS listening on http://{host}:{port} (backend={backend_mode()})", flush=True) diff --git a/docker-compose.yml b/docker-compose.yml index ef0021d..00d6221 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,16 @@ services: BROWSER_USE_LLM_TIMEOUT_SECONDS: ${BROWSER_USE_LLM_TIMEOUT_SECONDS:-90} ANONYMIZED_TELEMETRY: ${ANONYMIZED_TELEMETRY:-false} LOG_RETENTION: ${LOG_RETENTION:-20000} + AUTH_REQUIRED: ${AUTH_REQUIRED:-true} + AUTH_SESSION_TTL_SECONDS: ${AUTH_SESSION_TTL_SECONDS:-604800} + AUTH_COOKIE_SECURE: ${AUTH_COOKIE_SECURE:-false} + WORKSPACE_NAME: ${WORKSPACE_NAME:-ProspectOS workspace} + OWNER_EMAIL: ${OWNER_EMAIL:-} + OWNER_PASSWORD: ${OWNER_PASSWORD:-} + OWNER_NAME: ${OWNER_NAME:-Workspace owner} + BACKUP_DIR: ${BACKUP_DIR:-/data/backups} + BACKUP_RETENTION: ${BACKUP_RETENTION:-14} + BACKUP_INTERVAL_HOURS: ${BACKUP_INTERVAL_HOURS:-24} AI_PROVIDER: ${AI_PROVIDER:-custom} AI_BASE_URL: ${AI_BASE_URL:-} AI_API_KEY: ${AI_API_KEY:-} diff --git a/scripts/backup_sqlite.py b/scripts/backup_sqlite.py new file mode 100644 index 0000000..e9ed122 --- /dev/null +++ b/scripts/backup_sqlite.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Create and verify a consistent SQLite backup for ProspectOS. + +Usage inside the app container: + python scripts/backup_sqlite.py +""" +from __future__ import annotations + +import json +import os +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path + +db_path = os.environ.get("PROSPECT_DB", "/data/prospects.db") +backup_dir = Path(os.environ.get("BACKUP_DIR", "") or (Path(db_path).parent / "backups")).resolve() +retention = max(1, min(100, int(os.environ.get("BACKUP_RETENTION", "14")))) +backup_dir.mkdir(parents=True, exist_ok=True) +name = "prospects-" + datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + ".db" +target = backup_dir / name +source = sqlite3.connect(db_path, timeout=30) +destination = sqlite3.connect(str(target), timeout=30) +try: + source.backup(destination) + check = destination.execute("PRAGMA integrity_check").fetchone()[0] + if str(check).lower() != "ok": + raise SystemExit(f"integrity_check_failed:{check}") + destination.commit() +finally: + destination.close() + source.close() +items = sorted(backup_dir.glob("prospects-*.db"), key=lambda path: path.stat().st_mtime, reverse=True) +for old in items[retention:]: + old.unlink(missing_ok=True) +print(json.dumps({"name": name, "path": str(target), "size_bytes": target.stat().st_size, "integrity": "ok"})) diff --git a/tests/test_app.py b/tests/test_app.py index 27452aa..85eaed0 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -17,6 +17,8 @@ class BrowserOnlyAppTests(unittest.TestCase): self.tmp = tempfile.NamedTemporaryFile(delete=False) self.tmp.close() server.DB_PATH = self.tmp.name + self.auth_required = server.AUTH_REQUIRED + server.AUTH_REQUIRED = False self.scan_patcher = patch("api.server.scan_website", return_value={"website_status": "healthy", "website_http_status": 200, "website_final_url": "", "website_latency_ms": 50, "website_https": 1, "website_title": "", "website_technology": "", "website_has_viewport": 1, "website_tls_expires_at": "", "website_checked_at": server.now(), "website_error": "", "website_emails": [], "email_evidence": []}) self.scan_patcher.start() self.http = server.ThreadingHTTPServer(("127.0.0.1", 0), server.Handler) @@ -25,6 +27,7 @@ class BrowserOnlyAppTests(unittest.TestCase): self.conn = HTTPConnection("127.0.0.1", self.http.server_port) def tearDown(self): + server.AUTH_REQUIRED = self.auth_required self.scan_patcher.stop() self.http.shutdown(); self.http.server_close(); self.thread.join(1) try: os.unlink(self.tmp.name) @@ -294,6 +297,19 @@ class BrowserOnlyAppTests(unittest.TestCase): saved = server.db().execute("SELECT ai_promotion_id FROM prospects WHERE id=1").fetchone() self.assertEqual(saved[0], promotion["id"]) + @patch("api.server.ai_assess_prospect", return_value={"ai_probability": 88, "ai_recommendation": "contact", "ai_reasoning": "Active business with a broken site."}) + def test_workspace_workflow_and_assessment_history(self, assess): + with patch("api.server.google_search", return_value=[{"name": "Acme", "website": "https://acme.co.za", "domain": "acme.co.za"}]): + _, run = self.request("POST", "/api/discover", {"category": "plumbers", "keywords": "plumbing", "location": "Cape Town"}) + self.wait_for_run(run["id"]) + conn = server.db(); conn.execute("INSERT INTO users(organization_id,email,name,password_hash,role,active,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)", (1, "reviewer@example.com", "Reviewer", "", "reviewer", 1, server.now(), server.now())); conn.commit(); user_id = conn.execute("SELECT id FROM users WHERE email='reviewer@example.com'").fetchone()[0]; conn.close() + status, note = self.request("POST", "/api/prospects/1/notes", {"body": "Call after reviewing the website."}); self.assertEqual(status, 201) + status, task = self.request("POST", "/api/prospects/1/tasks", {"title": "Send tailored draft", "due_at": "2030-01-01"}); self.assertEqual(status, 201) + status, assigned = self.request("POST", "/api/prospects/1/assignment", {"assigned_user_id": user_id}); self.assertEqual(status, 200); self.assertEqual(assigned["assigned_user_id"], user_id) + status, body = self.request("POST", "/api/prospects/1/assess", {}); self.assertEqual(status, 200); self.assertEqual(body["ai_probability"], 88) + status, history = self.request("GET", "/api/prospects/1/history"); self.assertEqual(status, 200); self.assertTrue(history["score_history"]); self.assertTrue(history["ai_history"]) + status, tasks = self.request("GET", "/api/prospects/1/tasks"); self.assertEqual(status, 200); self.assertEqual(tasks["items"][0]["title"], "Send tailored draft") + if __name__ == "__main__": unittest.main() diff --git a/web/app.js b/web/app.js index a3a4bd6..40d9f56 100644 --- a/web/app.js +++ b/web/app.js @@ -1,14 +1,14 @@ const $=id=>document.getElementById(id); const esc=v=>String(v??"").replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',"'":'''}[c])); -let prospects=[],runs=[],settings={},logs=[],selected=null,pollTimer=null; +let prospects=[],runs=[],settings={},logs=[],selected=null,pollTimer=null,currentUser=null,csrfToken=''; let activeLogTab='all'; const LOG_TABS=[['all','All'],['action','Actions'],['api','API calls'],['ai','AI calls'],['search','Searches'],['browser','Browser/tool steps'],['errors','Errors']]; function logAction(action,details={},level='info'){try{fetch('/api/logs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action,details,level,path:location.hash.slice(1)||'dashboard'})}).catch(()=>{})}catch{}} -function installExtraNav(){const nav=$('nav');if(!nav)return;[['teach-ai','Teach AI','✦'],['promotions','Promotions','%'],['logs','Logs','≋']].forEach(([route,label,icon])=>{if(nav.querySelector(`[data-route="${route}"]`))return;const link=document.createElement('a');link.href='#'+route;link.dataset.route=route;link.innerHTML=`${icon}${label}`;nav.append(link)})} +function installExtraNav(){const nav=$('nav');if(!nav)return;[['teach-ai','Teach AI','✦'],['promotions','Promotions','%'],['logs','Logs','≋'],['users','Users','♙']].forEach(([route,label,icon])=>{if(nav.querySelector(`[data-route="${route}"]`))return;const link=document.createElement('a');link.href='#'+route;link.dataset.route=route;link.innerHTML=`${icon}${label}`;nav.append(link)})} installExtraNav(); let activeProspectCategory='all'; let prospectScoreBand='all',prospectWebsiteFilter='all',prospectEmailFilter='all',prospectReviewFilter='all'; const HIGH_POTENTIAL_SCORE=70; -async function api(path,opt={}){const r=await fetch(path,{...opt,headers:{'Content-Type':'application/json',...(opt.headers||{})}});const b=await r.json().catch(()=>({}));if(!r.ok)throw Error(b.error||`Request failed (${r.status})`);return b} +async function api(path,opt={}){const headers={'Content-Type':'application/json',...(opt.headers||{})};if(csrfToken&&!headers['X-CSRF-Token'])headers['X-CSRF-Token']=csrfToken;const r=await fetch(path,{...opt,credentials:'same-origin',headers});const b=await r.json().catch(()=>({}));if(r.status===401&&!path.startsWith('/api/auth/')){currentUser=null;csrfToken='';renderAuth({setup_required:false});}if(!r.ok)throw Error(b.error||`Request failed (${r.status})`);return b} let loadWarnings=[]; async function refreshWorkerStatus(){const dot=$('sourceDot'),label=$('sourceLabel'),sub=$('sourceSub');if(!dot||!label||!sub)return;try{const health=await api('/api/health');const ready=Boolean(health.configured);dot.classList.toggle('offline',!ready);label.textContent=health.source==='browser'?'Browser discovery':'Google Maps worker';sub.textContent=ready?'Ready':'Not configured';}catch(error){dot.classList.add('offline');label.textContent='Discovery status';sub.textContent='Unavailable';}} function date(v){if(!v)return'—';const d=new Date(v);return isNaN(d)?String(v):d.toLocaleString(undefined,{day:'numeric',month:'short',hour:'2-digit',minute:'2-digit'})} @@ -24,7 +24,7 @@ function prospectTable(){const rows=prospectRows();return `
CATALOGUE${total}saved prospects
NEEDS REVIEW${pending}awaiting your decision
HIGH OPPORTUNITY${hot}score 75 or higher
PUBLIC EMAILS${withEmail}evidence-backed contacts
NEXT ACTION

Review your strongest leads

Inspect the evidence before deciding whether to contact them.

Open explorer →
${strongest.map(p=>{const score=effectiveScore(p);return ``}).join('')||empty('Run a discovery to build your catalogue')}
RECENT ACTIVITY

Runs

View all
${runs.slice(0,6).map(r=>`
#${r.id} · ${esc(r.query)}${esc(r.status)} · ${r.added||0} added
`).join('')||empty('No runs yet')}
`)} function renderDiscover(){ $('view').innerHTML=layout('Discover','Set the criteria for this run. Source configuration and provider settings live in Settings.',`
NEW RUN

Find potential customers

Search Google Maps, deduplicate the catalogue, then enrich each result.

Email collection always on
HOW IT WORKS

Evidence-first pipeline

  1. SearchMaps worker finds public business listings.
  2. EnrichWebsite, HTTPS, DNS hints and public email checks run safely.
  3. ReviewYou decide what is relevant before any contact.
`)} function renderProspects(){ $('view').innerHTML=layout('Prospects','Browse the strongest opportunities first, then narrow the catalogue with filters and sort by the latest score.',`
${prospectTabs()}

Click any prospect to review evidence, change its status, assess it with AI, or draft a tailored message. Review status is changed inside the prospect drawer.

${prospectTable()}
`);if($('prospectScoreBand'))$('prospectScoreBand').value=prospectScoreBand;if($('prospectWebsiteFilter'))$('prospectWebsiteFilter').value=prospectWebsiteFilter;if($('prospectEmailFilter'))$('prospectEmailFilter').value=prospectEmailFilter;if($('prospectReviewFilter'))$('prospectReviewFilter').value=prospectReviewFilter} -function renderRuns(){ $('view').innerHTML=layout('Runs','Monitor discovery jobs and inspect errors without leaving the run history.',`
${runs.map(r=>``).join('')}
RunStatusResultsStartedSource
#${r.id}${esc(r.query)}${esc(r.status)}${esc(r.stage||'')}${r.added||0} added / ${r.found||0} found${date(r.created_at)}${esc(r.source||'google_maps')}
${runs.length?'':empty('No discovery runs yet')}
`)} +function renderRuns(){ $('view').innerHTML=layout('Runs','Monitor discovery jobs, recover interrupted work, and inspect every state transition.',`
${runs.map(r=>{const active=['queued','running'].includes(r.status);const retry=['failed','cancelled'].includes(r.status)||r.recovery_state==='retryable';return ``}).join('')}
RunStatusResultsStartedSourceActions
#${r.id}${esc(r.query)}${esc(r.status)}${esc(r.stage||'')}${r.recovery_state?` · ${esc(r.recovery_state)}`:''}${r.added||0} added / ${r.found||0} found${date(r.created_at)}${esc(r.source||'google_maps')}${active?``:''}${retry?``:''}
${runs.length?'':empty('No discovery runs yet')}
`)} function logTabCount(tab,counts){return tab==='errors'?Number(counts?.errors||0):Number(counts?.[tab]||0)} function logTabs(counts={}){return `
${LOG_TABS.map(([key,label])=>``).join('')}
`} function renderLogTable(){const rows=Array.isArray(logs)?logs:[];return rows.length?`
${rows.map(row=>{const details=typeof row.details==='string'?row.details:JSON.stringify(row.details||{},null,2);return ``}).join('')}
TimeTypeEventStatusDurationDetails
${esc(date(row.created_at))}${esc(row.kind||'system')}${esc(row.event||'')}${row.path?`${esc(row.method||'')} ${esc(row.path)}`:''}${row.error?`${esc(row.error)}`:''}${row.status?esc(row.status):'—'}${row.duration_ms!=null?esc(row.duration_ms)+' ms':'—'}
View
${esc(details)}
`:empty('No log entries for this tab')} @@ -70,7 +70,7 @@ async function load(){ return loadWarnings; } function renderLoadWarnings(){if(!loadWarnings.length)return;const view=$('view');if(!view)return;const notice=document.createElement('div');notice.className='notice error load-warning';notice.setAttribute('role','status');notice.textContent=`Dashboard loaded with limited data. ${loadWarnings.join(' · ')}`;view.prepend(notice)} -async function route(){const r=location.hash.slice(1)||'dashboard';installExtraNav();document.querySelectorAll('[data-route]').forEach(a=>a.classList.toggle('active',a.dataset.route===r));try{await load();({dashboard:renderDashboard,discover:renderDiscover,prospects:renderProspects,runs:renderRuns,logs:renderLogs,settings:renderSettings,'teach-ai':renderTeachAI,promotions:renderPromotions}[r]||renderDashboard)();renderLoadWarnings();$('updated').textContent='Updated '+new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});refreshWorkerStatus();}catch(e){$('view').innerHTML=empty(e.message);refreshWorkerStatus();}} +async function route(){const r=location.hash.slice(1)||'dashboard';installExtraNav();document.querySelectorAll('[data-route]').forEach(a=>a.classList.toggle('active',a.dataset.route===r));try{await load();({dashboard:renderDashboard,discover:renderDiscover,prospects:renderProspects,runs:renderRuns,logs:renderLogs,settings:renderSettings,'teach-ai':renderTeachAI,promotions:renderPromotions,users:renderUsers}[r]||renderDashboard)();renderLoadWarnings();$('updated').textContent='Updated '+new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});refreshWorkerStatus();}catch(e){$('view').innerHTML=empty(e.message);refreshWorkerStatus();}} function suggestedOffer(p){if(p.ai_offer)return p.ai_offer;if(['broken','unreachable','error','slow'].includes(String(p.website_status||'')))return'Move their hosting to our Gold Plus package at R119/month and rebuild the website at no extra design charge.';if(!p.website)return'Offer a professional .co.za domain, reliable hosting from R119/month, professional email, and a free one-page website.';return'Offer a friendly website and hosting review; only suggest a change that is supported by the evidence.'} function jsonArray(value){try{const parsed=JSON.parse(value||'[]');return Array.isArray(parsed)?parsed:[]}catch{return[]}} function showDraftNotes(d){let notes=$('draftNotes');if(!notes){notes=document.createElement('div');notes.id='draftNotes';notes.className='draft-notes';const status=$('messageStatus');if(status)status.parentNode.insertBefore(notes,status)}if(!notes)return;const unknowns=Array.isArray(d?.unknowns)?d.unknowns:[];const used=Array.isArray(d?.evidence_used)?d.evidence_used:[];notes.innerHTML=`AI review notes

${esc(d?.rationale||'The draft uses the prospect evidence and your instructions.')}

${unknowns.length?`

Confirm before sending: ${esc(unknowns.join(' · '))}

`:''}${used.length?`

Evidence used: ${esc(used.join(', '))}

`:''}${d?.follow_up?`

${esc(d.follow_up)}

`:''}`} @@ -112,12 +112,12 @@ function addAssessmentPanel(id){const p=prospects.find(x=>String(x.id)===String( function candidateEvidence(candidate){try{const value=JSON.parse(candidate.evidence||'[]');return Array.isArray(value)?value.filter(Boolean).map(String):[]}catch{return[]}} function renderWebsiteCandidates(items,id){const section=$('websiteCandidatesPanel');if(!section)return;const rows=Array.isArray(items)?items:[];section.innerHTML=`

Possible official websites

Review the evidence before accepting a domain. Accepting runs the normal website and public-email scan.

${rows.length?`
${rows.map(c=>{const evidence=candidateEvidence(c);const status=c.status||'candidate';return `
${esc(c.domain)} ↗${esc(status)}

Confidence ${Number(c.confidence||0)}%

${evidence.length?`
    ${evidence.map(item=>`
  • ${esc(item)}
  • `).join('')}
`:'

No evidence returned.

'}${status==='candidate'?`
`:''}
`}).join('')}
`:'

No plausible official website candidates found yet.

'}`} function addWebsitePanel(id){const body=$('drawerBody');const p=prospects.find(x=>String(x.id)===String(id));if(!p||!body)return;const section=document.createElement('section');section.className='drawer-section';section.id='websiteCandidatesPanel';section.innerHTML=`

Find a possible website

Searches a few permitted public pages for a likely official domain when Maps did not list one. It never logs in or bypasses access challenges.

Requires a tested AI provider in Settings.

`;const actions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);else body.append(section);api(`/api/prospects/${p.id}/website-candidates`).then(d=>{if((d.items||[]).length)renderWebsiteCandidates(d.items,p.id)}).catch(()=>{})} -const _openDrawerWithAssessment=openDrawer;openDrawer=function(id){_openDrawerWithAssessment(id);addAssessmentPanel(id);addWebsitePanel(id)} +const _openDrawerWithAssessment=openDrawer;openDrawer=function(id){_openDrawerWithAssessment(id);addAssessmentPanel(id);addWebsitePanel(id);addWorkflowPanel(id);addHistoryPanel(id)} async function assessProspect(id,button){if(button){button.disabled=true;button.textContent='Assessing…'}try{const d=await api(`/api/prospects/${id}/assess`,{method:'POST',body:'{}'});const index=prospects.findIndex(p=>String(p.id)===String(id));if(index>=0)prospects[index]={...prospects[index],...d};selected=prospects[index]||selected;openDrawer(id);if($('prospectTabs'))$('prospectTabs').innerHTML=prospectTabs();if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()}catch(error){const panel=$('aiAssessmentPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}if(button){button.disabled=false;button.textContent='Assess with AI'}}} document.addEventListener('click',async e=>{const button=e.target.closest('[data-assess]');if(button){e.preventDefault();e.stopPropagation();await assessProspect(button.dataset.assess,button)}}); async function findWebsiteCandidates(id,button){if(button){button.disabled=true;button.textContent='Searching…'}const section=$('websiteCandidatesPanel');try{const d=await api(`/api/prospects/${id}/website-candidates`,{method:'POST',body:'{}'});renderWebsiteCandidates(d.items||[],id)}catch(error){if(section){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;section.append(notice)}}finally{if(button){button.disabled=false;button.textContent='Search again'}}} document.addEventListener('click',async e=>{const find=e.target.closest('[data-find-websites]');if(find){e.preventDefault();e.stopPropagation();await findWebsiteCandidates(find.dataset.findWebsites,find);return}const accept=e.target.closest('[data-accept-website]');if(accept){e.preventDefault();e.stopPropagation();accept.disabled=true;accept.textContent='Checking…';try{const d=await api(`/api/prospects/${accept.dataset.prospectId}/website-candidates/${accept.dataset.acceptWebsite}`,{method:'PATCH',body:JSON.stringify({status:'accepted'})});if(d.prospect){const i=prospects.findIndex(p=>String(p.id)===String(d.prospect.id));if(i>=0)prospects[i]={...prospects[i],...d.prospect};selected=d.prospect}openDrawer(accept.dataset.prospectId);if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()}catch(error){accept.disabled=false;accept.textContent='Accept domain';const panel=$('websiteCandidatesPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}}return}const reject=e.target.closest('[data-reject-website]');if(reject){e.preventDefault();e.stopPropagation();reject.disabled=true;try{const d=await api(`/api/prospects/${reject.dataset.prospectId}/website-candidates/${reject.dataset.rejectWebsite}`,{method:'PATCH',body:JSON.stringify({status:'rejected'})});const current=await api(`/api/prospects/${reject.dataset.prospectId}/website-candidates`);renderWebsiteCandidates(current.items||[],reject.dataset.prospectId)}catch(error){reject.disabled=false;const panel=$('websiteCandidatesPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}}}}); -window.addEventListener('hashchange',route);$('refreshAll').addEventListener('click',route);ensureMessageDialog();refreshWorkerStatus();setInterval(refreshWorkerStatus,30000);route(); +window.addEventListener('hashchange',route);$('refreshAll').addEventListener('click',route);ensureMessageDialog();setInterval(refreshWorkerStatus,30000);bootstrapAuth(); document.addEventListener('click',e=>{const tab=e.target.closest('[data-category-tab]');if(!tab)return;e.preventDefault();e.stopPropagation();activeProspectCategory=tab.dataset.categoryTab||'all';if($('prospectTabs'))$('prospectTabs').innerHTML=prospectTabs();if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()}); document.addEventListener('click',e=>{const tab=e.target.closest('[data-log-tab]');if(tab){e.preventDefault();activeLogTab=tab.dataset.logTab||'all';refreshLogs();return}if(e.target.id==='refreshLogs')refreshLogs()}); document.addEventListener('click',e=>{const target=e.target.closest('button,a,select,input,textarea,tr,[role="button"]');if(!target)return;const details={tag:target.tagName.toLowerCase(),id:target.id||'',label:target.getAttribute('aria-label')||target.getAttribute('title')||'',route:location.hash.slice(1)||'dashboard'};for(const name of ['open','message','assess','findWebsites','acceptWebsite','rejectWebsite','categoryTab','logTab','review'])if(target.dataset[name])details[name]=target.dataset[name];logAction('ui_click',details)},true); @@ -125,3 +125,21 @@ document.addEventListener('change',e=>{const target=e.target;if(!target||!target document.addEventListener('submit',e=>{if(e.target?.id)logAction('ui_submit',{form:e.target.id,route:location.hash.slice(1)||'dashboard'})},true); window.addEventListener('error',e=>logAction('client_error',{message:String(e.message||'Unknown browser error').slice(0,500),source:String(e.filename||'').slice(0,500),line:e.lineno||0},'error')); window.addEventListener('unhandledrejection',e=>logAction('client_unhandled_rejection',{message:String(e.reason?.message||e.reason||'Unhandled promise rejection').slice(0,500)},'error')); + +/* Workspace security and workflow UI */ +function authShell(title,setup){const root=$('authRoot')||document.body.appendChild(Object.assign(document.createElement('div'),{id:'authRoot'}));root.innerHTML=`
P
ProspectOSPrivate growth workspace
${setup?'FIRST-TIME SETUP':'SECURE SIGN IN'}

${title}

${setup?'Create the workspace owner account. Existing catalogue data will be kept in this workspace.':'Sign in to continue to your prospect workspace.'}

${setup?``:''}
`;root.hidden=false;document.querySelector('.app-shell')?.setAttribute('aria-hidden','true');document.querySelector('.app-shell')?.classList.add('auth-hidden');$('detailDrawer')?.classList.remove('open')} +function renderAuth(status={}){authShell(status.setup_required?'Create your workspace':'Welcome back',Boolean(status.setup_required));const nav=$('nav');if(nav)nav.setAttribute('aria-hidden','true')} +function setAppAuthenticated(value){const root=$('authRoot');if(root)root.hidden=value;document.querySelector('.app-shell')?.classList.toggle('auth-hidden',!value);document.querySelector('.app-shell')?.removeAttribute('aria-hidden');const nav=$('nav');if(nav)nav.setAttribute('aria-hidden',value?'false':'true');const actions=document.querySelector('.top-actions');if(actions&&value&&!$('logoutButton')){const button=document.createElement('button');button.id='logoutButton';button.className='secondary';button.textContent='Sign out';actions.prepend(button)}if(actions&&!value)$('logoutButton')?.remove();const who=$('updated');if(who&¤tUser)who.textContent=`${currentUser.name||currentUser.email} · ${currentUser.role}`} +async function bootstrapAuth(){try{const status=await fetch('/api/auth/status',{credentials:'same-origin'}).then(r=>r.json());if(status.authenticated){const me=await fetch('/api/auth/me',{credentials:'same-origin'}).then(r=>r.json());currentUser=me.user||status.user;csrfToken=me.csrf_token||'';setAppAuthenticated(true);route();return}setAppAuthenticated(false);renderAuth(status)}catch(error){setAppAuthenticated(false);renderAuth({setup_required:false})}} +async function submitAuth(form){const setup=form.id==='authSetupForm';const message=$('authMessage');const button=form.querySelector('button');button.disabled=true;message.textContent='Working…';try{const payload={email:$('authEmail').value,password:$('authPassword').value};if(setup){payload.name=$('authName').value;payload.workspace_name=$('authWorkspace').value}const response=await fetch(setup?'/api/auth/setup':'/api/auth/login',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});const body=await response.json().catch(()=>({}));if(!response.ok)throw Error(body.error||`Request failed (${response.status})`);currentUser=body.user;csrfToken=body.csrf_token||'';setAppAuthenticated(true);if(!location.hash)location.hash='#dashboard';route()}catch(error){message.textContent=error.message;message.className='notice error';button.disabled=false}} +async function loadUsers(){const data=await api('/api/users');return data.items||[]} +function renderUsers(){const canManage=ROLE_ORDER_UI(currentUser?.role)>='admin';$('view').innerHTML=layout('Users & workspace','Control who can access this workspace, what they can do, and who owns follow-up work.',`
WORKSPACE

Ownership

All prospects, runs, notes, and audit records are isolated to this workspace.

Loading workspace…
${canManage?`
`:''}
ACCESS

Invite a teammate

Roles limit destructive and operational actions.

${canManage?`
`:'

Only admins and owners can manage users.

'}
MEMBERS

Workspace users

Loading users…
${canManage?`
BACKUPS

Verified database backups

Backups are created online, integrity-checked, and retained according to the server setting.

Loading backups…
AUDIT

Recent control events

Sign-ins, permission changes, assignments, reviews, retries, and backup actions.

Open logs →
Loading audit…
`:''}`);refreshUsersPage()} +function ROLE_ORDER_UI(role){return {viewer:10,reviewer:20,researcher:30,admin:40,owner:50}[role]||0} +async function refreshUsersPage(){try{const [workspace,users]=await Promise.all([api('/api/workspace'),loadUsers()]);const info=$('workspaceInfo');if(info)info.innerHTML=`
${esc(workspace.workspace?.name||'Workspace')}${workspace.counts?.prospects||0} prospects · ${workspace.counts?.users||0} users
`;if($('workspaceName'))$('workspaceName').value=workspace.workspace?.name||'';const list=$('usersList');if(list)list.innerHTML=users.map(u=>`
${esc(u.name||u.email)}${esc(u.email)} · joined ${date(u.created_at)}${u.last_login_at?` · last sign-in ${date(u.last_login_at)}`:''}
${esc(u.role)}${ROLE_ORDER_UI(currentUser?.role)>=40&&u.role!=='owner'?``:''}
`).join('')||empty('No users yet');if($('backupsList')){const backups=await api('/api/backups');$('backupsList').innerHTML=backups.items?.map(b=>`
${esc(b.name)}${Math.round((b.size_bytes||0)/1024)} KB · ${date(b.created_at)}
`).join('')||'

No backups created yet.

'}if($('auditList')){const audit=await api('/api/audit?limit=30');$('auditList').innerHTML=audit.items?.map(a=>`
${esc(a.action)}${esc(a.user_name||a.user_email||'system')} · ${esc(a.entity_type||'')} ${a.entity_id||''} · ${date(a.created_at)}
`).join('')||'

No audit events yet.

'}}catch(error){const list=$('usersList');if(list)list.innerHTML=`

${esc(error.message)}

`}} +function addWorkflowPanel(id){const body=$('drawerBody');const p=prospects.find(x=>String(x.id)===String(id));if(!p||!body)return;const section=document.createElement('section');section.className='drawer-section workflow-panel';section.id='workflowPanel';section.innerHTML=`

Ownership & follow-up

Notes and tasks stay with the prospect
Loading notes…
Loading tasks…
`;const actions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);else body.append(section);Promise.all([loadUsers(),api(`/api/prospects/${p.id}/notes`),api(`/api/prospects/${p.id}/tasks`)]).then(([users,notes,tasks])=>{const select=$('assignmentSelect');if(select){select.innerHTML=''+users.filter(u=>u.active).map(u=>``).join('')}$('notesList').innerHTML=(notes.items||[]).map(n=>`
${esc(n.user_name||n.user_email||'Note')}${esc(n.body)}${date(n.created_at)}
`).join('')||'

No notes yet.

';$('tasksList').innerHTML=(tasks.items||[]).map(t=>`
${t.due_at?`Due ${esc(t.due_at)} · `:''}${esc(t.status)}
`).join('')||'

No follow-up tasks yet.

'}).catch(error=>{section.insertAdjacentHTML('beforeend',`

${esc(error.message)}

`)})} +function addHistoryPanel(id){const body=$('drawerBody');if(!body)return;const section=document.createElement('section');section.className='drawer-section';section.id='historyPanel';section.innerHTML='

Score & AI history

Loading assessment history…

';const actions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);else body.append(section);api(`/api/prospects/${id}/history`).then(data=>{const scores=data.score_history||[],ais=data.ai_history||[];section.innerHTML=`

Score & AI history

${scores.length?`
${scores.slice(0,12).map(s=>`
${esc(s.score_type)} · ${Number(s.score)}%${date(s.created_at)}${s.model?` · ${esc(s.model)}`:''}
`).join('')}
`:'

No score history yet.

'}${ais.length?`
${ais.length} AI assessment${ais.length===1?'':'s'}${ais.slice(0,8).map(a=>`
${Number(a.probability)}% · ${esc(aiRecommendationLabel(a.recommendation))}${date(a.created_at)}${a.model?` · ${esc(a.model)}`:''}

${esc(a.reasoning||'')}

`).join('')}
`:''}`}).catch(error=>{section.innerHTML=`

Score & AI history

${esc(error.message)}

`})} +/* Corrected compact user page (keeps admin controls available to owners/admins). */ +function renderUsers(){const canManage=['owner','admin'].includes(currentUser?.role);$('view').innerHTML=layout('Users & workspace','Manage access, ownership, assignments, backups, and audit visibility.',`
WORKSPACE

Workspace ownership

All catalogue records and operational history are isolated here.

Loading…
${canManage?`
`:''}
TEAM ACCESS

Add a user

Use reviewer for day-to-day review, researcher for runs, and admin for controls.

${canManage?`
`:'

Only owners and admins can manage users.

'}
MEMBERS

Workspace users

Loading…
${canManage?`
BACKUPS

Verified backups

Online SQLite backups are integrity-checked before being listed.

Loading…
AUDIT TRAIL

Recent control events

Open logs →
Loading…
`:''}`);refreshUsersPage()} +document.addEventListener('submit',async e=>{if(e.target.id==='authLoginForm'||e.target.id==='authSetupForm'){e.preventDefault();await submitAuth(e.target);return}if(e.target.id==='userForm'){e.preventDefault();const m=$('userMessage');try{await api('/api/users',{method:'POST',body:JSON.stringify({name:$('newUserName').value,email:$('newUserEmail').value,password:$('newUserPassword').value,role:$('newUserRole').value})});m.textContent='User created.';m.className='notice ok';e.target.reset();refreshUsersPage()}catch(error){m.textContent=error.message;m.className='notice error'}}if(e.target.id==='workspaceForm'){e.preventDefault();try{await api('/api/workspace',{method:'POST',body:JSON.stringify({name:$('workspaceName').value})});refreshUsersPage()}catch(error){alert(error.message)}}if(e.target.id==='noteForm'){e.preventDefault();try{await api(`/api/prospects/${selected.id}/notes`,{method:'POST',body:JSON.stringify({body:$('noteBody').value})});openDrawer(selected.id)}catch(error){alert(error.message)}}if(e.target.id==='taskForm'){e.preventDefault();try{await api(`/api/prospects/${selected.id}/tasks`,{method:'POST',body:JSON.stringify({title:$('taskTitle').value,due_at:$('taskDue').value})});openDrawer(selected.id)}catch(error){alert(error.message)}}}); +document.addEventListener('change',async e=>{if(e.target.id==='assignmentSelect'&&selected){try{const updated=await api(`/api/prospects/${selected.id}/assignment`,{method:'POST',body:JSON.stringify({assigned_user_id:e.target.value||null})});selected=updated;const i=prospects.findIndex(p=>String(p.id)===String(updated.id));if(i>=0)prospects[i]=updated}catch(error){alert(error.message)}}if(e.target.dataset.userRole){try{await api(`/api/users/${e.target.dataset.userRole}`,{method:'PATCH',body:JSON.stringify({role:e.target.value})});refreshUsersPage()}catch(error){alert(error.message)}}if(e.target.dataset.userActive){try{await api(`/api/users/${e.target.dataset.userActive}`,{method:'PATCH',body:JSON.stringify({active:e.target.checked})});refreshUsersPage()}catch(error){e.target.checked=!e.target.checked;alert(error.message)}}if(e.target.dataset.taskStatus&&selected){try{await api(`/api/prospects/${selected.id}/tasks/${e.target.dataset.taskStatus}`,{method:'PATCH',body:JSON.stringify({status:e.target.checked?'done':'open'})});openDrawer(selected.id)}catch(error){alert(error.message)}}}); +document.addEventListener('click',async e=>{if(e.target.id==='logoutButton'){try{await fetch('/api/auth/logout',{method:'POST',credentials:'same-origin',headers:{'X-CSRF-Token':csrfToken}})}finally{currentUser=null;csrfToken='';bootstrapAuth()}}if(e.target.id==='refreshUsers')refreshUsersPage();if(e.target.id==='createBackup'){e.target.disabled=true;try{await api('/api/backups',{method:'POST',body:'{}'});refreshUsersPage()}catch(error){alert(error.message)}finally{e.target.disabled=false}}const retry=e.target.closest('[data-retry-run]');if(retry){retry.disabled=true;try{await api(`/api/runs/${retry.dataset.retryRun}/retry`,{method:'POST',body:'{}'});route()}catch(error){alert(error.message)}finally{retry.disabled=false}}const cancel=e.target.closest('[data-cancel-run]');if(cancel){cancel.disabled=true;try{await api(`/api/runs/${cancel.dataset.cancelRun}/cancel`,{method:'POST',body:'{}'});route()}catch(error){alert(error.message)}finally{cancel.disabled=false}}const events=e.target.closest('[data-run-events]');if(events){try{const data=await api(`/api/runs/${events.dataset.runEvents}/events`);const panel=$('runEventsPanel');if(panel)panel.innerHTML=`

Run #${events.dataset.runEvents} events

${(data.items||[]).map(item=>`
${esc(item.stage)} · ${esc(item.status)}${esc(item.message||'')} · ${date(item.created_at)}
`).join('')||'

No events.

'}
`}catch(error){alert(error.message)}}}); diff --git a/web/index.html b/web/index.html index 35f036e..feb6e90 100644 --- a/web/index.html +++ b/web/index.html @@ -1,3 +1,3 @@ -ProspectOS +ProspectOS
Workspace/Dashboard
—
ProspectOS · public data only · manual review before contact

Tailor a message

Use the evidence and your offer to create an editable draft.

diff --git a/web/style.css b/web/style.css index 9be35f9..c131b8d 100644 --- a/web/style.css +++ b/web/style.css @@ -142,3 +142,7 @@ input::placeholder { color: #aeb6c5; } input:focus, select:focus { border-color: .prospect-filter-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.prospect-filter-row .search{flex:1 1 260px;min-width:220px}.prospect-filter-row select{width:auto;min-width:140px;flex:0 1 170px;margin:0}.prospect-filter-row button{flex:0 0 auto}.knowledge-grid,.promotions-grid{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(280px,.75fr);gap:17px;align-items:start}.knowledge-form,.promotion-form{display:grid;gap:13px}.knowledge-form textarea,.promotion-form textarea{resize:vertical;line-height:1.45}.knowledge-preview{padding:22px}.knowledge-status{display:grid;gap:7px;padding:15px;border:1px solid #e5e9f2;border-radius:10px;background:#fafbff;color:#526078;font-size:12px}.knowledge-status span{color:#8b96a8;font-size:11px}.knowledge-preview details{margin-top:15px}.knowledge-preview summary{color:#5b50d3;cursor:pointer;font-size:11px;font-weight:800}.knowledge-preview details p{max-height:400px;overflow:auto;margin:9px 0 0;padding:11px;border-radius:8px;background:#f6f7fb;color:#627087;font-size:11px;line-height:1.55;white-space:pre-wrap}.promotion-list{display:grid;gap:10px}.promotion-card{display:flex;justify-content:space-between;gap:14px;padding:14px;border:1px solid #e5e9f2;border-radius:10px;background:#fff}.promotion-card.inactive{background:#fafbfc;opacity:.78}.promotion-card p{margin:7px 0;color:#627087;font-size:12px;line-height:1.45}.promotion-card small{display:block;margin-top:5px;color:#919bad;font-size:10px}.promotion-card-head{display:flex;align-items:center;gap:12px}.promotion-card-head b{color:#29364d;font-size:13px}.switch{display:inline-flex;align-items:center;gap:6px;color:#7d899c;font-size:10px;font-weight:800;cursor:pointer}.switch input{width:15px;height:15px;margin:0}.promotion-card>.secondary{align-self:flex-start;white-space:nowrap}.message-controls{grid-template-columns:repeat(3,1fr)} @media(max-width:900px){.knowledge-grid,.promotions-grid{grid-template-columns:1fr}.message-controls{grid-template-columns:1fr 1fr}} @media(max-width:680px){.prospect-filter-row .search{min-width:100%;flex-basis:100%}.prospect-filter-row select{flex:1 1 45%;min-width:0}.prospect-filter-row button{flex:1 1 45%}.promotion-card{display:block}.promotion-card>.secondary{margin-top:10px}.message-controls{grid-template-columns:1fr}} + +/* workspace access, ownership, history, and recovery */ +.auth-hidden{visibility:hidden}.auth-hidden *{pointer-events:none}.auth-card{position:fixed;inset:0;z-index:100;display:grid;align-content:center;justify-items:start;gap:11px;padding:42px;box-sizing:border-box;background:radial-gradient(circle at 75% 10%,#eceaff 0,#f7f8fc 42%,#f7f8fc 100%)}.auth-card>*,.auth-card form{width:min(430px,100%)}.auth-brand{display:flex;align-items:center;gap:11px;margin-bottom:20px}.auth-brand b,.auth-brand small{display:block}.auth-brand b{font-size:16px}.auth-brand small{color:#8995a8;font-size:11px}.auth-card h1{margin:4px 0;font-size:32px;letter-spacing:-.8px}.auth-card>p{margin:0 0 14px;color:#7c899c;font-size:13px}.auth-card form{display:grid;gap:13px;padding:24px;border:1px solid #e0e5ef;border-radius:16px;background:#fff;box-shadow:0 15px 45px #25355714}.auth-card label{display:grid;gap:6px;color:#4d5c73;font-size:12px;font-weight:800}.auth-card input{margin:0}.auth-card button{height:44px}.user-grid{align-items:start}.workspace-info{padding:15px 0}.workspace-stat{display:grid;gap:4px}.workspace-stat b{font-size:18px}.workspace-stat span{color:#8a96a8;font-size:12px}.user-form{display:grid;gap:12px;margin-top:16px}.user-form label{display:grid;gap:6px;color:#4d5c73;font-size:12px;font-weight:800}.user-form input,.user-form select{margin:0}.user-list,.backup-list,.audit-list{display:grid;gap:0;margin-top:10px}.user-row,.backup-row,.audit-row{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:13px 0;border-top:1px solid #edf0f5}.user-row b,.backup-row b,.audit-row b{display:block;font-size:12px}.user-row small,.backup-row span,.audit-row span{display:block;margin-top:4px;color:#8a96a8;font-size:11px}.user-controls{display:flex;align-items:center;gap:8px}.user-controls select{width:auto;margin:0;height:32px;padding:0 7px;font-size:11px}.user-controls .switch{white-space:nowrap}.run-actions{white-space:nowrap}.run-actions button{margin-right:5px;padding:7px 9px;font-size:10px}.drawer-field{display:grid;gap:6px;color:#526078;font-size:11px;font-weight:800}.drawer-field select{margin:0}.drawer-inline-form{display:grid;grid-template-columns:1fr auto;gap:7px;margin-top:11px}.drawer-inline-form textarea,.drawer-inline-form input{width:100%;margin:0;border:1px solid #dbe2ed;border-radius:8px;padding:9px;font:inherit;font-size:12px;box-sizing:border-box}.drawer-inline-form textarea{grid-column:1/-1;resize:vertical}.drawer-inline-form button{font-size:10px;padding:8px 10px}.workflow-list{display:grid;gap:7px;margin-top:13px}.workflow-item{display:grid;gap:3px;padding:9px;border-radius:8px;background:#fafbff;border:1px solid #edf0f5}.workflow-item b{font-size:11px;color:#3d4b63}.workflow-item span{font-size:12px;color:#59677e;white-space:pre-wrap}.workflow-item small{color:#929cad;font-size:10px}.task-item label{display:flex;align-items:center;gap:6px}.task-item input{width:auto;height:auto;margin:0}.history-list{display:grid;gap:6px}.history-item{display:flex;justify-content:space-between;gap:8px;padding:8px 0;border-bottom:1px solid #f0f1f5}.history-item b{font-size:11px}.history-item span{color:#929cad;font-size:10px}.history-details{margin-top:12px}.history-details summary{color:#5b50d3;cursor:pointer;font-size:11px;font-weight:800}.history-assessment{padding:9px 0;border-bottom:1px solid #f0f1f5}.history-assessment b,.history-assessment small{display:block}.history-assessment b{font-size:11px}.history-assessment small{margin-top:3px;color:#929cad;font-size:10px}.history-assessment p{margin:5px 0 0;color:#59677e;font-size:11px}.run-events-panel{margin-top:16px} +@media(max-width:680px){.auth-card{padding:24px}.user-row,.backup-row,.audit-row{display:block}.user-controls{margin-top:9px;justify-content:flex-start}.drawer-inline-form{grid-template-columns:1fr}.drawer-inline-form button{justify-self:start}}