Review your strongest leads
Inspect the evidence before deciding whether to contact them.
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 `
Inspect the evidence before deciding whether to contact them.
Search Google Maps, deduplicate the catalogue, then enrich each result.
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.
| Run | Status | Results | Started | Source |
|---|---|---|---|---|
| #${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')} |
| Run | Status | Results | Started | Source | Actions |
|---|---|---|---|---|---|
| #${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?``:''} |
| Time | Type | Event | Status | Duration | Details |
|---|---|---|---|---|---|
| ${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)} |
${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=`Review the evidence before accepting a domain. Accepting runs the normal website and public-email scan.
${rows.length?`Confidence ${Number(c.confidence||0)}%
${evidence.length?`No evidence returned.
'}${status==='candidate'?``:''}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=`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=`${setup?'Create the workspace owner account. Existing catalogue data will be kept in this workspace.':'Sign in to continue to your prospect workspace.'}
All prospects, runs, notes, and audit records are isolated to this workspace.
Roles limit destructive and operational actions.
Only admins and owners can manage users.
'}Backups are created online, integrity-checked, and retained according to the server setting.
Sign-ins, permission changes, assignments, reviews, retries, and backup actions.
No backups created yet.
'}if($('auditList')){const audit=await api('/api/audit?limit=30');$('auditList').innerHTML=audit.items?.map(a=>`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=`No notes yet.
';$('tasksList').innerHTML=(tasks.items||[]).map(t=>`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='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=`No score history yet.
'}${ais.length?`${esc(a.reasoning||'')}
${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.',`All catalogue records and operational history are isolated here.
Use reviewer for day-to-day review, researcher for runs, and admin for controls.
Only owners and admins can manage users.
'}Online SQLite backups are integrity-checked before being listed.
No events.
'}