diff --git a/.env.example b/.env.example index e54c2ec..e56fbf7 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,10 @@ # ProspectOS host port HOST_PORT=8000 +# Public origin used in client-facing website concept links. Set this when +# the app is behind a reverse proxy; otherwise the request Host header is used. +PUBLIC_BASE_URL= + # Maps worker admin/UI port. Keep it private if the server is internet-facing. GMAPS_HOST_PORT=8081 diff --git a/README.md b/README.md index f19d0b4..26d11c9 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,16 @@ reviewable homepage text snapshot that is included in AI assessments and email drafts. The **Promotions** view stores dated, active/inactive offers; an active promotion can be selected in the email draft dialog and is passed to the AI with its exact description and terms. +From a prospect's detail drawer, **Generate modern mockup** creates a +shareable, mobile-first website concept using the prospect's public details +and (when available) a fresh snapshot of its current homepage. The preview is +a standalone HTML document ready to place in a PHP/HTML hosting folder; it +does not require PHP, JavaScript, forms, tracking, external fonts, or remote +assets to render. If AI is configured it can produce a tailored design; when +AI is unavailable the deterministic fallback still produces a polished +preview. Each tokenised link is read-only, marked as a concept, sent with +`noindex`, and can be included once in a human-reviewed email draft. It is not +a production site and should be checked and edited before sending. The **Logs** view separates API calls, user actions, AI requests, searches, Browser Use/tool steps, and errors. AI request payloads and responses are truncated and credentials such as API keys, authorization headers, tokens, and @@ -79,6 +89,7 @@ error rather than remaining stuck forever. | Variable | Default | Purpose | |---|---:|---| | `HOST_PORT` | `8000` | Host port for ProspectOS | +| `PUBLIC_BASE_URL` | empty | Public origin used for client-facing mockup share links; set it behind a reverse proxy if forwarded host headers are unavailable | | `GMAPS_HOST_PORT` | `8081` | Host port for the Maps worker admin UI | | `GMAPS_API_MODE` | `standard` | `standard` for the bundled worker; `saas` only for the optional SaaS API | | `GMAPS_API_KEY` | empty | Not required in standard mode; only used in SaaS mode | @@ -212,6 +223,11 @@ stores a bounded, reviewable public homepage snapshot. Promotions are managed with `GET/POST /api/promotions`, `PATCH /api/promotions/{id}`, and `DELETE /api/promotions/{id}`. An active promotion selected in the draft dialog is sent as `promotion_id` and retained on that prospect's draft. +Mockups are created with `POST /api/prospects/{id}/mockup`, listed with +`GET /api/prospects/{id}/mockups`, and opened by their public +`/mockups/{token}` share URL. Set `PUBLIC_BASE_URL` when the reverse proxy does +not forward the public host; otherwise the link is built from the request's +forwarded host or `Host` header. ## Tests diff --git a/api/server.py b/api/server.py index 95fe658..7207090 100644 --- a/api/server.py +++ b/api/server.py @@ -24,11 +24,12 @@ import urllib.error import urllib.request from datetime import datetime, timezone from html.parser import HTMLParser +from html import escape as html_escape 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 +from urllib.parse import parse_qs, quote, quote_plus, unquote, urlparse try: from intelligence import domain_candidates, scan_website, _safe_url @@ -65,6 +66,7 @@ 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" +PUBLIC_BASE_URL = os.environ.get("PUBLIC_BASE_URL", "").strip().rstrip("/") BACKUP_DIR = os.environ.get("BACKUP_DIR", "").strip() try: BACKUP_RETENTION = max(1, min(100, int(os.environ.get("BACKUP_RETENTION", "14")))) @@ -462,6 +464,7 @@ def db() -> sqlite3.Connection: ai_unknowns TEXT NOT NULL DEFAULT '[]', ai_evidence_ids TEXT NOT NULL DEFAULT '[]', ai_follow_up TEXT NOT NULL DEFAULT '', + ai_mockup_id INTEGER, opportunity_score INTEGER NOT NULL DEFAULT 0, raw_json TEXT NOT NULL DEFAULT '{}' )""") @@ -598,6 +601,22 @@ def db() -> sqlite3.Connection: model TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL )""") + conn.execute("""CREATE TABLE IF NOT EXISTS mockups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + organization_id INTEGER NOT NULL DEFAULT 1, + prospect_id INTEGER NOT NULL, + created_by INTEGER, + token TEXT NOT NULL UNIQUE, + title TEXT NOT NULL DEFAULT '', + source_url TEXT NOT NULL DEFAULT '', + html TEXT NOT NULL, + ai_model TEXT NOT NULL DEFAULT '', + generation_source TEXT NOT NULL DEFAULT 'fallback', + instructions TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'published', + created_at TEXT NOT NULL, + updated_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, @@ -666,6 +685,7 @@ def db() -> sqlite3.Connection: "ai_evidence_ids": "TEXT NOT NULL DEFAULT '[]'", "ai_follow_up": "TEXT NOT NULL DEFAULT ''", "ai_promotion_id": "INTEGER", + "ai_mockup_id": "INTEGER", "opportunity_score": "INTEGER NOT NULL DEFAULT 0", "raw_json": "TEXT NOT NULL DEFAULT '{}'", } @@ -705,12 +725,13 @@ def db() -> sqlite3.Connection: "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"}, + "mockups": {"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"): + for table in ("prospects", "runs", "suppressions", "schedules", "website_candidates", "logs", "promotions", "prospect_notes", "prospect_tasks", "score_history", "ai_assessment_history", "audit_events", "run_events", "mockups"): 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)") @@ -729,6 +750,8 @@ def db() -> sqlite3.Connection: 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.execute("CREATE INDEX IF NOT EXISTS idx_mockups_prospect ON mockups(organization_id, prospect_id, id DESC)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_mockups_token ON mockups(token)") conn.commit() _schema_path = DB_PATH _schema_lock.release() @@ -1921,7 +1944,7 @@ def scheduler_loop() -> None: time.sleep(30) -def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm", goal: str = "conversation", promotion: dict[str, Any] | None = None) -> dict[str, Any]: +def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm", goal: str = "conversation", promotion: dict[str, Any] | None = None, mockup: dict[str, Any] | None = None) -> dict[str, Any]: """Generate an evidence-grounded, human-editable outreach draft. ``tone`` and ``goal`` are deliberately small, validated controls rather @@ -1945,6 +1968,8 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm" evidence_ids.append("public_email_listing") if prospect.get("phone"): evidence_ids.append("public_phone_listing") + if mockup and mockup.get("share_url"): + evidence_ids.append("mockup_preview") tone_options = {"warm": "warm and approachable", "direct": "direct and concise", "consultative": "consultative and helpful"} goal_options = {"conversation": "start a low-pressure conversation", "review": "offer a short website/hosting review", "quote": "invite them to request pricing or a quote"} tone_key = str(tone or "warm").strip().lower() @@ -1990,6 +2015,10 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm" "name": promotion.get("name"), "description": promotion.get("description"), "terms": promotion.get("terms"), "starts_at": promotion.get("starts_at"), "ends_at": promotion.get("ends_at"), } if promotion else None, + "mockup_preview": { + "title": mockup.get("title"), "url": mockup.get("share_url"), + "source": mockup.get("generation_source"), + } if mockup and mockup.get("share_url") else None, } system = ( "You are an expert B2B copywriter for a small South African web-hosting and website-design business. " @@ -2012,7 +2041,9 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm" "unknowns, evidence_used, follow_up. subject and message are plain text. rationale is concise. " "recommended_offer is a short summary of the user's offer. unknowns is an array of facts to confirm. " "evidence_used is an array containing only supplied evidence IDs. follow_up is a short human review note, " - "not part of the email. If a selected promotion is supplied, mention it naturally only when it is active and its exact terms are supplied; never invent an expiry, discount, price, or eligibility rule." + "not part of the email. If a selected promotion is supplied, mention it naturally only when it is active and its exact terms are supplied; never invent an expiry, discount, price, or eligibility rule. " + "If a mockup_preview URL is supplied, you may mention it once as an optional concept preview. Use the exact URL, " + "call it a concept/preview rather than a live or finished site, and never imply that the prospect approved it." ) prompt = json.dumps({"evidence": evidence, "user_offer_or_instruction": offer[:2000], "tone": tone_key, "goal": goal_key}, ensure_ascii=False) headers = {"Authorization": "Bearer " + ai_api_key} if ai_api_key else {} @@ -2037,14 +2068,23 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm" return [] return [str(item).strip()[:size] for item in value if str(item).strip()][:limit] used = [item for item in text_list(parsed.get("evidence_used"), limit=10, size=80) if item in evidence_ids] + message_text = str(parsed["message"]).strip()[:8000] + mockup_url = str(mockup.get("share_url") or "").strip() if mockup else "" + # The link is an explicit part of the requested workflow. If the model + # omits it despite receiving the evidence, append a clear editable line. + if mockup_url and mockup_url not in message_text: + message_text = (message_text.rstrip() + "\n\nI also prepared a concept preview for you: " + mockup_url).strip()[:8000] + if mockup_url and "mockup_preview" not in used: + used.append("mockup_preview") return { "subject": str(parsed["subject"]).strip()[:200], - "message": str(parsed["message"]).strip()[:8000], + "message": message_text, "rationale": str(parsed.get("rationale") or "").strip()[:2000], "recommended_offer": str(parsed.get("recommended_offer") or offer).strip()[:2000], "unknowns": text_list(parsed.get("unknowns")), "evidence_used": used, "follow_up": str(parsed.get("follow_up") or "Review the draft and confirm the offer before sending.").strip()[:1000], + "mockup_url": mockup_url, } @@ -2131,6 +2171,101 @@ def ai_assess_prospect(prospect: dict[str, Any]) -> dict[str, Any]: } +def _mockup_text(value: Any, limit: int = 6000) -> str: + return re.sub(r"\s+", " ", str(value or "")).strip()[:limit] + + +def sanitize_mockup_html(value: Any) -> str: + """Remove active or unsafe markup before a generated preview is published.""" + text = str(value or "").strip() + if text.startswith("```"): + text = re.sub(r"^```(?:html)?\s*|\s*```$", "", text, flags=re.I | re.S).strip() + text = re.sub(r"]*>.*?", "", text, flags=re.I | re.S) + text = re.sub(r"]*>.*?", "", text, flags=re.I | re.S) + text = re.sub(r"]*>.*?", "", text, flags=re.I | re.S) + text = re.sub(r"]*>", "", text, flags=re.I | re.S) + text = re.sub(r"]*>", "", text, flags=re.I | re.S) + text = re.sub(r"]*(?:http-equiv\s*=\s*['\"]?refresh|content\s*=\s*['\"]?\d+\s*;\s*url)[^>]*>", "", text, flags=re.I | re.S) + text = re.sub(r"<(?:object|embed|video|audio|base)\b[^>]*>.*?", "", text, flags=re.I | re.S) + text = re.sub(r"\s+on[a-z]+\s*=\s*(?:\"[^\"]*\"|'[^']*'|[^\s>]+)", "", text, flags=re.I) + text = re.sub(r"\s+(?:href|src)\s*=\s*(['\"])\s*javascript:[^'\"]*\1", "", text, flags=re.I) + text = re.sub(r"url\s*\(\s*['\"]?\s*javascript:[^)]+\)", "", text, flags=re.I) + if not re.search(r" str: + """Produce a polished no-AI fallback so the button always gives a preview.""" + snapshot = snapshot or {} + name = html_escape(_mockup_text(prospect.get("name"), 160) or "Your business") + category = html_escape(_mockup_text(prospect.get("category") or prospect.get("search_category"), 120) or "Local business") + address = html_escape(_mockup_text(prospect.get("address"), 240)) + phone = _mockup_text(prospect.get("phone"), 80) + email = _mockup_text(prospect.get("emails"), 160).split(",")[0].strip() + website = html_escape(_mockup_text(prospect.get("website") or snapshot.get("website_url"), 400)) + title = html_escape(_mockup_text(snapshot.get("website_title"), 160) or name) + description = html_escape(_mockup_text(prospect.get("description"), 500) or f"A clearer, faster online home for {name}.") + phone_link = html_escape(re.sub(r"[^0-9+]+", "", phone)) + email_link = html_escape(email) + contact = " · ".join(item for item in [address, html_escape(phone), html_escape(email)] if item) + return f''' +{name} · Website concept +
WEBSITE CONCEPT PREVIEW · prepared for review
{category}

{name}, made easier to find.

{description}

{title}A modern, mobile-first way to present what you do.
WHAT WE DO

Clear services. Better first impressions.

Visitors can quickly understand how {name} helps, then take the next step from any device.

01

Simple navigation

Important information is easy to find on mobile and desktop.

02

Trust at a glance

Strong hierarchy and useful contact details make the business feel established.

03

Built for action

Clear calls to action help interested visitors make contact.

ABOUT {category.upper()}

A stronger home for your business.

This concept uses the public information currently available and leaves space for your exact services, photos, proof, and preferred contact flow.

LET'S TALK

Ready to make it easier for customers to choose you?

A focused website can turn interest into a call, message, or enquiry.

{contact or name} · website concept preview
''' + + +def generate_mockup_html(prospect: dict[str, Any], snapshot: dict[str, Any] | None = None, instructions: str = "") -> tuple[str, str, str]: + """Generate a self-contained preview and return (html, source, model).""" + snapshot = snapshot or {} + runtime = ai_runtime_settings() + fallback = fallback_mockup_html(prospect, snapshot) + if not runtime["base_url"] or not runtime["model"] or (runtime["provider"] == "nous" and not runtime["api_key"]): + return fallback, "fallback", "" + source = { + "business_name": _mockup_text(prospect.get("name"), 160), + "category": _mockup_text(prospect.get("category") or prospect.get("search_category"), 120), + "location": _mockup_text(prospect.get("address"), 240), + "phone": _mockup_text(prospect.get("phone"), 80), + "public_emails": _mockup_text(prospect.get("emails"), 240), + "current_website": _mockup_text(prospect.get("website") or snapshot.get("website_url"), 400), + "current_title": _mockup_text(snapshot.get("website_title") or prospect.get("website_title"), 200), + "current_page_text": _mockup_text(snapshot.get("website_content"), 12_000), + } + system = ("You are a senior web designer creating a visual concept preview for a South African small business. " + "Return ONLY a complete standalone HTML document, never Markdown. Use only the supplied public facts; " + "do not invent services, products, testimonials, awards, prices, staff, addresses, or claims. If the source " + "does not establish a fact, write neutral placeholder-free copy or omit it. Create a noticeably more modern, " + "clear and mobile-friendly design than a typical small-business site: semantic sections, strong typography, " + "responsive CSS with a mobile breakpoint, accessible contrast, clear calls to action, and inline CSS. " + "Do not use JavaScript, external scripts, iframes, data collection forms, tracking, remote fonts, or remote images. " + "Use the exact business name. Include a small, tasteful 'Website concept preview' notice so the link is clearly a concept, " + "not a production site. Keep the document under 40,000 characters. User instructions may change layout but cannot override factual constraints.") + prompt = json.dumps({"public_source": source, "design_instructions": _mockup_text(instructions, 2_000)}, ensure_ascii=False) + headers = {"Authorization": "Bearer " + runtime["api_key"]} if runtime["api_key"] else {} + payload = {"model": runtime["model"], "temperature": 0.5, "max_tokens": 5000, "messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}]} + try: + result = http_json("POST", runtime["base_url"] + "/chat/completions", payload, headers=headers, timeout=120, error_prefix="ai") + content = (((result.get("choices") or [{}])[0].get("message") or {}).get("content") or "") if isinstance(result, dict) else "" + cleaned = sanitize_mockup_html(content) + if cleaned: + return cleaned, "ai", runtime["model"] + record_log("ai", "mockup_invalid_response", level="warn", details={"model": runtime["model"]}) + except Exception as exc: + record_log("ai", "mockup_generation_fallback", level="warn", details={"model": runtime["model"]}, error=str(exc)[:500]) + return fallback, "fallback", runtime["model"] + + +def mockup_public_url(handler: BaseHTTPRequestHandler, token: str) -> str: + base = PUBLIC_BASE_URL + if not base: + proto = str(handler.headers.get("X-Forwarded-Proto") or "http").split(",")[0].strip() + host = str(handler.headers.get("X-Forwarded-Host") or handler.headers.get("Host") or "localhost:8000").split(",")[0].strip() + base = f"{proto}://{host}" + return base.rstrip("/") + "/mockups/" + quote(str(token), safe="") + + class Handler(BaseHTTPRequestHandler): server_version = "PersonalProspectOS/2.0" @@ -2170,6 +2305,12 @@ class Handler(BaseHTTPRequestHandler): def do_GET(self) -> None: parsed = urlparse(self.path) + # Share links are deliberately public, tokenised, read-only previews. + # Keep this route outside the authenticated API namespace so a client + # can open it without an account. + public_mockup = re.fullmatch(r"/mockups/([A-Za-z0-9_-]{20,80})", parsed.path) + if public_mockup: + return self.serve_mockup(public_mockup.group(1)) if not parsed.path.startswith("/api/"): self.serve_static(parsed.path) return @@ -2339,6 +2480,21 @@ class Handler(BaseHTTPRequestHandler): return self.send_json(404, {"error": "not_found"}) 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+)/mockups", parsed.path) + if match: + prospect_id = int(match.group(1)) + organization_id = 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"}) + rows = conn.execute("""SELECT id,prospect_id,token,title,source_url,ai_model,generation_source, + instructions,status,created_at,updated_at FROM mockups + WHERE prospect_id=? AND organization_id=? ORDER BY id DESC""", (prospect_id, organization_id)).fetchall() + items = [] + for row in rows: + item = dict(row) + item["share_url"] = mockup_public_url(self, item["token"]) + items.append(item) + return self.send_json(200, {"items": items}) 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) @@ -2414,6 +2570,32 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(content) + def serve_mockup(self, token: str) -> None: + """Serve a published, sanitised concept preview by opaque token.""" + conn = db() + try: + row = conn.execute("SELECT html FROM mockups WHERE token=? AND status='published' LIMIT 1", (token,)).fetchone() + finally: + conn.close() + if not row: + body = b"Preview not found" + self.send_response(404) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + body = str(row["html"] or "").encode("utf-8", "replace") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("X-Robots-Tag", "noindex, nofollow") + self.send_header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'") + self.send_header("Cache-Control", "public, max-age=300") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + def do_POST(self) -> None: path = urlparse(self.path).path if path == "/api/auth/setup": @@ -2863,6 +3045,61 @@ class Handler(BaseHTTPRequestHandler): conn.close() except Exception: pass + mockup_match = re.fullmatch(r"/api/prospects/(\d+)/mockup", urlparse(self.path).path) + if mockup_match: + if not role_allows(getattr(self, "_auth_user", None), "reviewer"): + return self.send_json(403, {"error": "permission_denied"}) + conn: sqlite3.Connection | None = None + prospect_id = int(mockup_match.group(1)) + try: + conn = db() + organization_id = getattr(self, "_organization_id", 1) + row = conn.execute("SELECT * FROM prospects WHERE id=? AND organization_id=?", (prospect_id, organization_id)).fetchone() + if not row: + return self.send_json(404, {"error": "not_found"}) + payload = self.read_json() + instructions = str(payload.get("instructions") or "").strip()[:2_000] + prospect = dict(row) + source_url = str(prospect.get("website") or prospect.get("website_final_url") or "").strip() + snapshot: dict[str, Any] = {} + if source_url: + try: + snapshot = fetch_company_page(source_url) + except Exception as exc: + # A preview must still be useful when the current site + # is slow, blocked, or listed as unhealthy. + record_log("search", "mockup_source_fetch_failed", level="warn", prospect_id=prospect_id, + details={"url": _safe_log_url(source_url)}, error=str(exc)[:500]) + html, generation_source, model = generate_mockup_html(prospect, snapshot, instructions) + token = secrets.token_urlsafe(24) + stamp = now() + title = (_mockup_text(prospect.get("name"), 140) or "Business") + " · Website concept" + cur = conn.execute("""INSERT INTO mockups + (organization_id,prospect_id,created_by,token,title,source_url,html,ai_model,generation_source,instructions,status,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""", (organization_id, prospect_id, getattr(self, "_user_id", None), token, + title, str(snapshot.get("website_url") or source_url)[:500], html, model, generation_source, instructions, "published", stamp, stamp)) + conn.execute("UPDATE prospects SET ai_mockup_id=?,updated_at=? WHERE id=? AND organization_id=?", (int(cur.lastrowid), stamp, prospect_id, organization_id)) + conn.commit() + share_url = mockup_public_url(self, token) + record_log("ai", "mockup_generated", prospect_id=prospect_id, + details={"mockup_id": int(cur.lastrowid), "source": generation_source, "model": model, + "source_url": _safe_log_url(str(snapshot.get("website_url") or source_url)), "share_url": share_url}) + record_audit("mockup_generated", organization_id=organization_id, user_id=getattr(self, "_user_id", None), + entity_type="mockup", entity_id=int(cur.lastrowid), details={"prospect_id": prospect_id, "source": generation_source, "model": model}) + return self.send_json(201, {"id": int(cur.lastrowid), "prospect_id": prospect_id, "token": token, + "title": title, "source_url": str(snapshot.get("website_url") or source_url), + "generation_source": generation_source, "ai_model": model, + "created_at": stamp, "share_url": share_url}) + 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)}) + except sqlite3.Error as exc: + record_log("ai", "mockup_persist_failed", level="error", prospect_id=prospect_id, error=str(exc)[:500]) + return self.send_json(500, {"error": "mockup_persist_failed"}) + finally: + if conn is not None: + conn.close() 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"): @@ -2917,8 +3154,29 @@ class Handler(BaseHTTPRequestHandler): promotion = dict(promotion_row) 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=?,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() + include_value = payload.get("include_mockup", True) + include_mockup = not (include_value is False or str(include_value).strip().lower() in {"0", "false", "off", "no"}) + mockup = None + mockup_id = payload.get("mockup_id") + if include_mockup: + if mockup_id not in (None, "", 0, "0"): + try: + mockup_id = int(mockup_id) + except (TypeError, ValueError): + return self.send_json(400, {"error": "invalid_mockup_id"}) + mockup_row = conn.execute("SELECT * FROM mockups WHERE id=? AND prospect_id=? AND organization_id=? AND status='published'", (mockup_id, int(message_match.group(1)), getattr(self, "_organization_id", 1))).fetchone() + else: + mockup_row = conn.execute("SELECT * FROM mockups WHERE prospect_id=? AND organization_id=? AND status='published' ORDER BY id DESC LIMIT 1", (int(message_match.group(1)), getattr(self, "_organization_id", 1))).fetchone() + if mockup_row: + mockup = dict(mockup_row) + mockup["share_url"] = mockup_public_url(self, mockup["token"]) + mockup_id = int(mockup["id"]) + else: + mockup_id = None + else: + mockup_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, mockup=mockup) + stamp = now(); conn.execute("UPDATE prospects SET ai_subject=?,ai_message=?,ai_offer=?,ai_promotion_id=?,ai_mockup_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,mockup_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)}) diff --git a/docker-compose.yml b/docker-compose.yml index 00d6221..d41223a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,7 @@ services: - "8080:8000" environment: PROSPECT_DB: /data/prospects.db + PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-} PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH: /usr/bin/chromium DISCOVERY_BACKEND: ${DISCOVERY_BACKEND:-gmaps} GMAPS_URL: ${GMAPS_URL:-http://gmaps:8080} diff --git a/tests/test_app.py b/tests/test_app.py index 85eaed0..6bd5420 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -159,6 +159,33 @@ class BrowserOnlyAppTests(unittest.TestCase): saved = server.db().execute("SELECT ai_probability,ai_recommendation,ai_reasoning FROM prospects WHERE id=1").fetchone() self.assertEqual(tuple(saved), (84.0, "contact", "Active business with a clear website opportunity.")) + def test_mockup_fallback_is_saved_and_public_share_link_works(self): + conn = server.db() + conn.execute("INSERT INTO prospects(name,domain,category,address,phone,emails,website,website_status,discovered_at) VALUES(?,?,?,?,?,?,?,?,?)", ("Acme Plumbing", "maps:acme", "plumbers", "Cape Town", "+27101234567", "info@acme.co.za", "", "no_detected_website", server.now())) + conn.commit(); conn.close() + status, created = self.request("POST", "/api/prospects/1/mockup", {"instructions": "Keep it friendly"}) + self.assertEqual(status, 201) + self.assertIn("/mockups/", created["share_url"]) + self.assertEqual(created["generation_source"], "fallback") + status, listed = self.request("GET", "/api/prospects/1/mockups") + self.assertEqual(status, 200) + self.assertEqual(listed["items"][0]["id"], created["id"]) + preview_path = "/mockups/" + created["token"] + self.conn.request("GET", preview_path) + response = self.conn.getresponse() + body = response.read().decode("utf-8") + self.assertEqual(response.status, 200) + self.assertIn("text/html", response.getheader("Content-Type")) + self.assertIn("Acme Plumbing", body) + self.assertNotIn("Hi') + self.assertIn("", html) + self.assertNotIn("script", html.lower()) + self.assertNotIn("onload", html.lower()) + self.assertNotIn("javascript:", html.lower()) + def test_browser_investigator_normalises_and_filters_candidates(self): raw = '{"candidates":[{"url":"https://www.acme.co.za/about","confidence":88,"evidence":["name match"]},{"url":"https://facebook.com/acme","confidence":99},{"domain":"other.co.za","confidence":"42","evidence":"not a list"}]}' candidates = browser_investigator._parse_agent_result(raw) @@ -239,6 +266,21 @@ class BrowserOnlyAppTests(unittest.TestCase): self.assertEqual(json.loads(saved[0]), body["unknowns"]) self.assertEqual(json.loads(saved[1]), body["evidence_used"]) + def test_email_draft_can_include_latest_mockup_link(self): + conn = server.db() + conn.execute("INSERT INTO prospects(name,domain,category,discovered_at) VALUES(?,?,?,?)", ("Acme", "maps:acme", "plumber", server.now())) + conn.execute("INSERT INTO mockups(organization_id,prospect_id,token,title,html,created_at,updated_at) VALUES(?,?,?,?,?,?,?)", (1, 1, "a" * 24, "Acme · Website concept", "Acme", server.now(), server.now())) + for key, value in (("AI_PROVIDER", "custom"), ("AI_BASE_URL", "https://ai.example/v1"), ("AI_MODEL", "test-model"), ("AI_API_KEY", "test-key")): + conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?)", (key, value, server.now())) + conn.commit(); conn.close() + content = json.dumps({"subject": "A concept for Acme", "message": "Good day Acme,\n\nWe can help.", "rationale": "Uses the supplied record.", "recommended_offer": "Website", "unknowns": [], "evidence_used": ["prospect_record"], "follow_up": "Review before sending."}) + with patch("api.server.http_json", return_value={"choices": [{"message": {"content": content}}]}): + status, body = self.request("POST", "/api/prospects/1/message", {"offer": "A modern website"}) + self.assertEqual(status, 200) + self.assertIn("/mockups/", body["message"]) + self.assertIn("mockup_preview", body["evidence_used"]) + self.assertTrue(body["mockup_url"]) + def test_logs_capture_api_actions_and_redact_credentials(self): server.record_log("ai", "test_ai_call", details={"api_key": "do-not-store", "model": "test", "prompt": "hello"}) status, body = self.request("POST", "/api/logs", {"action": "ui_test", "details": {"button": "test"}}) diff --git a/web/app.js b/web/app.js index 40d9f56..32b43e9 100644 --- a/web/app.js +++ b/web/app.js @@ -2,7 +2,7 @@ const $=id=>document.getElementById(id); const esc=v=>String(v??"").replace(/[&< 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 logAction(action,details={},level='info'){try{const headers={'Content-Type':'application/json'};if(csrfToken)headers['X-CSRF-Token']=csrfToken;fetch('/api/logs',{method:'POST',credentials:'same-origin',headers,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','≋'],['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'; @@ -76,7 +76,7 @@ function jsonArray(value){try{const parsed=JSON.parse(value||'[]');return Array. function showDraftNotes(d){let notes=$('draftNotes');if(!notes){notes=document.createElement('div');notes.id='draftNotes';notes.className='draft-notes';const status=$('messageStatus');if(status)status.parentNode.insertBefore(notes,status)}if(!notes)return;const unknowns=Array.isArray(d?.unknowns)?d.unknowns:[];const used=Array.isArray(d?.evidence_used)?d.evidence_used:[];notes.innerHTML=`AI review notes

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

${unknowns.length?`

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

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

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

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

${esc(d.follow_up)}

`:''}`} function promotionOptions(){return (Array.isArray(settings.promotions)?settings.promotions:[]).filter(item=>item.active).map(item=>``).join('')} function ensureMessageDialog(force=false){const dialog=$('messageDialog');const form=$('messageForm');if(dialog)dialog.classList.add('message-dialog');if(!form||(!force&&$('messageTone')))return;form.innerHTML=`

Tailor a message

Start from verified prospect evidence, your company knowledge, and any selected promotion.

`} -async function openMessage(id){ensureMessageDialog(true);selected=prospects.find(p=>String(p.id)===String(id));if(!selected)return;const score=effectiveScore(selected);$('messageTitle').textContent='Draft for '+selected.name;const context=$('messageContext');if(context)context.innerHTML=`
${score}
${esc(selected.name)}${esc(prospectCategory(selected))} · ${esc(selected.address||'Location not listed')}
${selected.website?`Website: ${esc((selected.website_status||'detected').replaceAll('_',' '))}`:'No website detected'}${selected.emails?`Public email: ${esc(selected.emails)}`:'No public email found'}
`;$('offerInput').value=suggestedOffer(selected);if($('messageTone'))$('messageTone').value='warm';if($('messageGoal'))$('messageGoal').value='conversation';if($('messagePromotion'))$('messagePromotion').value=selected.ai_promotion_id&&[...$('messagePromotion').options].some(option=>option.value===String(selected.ai_promotion_id))?String(selected.ai_promotion_id):'';$('subjectOutput').value=selected.ai_subject||'';$('bodyOutput').value=selected.ai_message||'';$('messageStatus').textContent='';$('messageStatus').className='notice';showDraftNotes({rationale:selected.ai_reasoning,unknowns:jsonArray(selected.ai_unknowns),evidence_used:jsonArray(selected.ai_evidence_ids),follow_up:selected.ai_follow_up});$('messageDialog').showModal()} +async function openMessage(id){ensureMessageDialog(true);selected=prospects.find(p=>String(p.id)===String(id));if(!selected)return;const score=effectiveScore(selected);$('messageTitle').textContent='Draft for '+selected.name;const context=$('messageContext');if(context)context.innerHTML=`
${score}
${esc(selected.name)}${esc(prospectCategory(selected))} · ${esc(selected.address||'Location not listed')}
${selected.website?`Website: ${esc((selected.website_status||'detected').replaceAll('_',' '))}`:'No website detected'}${selected.emails?`Public email: ${esc(selected.emails)}`:'No public email found'}
`;$('offerInput').value=suggestedOffer(selected);if($('messageTone'))$('messageTone').value='warm';if($('messageGoal'))$('messageGoal').value='conversation';if($('messagePromotion'))$('messagePromotion').value=selected.ai_promotion_id&&[...$('messagePromotion').options].some(option=>option.value===String(selected.ai_promotion_id))?String(selected.ai_promotion_id):'';$('subjectOutput').value=selected.ai_subject||'';$('bodyOutput').value=selected.ai_message||'';$('messageStatus').textContent='';$('messageStatus').className='notice';showDraftNotes({rationale:selected.ai_reasoning,unknowns:jsonArray(selected.ai_unknowns),evidence_used:jsonArray(selected.ai_evidence_ids),follow_up:selected.ai_follow_up});$('messageDialog').showModal();loadMessageMockupOption(id)} function reviewStatusLabel(status){return status==='approved'?'Accepted':status==='rejected'?'Rejected':status==='contacted'?'Contacted':'In review'} function reviewStatusTabs(p){const options=[['pending','In review'],['approved','Accepted'],['rejected','Rejected'],['contacted','Contacted']];return `

Review status

Saved to this prospect
${options.map(([value,label])=>``).join('')}
`} function openDrawer(id){const p=prospects.find(x=>String(x.id)===String(id));if(!p)return;selected=p;const score=effectiveScore(p);const assessed=Boolean(p.ai_checked_at&&Number.isFinite(Number(p.ai_probability)));$('drawerName').textContent=p.name;let reasons=[];try{reasons=JSON.parse(p.score_reasons||'[]')}catch{};$('drawerBody').innerHTML=`
${score}
${scoreLabel(p)}${assessed?'Updated from the latest AI assessment':'Explainable triage signal; assess with AI to update this score'}
${reviewStatusTabs(p)}

Contact

${esc(p.emails||'No public email found')}

${esc(p.phone||'No phone listed')}

Website health

Status${esc((p.website_status||'unknown').replaceAll('_',' '))}HTTPS${p.website_https?'Yes':'No / unknown'}Latency${p.website_latency_ms?p.website_latency_ms+' ms':'—'}Checked${date(p.website_checked_at)}

Why this scored

    ${reasons.map(r=>`
  • ${esc(r)}
  • `).join('')||'
  • No score reasons recorded
  • '}
${p.website?`Open website ↗`:''}
`;$('detailDrawer').classList.add('open');$('detailDrawer').setAttribute('aria-hidden','false')} @@ -105,14 +105,14 @@ document.addEventListener('submit',async e=>{ if(e.target.id==='teachAiForm'){e.preventDefault();const m=$('teachAiMessage');m.textContent='Saving…';m.className='notice';try{const result=await api('/api/settings',{method:'POST',body:JSON.stringify({company_profile:{website_url:$('companyWebsiteUrl').value,name:$('companyName').value,description:$('companyDescription').value,services:$('companyServices').value,packages:$('companyPackages').value,tone:$('companyTone').value,notes:$('companyNotes').value}})});settings.company_profile=result.company_profile||settings.company_profile;m.textContent='Company knowledge saved.';m.className='notice ok'}catch(x){m.textContent=x.message;m.className='notice error'}} if(e.target.id==='promotionForm'){e.preventDefault();const m=$('promotionMessage');m.textContent='Saving…';m.className='notice';try{await api('/api/promotions',{method:'POST',body:JSON.stringify({name:$('promotionName').value,description:$('promotionDescription').value,terms:$('promotionTerms').value,starts_at:$('promotionStarts').value,ends_at:$('promotionEnds').value,active:$('promotionActive').checked})});route()}catch(x){m.textContent=x.message;m.className='notice error'}} }); -document.addEventListener('click',async e=>{if(e.target.id==='generateMessage'){if(!selected)return;const s=$('messageStatus');const button=e.target;button.disabled=true;button.textContent='Writing…';s.textContent='The AI is comparing the evidence with your offer…';try{const d=await api(`/api/prospects/${selected.id}/message`,{method:'POST',body:JSON.stringify({offer:$('offerInput').value,tone:$('messageTone')?.value||'warm',goal:$('messageGoal')?.value||'conversation',promotion_id:$('messagePromotion')?.value||null})});$('subjectOutput').value=d.subject;$('bodyOutput').value=d.message;selected={...selected,ai_subject:d.subject,ai_message:d.message,ai_offer:$('offerInput').value,ai_promotion_id:$('messagePromotion')?.value?Number($('messagePromotion').value):null,ai_reasoning:d.rationale||'',ai_unknowns:JSON.stringify(d.unknowns||[]),ai_evidence_ids:JSON.stringify(d.evidence_used||[]),ai_follow_up:d.follow_up||''};const i=prospects.findIndex(p=>String(p.id)===String(selected.id));if(i>=0)prospects[i]=selected;showDraftNotes(d);s.textContent='Draft ready. Edit it, verify the facts, then copy it to your mail client.';s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}finally{button.disabled=false;button.textContent='Regenerate draft'}}if(e.target.id==='copyMessage'){await navigator.clipboard.writeText(`Subject: ${$('subjectOutput').value}\n\n${$('bodyOutput').value}`);$('messageStatus').textContent='Copied to clipboard.'}if(e.target.id==='testAi'){const s=$('settingsMessage');s.textContent='Testing…';s.className='notice';try{const d=await api('/api/ai/test',{method:'POST',body:'{}'});s.textContent=`Connection works · ${d.provider} · ${d.model}`;s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}}}); +document.addEventListener('click',async e=>{if(e.target.id==='generateMessage'){if(!selected)return;const s=$('messageStatus');const button=e.target;button.disabled=true;button.textContent='Writing…';s.textContent='The AI is comparing the evidence with your offer…';try{const d=await api(`/api/prospects/${selected.id}/message`,{method:'POST',body:JSON.stringify({offer:$('offerInput').value,tone:$('messageTone')?.value||'warm',goal:$('messageGoal')?.value||'conversation',promotion_id:$('messagePromotion')?.value||null,include_mockup:$('includeMockup')?.checked!==false,mockup_id:$('includeMockup')?.dataset.mockupId||null})});$('subjectOutput').value=d.subject;$('bodyOutput').value=d.message;selected={...selected,ai_subject:d.subject,ai_message:d.message,ai_offer:$('offerInput').value,ai_promotion_id:$('messagePromotion')?.value?Number($('messagePromotion').value):null,ai_mockup_id:d.mockup_url?(Number($('includeMockup')?.dataset.mockupId||selected.ai_mockup_id||0)||null):null,ai_reasoning:d.rationale||'',ai_unknowns:JSON.stringify(d.unknowns||[]),ai_evidence_ids:JSON.stringify(d.evidence_used||[]),ai_follow_up:d.follow_up||''};const i=prospects.findIndex(p=>String(p.id)===String(selected.id));if(i>=0)prospects[i]=selected;showDraftNotes(d);s.textContent='Draft ready. Edit it, verify the facts, then copy it to your mail client.';s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}finally{button.disabled=false;button.textContent='Regenerate draft'}}if(e.target.id==='copyMessage'){await navigator.clipboard.writeText(`Subject: ${$('subjectOutput').value}\n\n${$('bodyOutput').value}`);$('messageStatus').textContent='Copied to clipboard.'}if(e.target.id==='testAi'){const s=$('settingsMessage');s.textContent='Testing…';s.className='notice';try{const d=await api('/api/ai/test',{method:'POST',body:'{}'});s.textContent=`Connection works · ${d.provider} · ${d.model}`;s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}}}); document.addEventListener('click',async e=>{if(e.target.id==='learnCompanyWebsite'){const button=e.target,m=$('teachAiLearnMessage');const url=$('companyWebsiteUrl')?.value||'';if(!url){m.textContent='Enter a website URL first.';m.className='notice error';return}button.disabled=true;button.textContent='Reading…';m.textContent='Fetching the public page…';m.className='notice';try{const result=await api('/api/company-profile/learn',{method:'POST',body:JSON.stringify({url})});settings.company_profile=result.company_profile||settings.company_profile;const p=settings.company_profile||{};$('companyWebsiteUrl').value=p.website_url||url;if($('companyName')&&!$('companyName').value&&p.website_title)$('companyName').value=p.website_title;m.textContent=`Website snapshot saved (${(p.website_content||'').length.toLocaleString()} characters). Review it, then save your company knowledge.`;m.className='notice ok'}catch(x){m.textContent=x.message;m.className='notice error'}finally{button.disabled=false;button.textContent='Read website now'}}const toggle=e.target.closest('[data-promotion-active]');if(toggle){try{await api(`/api/promotions/${toggle.dataset.promotionActive}`,{method:'PATCH',body:JSON.stringify({active:toggle.checked})});const item=(settings.promotions||[]).find(p=>String(p.id)===String(toggle.dataset.promotionActive));if(item)item.active=toggle.checked?1:0;renderPromotions()}catch(x){toggle.checked=!toggle.checked;alert(x.message)}}const remove=e.target.closest('[data-delete-promotion]');if(remove){if(!confirm('Delete this promotion?'))return;remove.disabled=true;try{await api(`/api/promotions/${remove.dataset.deletePromotion}`,{method:'DELETE'});settings.promotions=(settings.promotions||[]).filter(p=>String(p.id)!==String(remove.dataset.deletePromotion));renderPromotions()}catch(x){remove.disabled=false;alert(x.message)}}}); function aiRecommendationLabel(value){return value==='contact'?'Contact recommended':value==='review'?'Review first':'Do not contact yet'} function addAssessmentPanel(id){const p=prospects.find(x=>String(x.id)===String(id));const body=$('drawerBody');if(!p||!body)return;const has=Number.isFinite(Number(p.ai_probability))&&p.ai_checked_at;const section=document.createElement('section');section.className='drawer-section';section.id='aiAssessmentPanel';section.innerHTML=has?`

AI assessment

Contact probability${Math.round(Number(p.ai_probability))}%Recommendation${esc(aiRecommendationLabel(p.ai_recommendation))}

${esc(p.ai_reasoning||'No rationale returned.')}

Assessed ${date(p.ai_checked_at)}

`:`

AI assessment

Use the collected evidence to estimate whether this company is worth contacting.

`;const actions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);else body.append(section)} function candidateEvidence(candidate){try{const value=JSON.parse(candidate.evidence||'[]');return Array.isArray(value)?value.filter(Boolean).map(String):[]}catch{return[]}} function renderWebsiteCandidates(items,id){const section=$('websiteCandidatesPanel');if(!section)return;const rows=Array.isArray(items)?items:[];section.innerHTML=`

Possible official websites

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

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

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

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

No evidence returned.

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

No plausible official website candidates found yet.

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

Find a possible website

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

Requires a tested AI provider in Settings.

`;const actions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);else body.append(section);api(`/api/prospects/${p.id}/website-candidates`).then(d=>{if((d.items||[]).length)renderWebsiteCandidates(d.items,p.id)}).catch(()=>{})} -const _openDrawerWithAssessment=openDrawer;openDrawer=function(id){_openDrawerWithAssessment(id);addAssessmentPanel(id);addWebsitePanel(id);addWorkflowPanel(id);addHistoryPanel(id)} +const _openDrawerWithAssessment=openDrawer;openDrawer=function(id){_openDrawerWithAssessment(id);addAssessmentPanel(id);addWebsitePanel(id);addWorkflowPanel(id);addHistoryPanel(id);addMockupPanel(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'}}} @@ -143,3 +143,10 @@ function renderUsers(){const canManage=['owner','admin'].includes(currentUser?.r document.addEventListener('submit',async e=>{if(e.target.id==='authLoginForm'||e.target.id==='authSetupForm'){e.preventDefault();await submitAuth(e.target);return}if(e.target.id==='userForm'){e.preventDefault();const m=$('userMessage');try{await api('/api/users',{method:'POST',body:JSON.stringify({name:$('newUserName').value,email:$('newUserEmail').value,password:$('newUserPassword').value,role:$('newUserRole').value})});m.textContent='User created.';m.className='notice ok';e.target.reset();refreshUsersPage()}catch(error){m.textContent=error.message;m.className='notice error'}}if(e.target.id==='workspaceForm'){e.preventDefault();try{await api('/api/workspace',{method:'POST',body:JSON.stringify({name:$('workspaceName').value})});refreshUsersPage()}catch(error){alert(error.message)}}if(e.target.id==='noteForm'){e.preventDefault();try{await api(`/api/prospects/${selected.id}/notes`,{method:'POST',body:JSON.stringify({body:$('noteBody').value})});openDrawer(selected.id)}catch(error){alert(error.message)}}if(e.target.id==='taskForm'){e.preventDefault();try{await api(`/api/prospects/${selected.id}/tasks`,{method:'POST',body:JSON.stringify({title:$('taskTitle').value,due_at:$('taskDue').value})});openDrawer(selected.id)}catch(error){alert(error.message)}}}); document.addEventListener('change',async e=>{if(e.target.id==='assignmentSelect'&&selected){try{const updated=await api(`/api/prospects/${selected.id}/assignment`,{method:'POST',body:JSON.stringify({assigned_user_id:e.target.value||null})});selected=updated;const i=prospects.findIndex(p=>String(p.id)===String(updated.id));if(i>=0)prospects[i]=updated}catch(error){alert(error.message)}}if(e.target.dataset.userRole){try{await api(`/api/users/${e.target.dataset.userRole}`,{method:'PATCH',body:JSON.stringify({role:e.target.value})});refreshUsersPage()}catch(error){alert(error.message)}}if(e.target.dataset.userActive){try{await api(`/api/users/${e.target.dataset.userActive}`,{method:'PATCH',body:JSON.stringify({active:e.target.checked})});refreshUsersPage()}catch(error){e.target.checked=!e.target.checked;alert(error.message)}}if(e.target.dataset.taskStatus&&selected){try{await api(`/api/prospects/${selected.id}/tasks/${e.target.dataset.taskStatus}`,{method:'PATCH',body:JSON.stringify({status:e.target.checked?'done':'open'})});openDrawer(selected.id)}catch(error){alert(error.message)}}}); document.addEventListener('click',async e=>{if(e.target.id==='logoutButton'){try{await fetch('/api/auth/logout',{method:'POST',credentials:'same-origin',headers:{'X-CSRF-Token':csrfToken}})}finally{currentUser=null;csrfToken='';bootstrapAuth()}}if(e.target.id==='refreshUsers')refreshUsersPage();if(e.target.id==='createBackup'){e.target.disabled=true;try{await api('/api/backups',{method:'POST',body:'{}'});refreshUsersPage()}catch(error){alert(error.message)}finally{e.target.disabled=false}}const retry=e.target.closest('[data-retry-run]');if(retry){retry.disabled=true;try{await api(`/api/runs/${retry.dataset.retryRun}/retry`,{method:'POST',body:'{}'});route()}catch(error){alert(error.message)}finally{retry.disabled=false}}const cancel=e.target.closest('[data-cancel-run]');if(cancel){cancel.disabled=true;try{await api(`/api/runs/${cancel.dataset.cancelRun}/cancel`,{method:'POST',body:'{}'});route()}catch(error){alert(error.message)}finally{cancel.disabled=false}}const events=e.target.closest('[data-run-events]');if(events){try{const data=await api(`/api/runs/${events.dataset.runEvents}/events`);const panel=$('runEventsPanel');if(panel)panel.innerHTML=`

Run #${events.dataset.runEvents} events

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

No events.

'}
`}catch(error){alert(error.message)}}}); + +/* Website concept previews */ +function renderMockupItems(items=[]){return items.length?`
${items.map(item=>`
${esc(item.title||'Website concept preview')}${esc(item.generation_source==='ai'?'AI-generated':'Instant fallback')} · ${date(item.created_at)}${item.source_url?`Based on ${esc(item.source_url)}`:''}
`).join('')}
`:'

No preview yet. Generate one from the public facts already collected for this prospect.

'} +function addMockupPanel(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 mockup-panel';section.id='mockupsPanel';section.innerHTML=`

Website concept preview

A shareable, mobile-friendly redesign concept based on the public evidence.
Concept only

The preview is static and sanitised: no forms, scripts, tracking, or invented customer claims.

Loading previews…
`;const actions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);else body.append(section);api(`/api/prospects/${p.id}/mockups`).then(data=>{const list=$('mockupsList');if(list)list.innerHTML=renderMockupItems(data.items||[])}).catch(error=>{const list=$('mockupsList');if(list)list.innerHTML=`

${esc(error.message)}

`})} +async function generateMockupPreview(id,button){const status=$('mockupStatus');if(button)button.disabled=true;if(status){status.textContent='Generating preview… The current public website may take a moment to read.';status.className='notice'}try{const data=await api(`/api/prospects/${id}/mockup`,{method:'POST',body:JSON.stringify({instructions:''})});if(status){status.innerHTML=`Preview ready. Open share link ↗`;status.className='notice ok'}const list=$('mockupsList');if(list){const latest=await api(`/api/prospects/${id}/mockups`);list.innerHTML=renderMockupItems(latest.items||[])}return data}catch(error){if(status){status.textContent=error.message;status.className='notice error'}throw error}finally{if(button)button.disabled=false}} +async function loadMessageMockupOption(id){const context=$('messageContext');if(!context)return;context.querySelector('.mockup-option')?.remove();try{const data=await api(`/api/prospects/${id}/mockups`);const latest=(data.items||[])[0];if(!latest)return;const option=document.createElement('label');option.className='mockup-option';option.innerHTML=`Include latest concept previewShare the editable preview link once in the email. Open it ↗`;context.append(option)}catch(error){logAction('mockup_option_load_failed',{prospect_id:id,error:error.message},'warn')}} +document.addEventListener('click',async e=>{const generate=e.target.closest('[data-generate-mockup]');if(generate){e.preventDefault();try{await generateMockupPreview(generate.dataset.generateMockup,generate)}catch{}return}const copy=e.target.closest('[data-copy-mockup]');if(copy){e.preventDefault();try{await navigator.clipboard.writeText(copy.dataset.copyMockup);copy.textContent='Copied';setTimeout(()=>{copy.textContent='Copy link'},1400)}catch{prompt('Copy preview link',copy.dataset.copyMockup)}}}); diff --git a/web/index.html b/web/index.html index feb6e90..f836a9a 100644 --- a/web/index.html +++ b/web/index.html @@ -1,3 +1,3 @@ -ProspectOS -
Workspace/Dashboard
—
ProspectOS · public data only · manual review before contact

Tailor a message

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

+ProspectOS +
Workspace/Dashboard
—
ProspectOS · public data only · manual review before contact

Tailor a message

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

diff --git a/web/style.css b/web/style.css index c131b8d..6fcf161 100644 --- a/web/style.css +++ b/web/style.css @@ -146,3 +146,13 @@ input::placeholder { color: #aeb6c5; } input:focus, select:focus { border-color: /* workspace access, ownership, history, and recovery */ .auth-hidden{visibility:hidden}.auth-hidden *{pointer-events:none}.auth-card{position:fixed;inset:0;z-index:100;display:grid;align-content:center;justify-items:start;gap:11px;padding:42px;box-sizing:border-box;background:radial-gradient(circle at 75% 10%,#eceaff 0,#f7f8fc 42%,#f7f8fc 100%)}.auth-card>*,.auth-card form{width:min(430px,100%)}.auth-brand{display:flex;align-items:center;gap:11px;margin-bottom:20px}.auth-brand b,.auth-brand small{display:block}.auth-brand b{font-size:16px}.auth-brand small{color:#8995a8;font-size:11px}.auth-card h1{margin:4px 0;font-size:32px;letter-spacing:-.8px}.auth-card>p{margin:0 0 14px;color:#7c899c;font-size:13px}.auth-card form{display:grid;gap:13px;padding:24px;border:1px solid #e0e5ef;border-radius:16px;background:#fff;box-shadow:0 15px 45px #25355714}.auth-card label{display:grid;gap:6px;color:#4d5c73;font-size:12px;font-weight:800}.auth-card input{margin:0}.auth-card button{height:44px}.user-grid{align-items:start}.workspace-info{padding:15px 0}.workspace-stat{display:grid;gap:4px}.workspace-stat b{font-size:18px}.workspace-stat span{color:#8a96a8;font-size:12px}.user-form{display:grid;gap:12px;margin-top:16px}.user-form label{display:grid;gap:6px;color:#4d5c73;font-size:12px;font-weight:800}.user-form input,.user-form select{margin:0}.user-list,.backup-list,.audit-list{display:grid;gap:0;margin-top:10px}.user-row,.backup-row,.audit-row{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:13px 0;border-top:1px solid #edf0f5}.user-row b,.backup-row b,.audit-row b{display:block;font-size:12px}.user-row small,.backup-row span,.audit-row span{display:block;margin-top:4px;color:#8a96a8;font-size:11px}.user-controls{display:flex;align-items:center;gap:8px}.user-controls select{width:auto;margin:0;height:32px;padding:0 7px;font-size:11px}.user-controls .switch{white-space:nowrap}.run-actions{white-space:nowrap}.run-actions button{margin-right:5px;padding:7px 9px;font-size:10px}.drawer-field{display:grid;gap:6px;color:#526078;font-size:11px;font-weight:800}.drawer-field select{margin:0}.drawer-inline-form{display:grid;grid-template-columns:1fr auto;gap:7px;margin-top:11px}.drawer-inline-form textarea,.drawer-inline-form input{width:100%;margin:0;border:1px solid #dbe2ed;border-radius:8px;padding:9px;font:inherit;font-size:12px;box-sizing:border-box}.drawer-inline-form textarea{grid-column:1/-1;resize:vertical}.drawer-inline-form button{font-size:10px;padding:8px 10px}.workflow-list{display:grid;gap:7px;margin-top:13px}.workflow-item{display:grid;gap:3px;padding:9px;border-radius:8px;background:#fafbff;border:1px solid #edf0f5}.workflow-item b{font-size:11px;color:#3d4b63}.workflow-item span{font-size:12px;color:#59677e;white-space:pre-wrap}.workflow-item small{color:#929cad;font-size:10px}.task-item label{display:flex;align-items:center;gap:6px}.task-item input{width:auto;height:auto;margin:0}.history-list{display:grid;gap:6px}.history-item{display:flex;justify-content:space-between;gap:8px;padding:8px 0;border-bottom:1px solid #f0f1f5}.history-item b{font-size:11px}.history-item span{color:#929cad;font-size:10px}.history-details{margin-top:12px}.history-details summary{color:#5b50d3;cursor:pointer;font-size:11px;font-weight:800}.history-assessment{padding:9px 0;border-bottom:1px solid #f0f1f5}.history-assessment b,.history-assessment small{display:block}.history-assessment b{font-size:11px}.history-assessment small{margin-top:3px;color:#929cad;font-size:10px}.history-assessment p{margin:5px 0 0;color:#59677e;font-size:11px}.run-events-panel{margin-top:16px} @media(max-width:680px){.auth-card{padding:24px}.user-row,.backup-row,.audit-row{display:block}.user-controls{margin-top:9px;justify-content:flex-start}.drawer-inline-form{grid-template-columns:1fr}.drawer-inline-form button{justify-self:start}} + +/* shareable website concept previews */ +.mockup-panel{background:linear-gradient(180deg,#fbfaff,#fff)} +.mockup-safety{max-width:560px;line-height:1.5} +.mockup-list{display:grid;gap:8px;margin-top:14px} +.mockup-card{display:flex;justify-content:space-between;align-items:center;gap:14px;padding:12px;border:1px solid #e3e6f2;border-radius:10px;background:#fff} +.mockup-card-main{display:grid;gap:3px;min-width:0}.mockup-card-main b{font-size:12px;color:#334158}.mockup-card-main small,.mockup-card-main .muted{font-size:10px;color:#8b96a8;overflow-wrap:anywhere} +.mockup-card-actions{display:flex;align-items:center;gap:6px;flex:0 0 auto}.mockup-card-actions button,.mockup-card-actions a{font-size:10px;padding:7px 9px}.mockup-link{text-decoration:none} +.mockup-option{display:flex;align-items:flex-start;gap:9px;margin-top:12px;padding:10px;border:1px solid #e0e5f1;border-radius:9px;background:#fff;cursor:pointer}.mockup-option input{width:16px;height:16px;margin:1px 0 0;flex:0 0 auto}.mockup-option span{display:grid;gap:3px}.mockup-option b{font-size:11px;color:#3c4a62}.mockup-option small{font-size:10px;color:#8995a8;line-height:1.4}.mockup-option a{color:#5b50d3;font-weight:800} +@media(max-width:680px){.mockup-card{display:block}.mockup-card-actions{margin-top:9px;justify-content:flex-start}.mockup-card-actions a,.mockup-card-actions button{flex:1}.mockup-panel .primary{width:100%}}