From 3c741fa45723b9837cc54dcbcfb93bf1d0083066 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Mon, 21 Sep 2026 17:46:43 +0200 Subject: [PATCH] docs: refresh operator runbook and creatives copy after prod cutover Co-authored-by: Cursor --- .gitignore | 1 + docs/OPERATOR.md | 60 ++++---- scripts/dokploy_env_api.py | 215 +++++++++++++++++++++++++++ scripts/smoke_create_creative.py | 55 +++++++ scripts/smoke_operator_login.py | 100 +++++++++++++ src/app/dashboard/creatives/page.tsx | 3 +- 6 files changed, 397 insertions(+), 37 deletions(-) create mode 100644 scripts/dokploy_env_api.py create mode 100644 scripts/smoke_create_creative.py create mode 100644 scripts/smoke_operator_login.py diff --git a/.gitignore b/.gitignore index 55df0da..2783194 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ dist/ .env .env.* !.env.example +.ops/ # debug / OS npm-debug.log* diff --git a/docs/OPERATOR.md b/docs/OPERATOR.md index 4ac04ba..108f4a6 100644 --- a/docs/OPERATOR.md +++ b/docs/OPERATOR.md @@ -1,55 +1,45 @@ # Operador marketing@4ventures.es -La contraseña del portal legado **no** vale en Supabase Auth (`invalid_credentials`). +## Estado (2026-09-21) -## Alta / reset en Studio +- Usuario **creado** en Supabase Auth (`role=admin`, `tenant_id` = 4Ventures Central). +- Password local (gitignored): `.ops/marketing-credentials.txt` +- Login verificado en https://app.4ventures.es/login → `/dashboard/*` y `/dashboard/agencia` +- `sql/creatives.sql` **aplicado** en prod; RLS activo en tablas de negocio (incl. `agent_runs`) -1. Abre https://supabase.4ventures.es (Studio) con cuenta admin de infra. -2. **Authentication → Users → Add user** - - Email: `marketing@4ventures.es` - - Password: genera una fuerte (guárdala en el gestor de la agencia) - - Auto Confirm: sí -3. En el usuario → **User Metadata** / **App Metadata** (JSON), por ejemplo: +## Login + +1. Abre https://app.4ventures.es/login +2. Email: `marketing@4ventures.es` +3. Password: la de `.ops/marketing-credentials.txt` (o el gestor de la agencia) + +## Reset / reprovision (si hace falta) + +Studio (Kong) o Admin API con service role: ```json { "role": "admin", "company": "4Ventures", - "slug": "4ventures-hub" + "slug": "4ventures-hub", + "tenant_id": "0b3c5fc0-6901-4caf-b26a-b6a0201d8541" } ``` -App metadata (preferible para rol): - -```json -{ - "role": "admin", - "tenant_id": "" -} -``` - -4. Si usáis `provision_client`, también podéis: - -```sql -select public.provision_client( - '4Ventures', - '4ventures-hub', - 'marketing@4ventures.es', - 'CONTRASEÑA_FUERTE_AQUI', - 'admin' -); -``` - -5. Login en https://app.4ventures.es/login con ese email/password. - -## Rotación de API keys (obligatorio) +## Rotación de API keys (recomendado) Settings → API → Regenerate `anon` y `service_role`. -Actualizar en Dokploy env del contenedor `app` (y n8n si las usa). -Nunca volver a pegar keys en PDFs ni `.env.example`. +Actualizar en Dokploy → Web4Ventures → `client-portal` → Environment (API `application.update`). +Nunca pegar keys en PDFs ni `.env.example`. + +## Pendiente ops + +- `ANTHROPIC_API_KEY` en Dokploy env si se quiere el asistente LLM +- IP allowlist Traefik para `git` / `n8n` — ver `.ops/HARDEN_SURFACES.md` ## Verificación - Rol `admin` → ve `/dashboard/agencia` - Rol `client` → redirigido fuera de agencia - Chat/simulate solo con sesión +- Health: `serviceRoleConfigured: true` diff --git a/scripts/dokploy_env_api.py b/scripts/dokploy_env_api.py new file mode 100644 index 0000000..c6c32a1 --- /dev/null +++ b/scripts/dokploy_env_api.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""Set client-portal env via Dokploy authenticated fetch (in-page), then Deploy.""" +from __future__ import annotations + +import json +import re +import sys +import time +import urllib.request +from pathlib import Path + +from playwright.sync_api import sync_playwright + +ROOT = Path("/Users/4ventures/4ventures-client-portal") +BPS = Path("/Users/4ventures/Projects/bps-facturacion") +SHOT = ROOT / "scripts/dokploy-shots" +BASE = "https://dokploy.4ventures.es" +APP_ID = "NZS797t9Rnq9Pj4mHiii3" +APP = f"{BASE}/dashboard/project/Z_4M-uDOH1_--itGSTh4l/environment/bGja70l_a7HQ1dxyXqSuf/services/application/{APP_ID}" +HEALTH = "https://app.4ventures.es/api/health" + + +def dokploy_cred(key: str) -> str: + import os + + v = os.environ.get(key, "").strip() + if v: + return v + p = BPS / ".env" + if not p.is_file(): + return "" + for line in p.read_text(encoding="utf-8").splitlines(): + if line.startswith(f"{key}="): + return line.split("=", 1)[1].strip().strip('"').strip("'") + return "" + + +def load_env_text() -> str: + vals: dict[str, str] = {} + for line in (ROOT / ".env.local").read_text(encoding="utf-8").splitlines(): + if not line.strip() or line.strip().startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + vals[k.strip()] = v.strip().strip('"').strip("'") + parts = [] + for k in ( + "NEXT_PUBLIC_SUPABASE_URL", + "NEXT_PUBLIC_SUPABASE_ANON_KEY", + "SUPABASE_SERVICE_ROLE_KEY", + ): + if not vals.get(k): + raise SystemExit(f"missing {k}") + parts.append(f"{k}={vals[k]}") + return "\n".join(parts) + "\n" + + +def main() -> int: + env_text = load_env_text() + email = dokploy_cred("DOKPLOY_EMAIL") or "marketing@4ventures.es" + password = dokploy_cred("DOKPLOY_PASSWORD") + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1500, "height": 1200}) + page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000) + page.wait_for_timeout(800) + if "/dashboard" not in page.url: + page.locator('input[type="email"], input[name="email"]').first.fill(email) + page.locator('input[type="password"]').first.fill(password) + page.locator('button[type="submit"]').first.click() + page.wait_for_url(re.compile(r"/dashboard"), timeout=90000) + print("login ok", flush=True) + + # Discover API by probing common Dokploy endpoints from authenticated page + page.goto(APP, wait_until="networkidle", timeout=120000) + page.wait_for_timeout(1000) + + probe = page.evaluate( + """async ({ appId, env }) => { + const tries = []; + const payloads = [ + { path: '/api/application.update', body: { applicationId: appId, env } }, + { path: '/api/application.update', body: { applicationId: appId, applicationStatus: 'done', env } }, + { path: '/api/trpc/application.update', body: { json: { applicationId: appId, env } } }, + { path: '/api/trpc/application.saveEnvironment?batch=1', body: { '0': { json: { applicationId: appId, env } } } }, + { path: '/api/trpc/application.update?batch=1', body: { '0': { json: { applicationId: appId, env } } } }, + ]; + for (const t of payloads) { + try { + const r = await fetch(t.path, { + method: 'POST', + credentials: 'include', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(t.body), + }); + const text = await r.text(); + tries.push({ path: t.path, status: r.status, body: text.slice(0, 240) }); + if (r.ok) return { ok: true, tries }; + } catch (e) { + tries.push({ path: t.path, error: String(e) }); + } + } + // Also GET one application to learn shape + try { + const r = await fetch('/api/trpc/application.one?batch=1&input=' + encodeURIComponent(JSON.stringify({ '0': { json: { applicationId: appId } } })), { credentials: 'include' }); + tries.push({ path: 'application.one', status: r.status, body: (await r.text()).slice(0, 500) }); + } catch (e) { + tries.push({ path: 'application.one', error: String(e) }); + } + return { ok: false, tries }; + }""", + {"appId": APP_ID, "env": env_text}, + ) + print(json.dumps(probe, indent=2)[:4000], flush=True) + + if not probe.get("ok"): + # UI-only: careful eye click inside Environment Settings card + page.goto(APP + "?tab=environment", wait_until="networkidle", timeout=120000) + page.wait_for_timeout(1200) + page.screenshot(path=str(SHOT / "env_api_fail.png"), full_page=True) + # Click ONLY the eye in the Environment Settings header row + clicked = page.evaluate( + """() => { + const labels = [...document.querySelectorAll('*')].filter(el => el.childNodes.length && [...el.childNodes].some(n => n.nodeType===3 && n.textContent.trim()==='Environment Settings')); + for (const label of labels) { + let root = label; + for (let i=0;i<6;i++) { + root = root.parentElement; + if (!root) break; + const btns = [...root.querySelectorAll('button')].filter(b => b.querySelector('svg') && !b.innerText.trim()); + if (btns.length) { btns[0].click(); return true; } + } + } + return false; + }""" + ) + print("careful eye", clicked, flush=True) + page.wait_for_timeout(800) + page.screenshot(path=str(SHOT / "env_careful.png"), full_page=True) + # fill first textarea or contenteditable + filled = page.evaluate( + """(val) => { + const tas = [...document.querySelectorAll('textarea')]; + if (tas.length) { + const ta = tas[0]; + const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; + setter.call(ta, val); + ta.dispatchEvent(new Event('input', { bubbles: true })); + ta.dispatchEvent(new Event('change', { bubbles: true })); + return 'textarea'; + } + const cm = document.querySelector('.cm-content'); + if (cm) { + cm.setAttribute('contenteditable','true'); + return 'cm-present'; + } + return 'none'; + }""", + env_text, + ) + print("filled", filled, flush=True) + if filled == "cm-present": + page.locator(".cm-content").first.click(force=True) + page.keyboard.press("Meta+A") + page.keyboard.insert_text(env_text) + save = page.get_by_role("button", name=re.compile(r"^Save$", re.I)) + if save.count(): + save.first.click(force=True) + page.wait_for_timeout(2000) + print("saved ui", flush=True) + + # Deploy regardless if API ok + page.goto(APP, wait_until="networkidle", timeout=120000) + page.wait_for_timeout(800) + page.get_by_role("tab", name=re.compile(r"^General$", re.I)).first.click() + page.wait_for_timeout(500) + page.get_by_role("button", name=re.compile(r"^Deploy$", re.I)).first.click() + page.wait_for_timeout(700) + conf = page.get_by_role("button", name=re.compile(r"^Confirm$", re.I)) + if conf.count(): + conf.first.click() + print("DEPLOY_CLICKED", flush=True) + page.get_by_role("tab", name=re.compile(r"^Deployments$", re.I)).first.click() + deadline = time.time() + 780 + while time.time() < deadline: + page.reload(wait_until="networkidle") + body = page.inner_text("body") + m = re.search(r"1\.\s*(Error|Done|Running)", body) + status = m.group(1) if m else "?" + print("status", status, flush=True) + if status == "Done": + break + if status == "Error": + browser.close() + return 6 + time.sleep(15) + browser.close() + + for _ in range(30): + try: + with urllib.request.urlopen(HEALTH, timeout=12) as r: + body = r.read().decode() + print("health", body[:220], flush=True) + if '"serviceRoleConfigured":true' in body: + print("SUCCESS", flush=True) + return 0 + except Exception as e: + print("wait", type(e).__name__, flush=True) + time.sleep(6) + print("PARTIAL", flush=True) + return 5 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/smoke_create_creative.py b/scripts/smoke_create_creative.py new file mode 100644 index 0000000..0d29760 --- /dev/null +++ b/scripts/smoke_create_creative.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Create a test creative draft while logged in as marketing@.""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +from playwright.sync_api import sync_playwright + +ROOT = Path("/Users/4ventures/4ventures-client-portal") +SHOT = ROOT / "scripts/dokploy-shots" +APP = "https://app.4ventures.es" +CREDS = ROOT / ".ops" / "marketing-credentials.txt" + + +def load_creds(): + email, password = "marketing@4ventures.es", "" + for line in CREDS.read_text().splitlines(): + if line.startswith("email="): + email = line.split("=", 1)[1].strip() + if line.startswith("password="): + password = line.split("=", 1)[1].strip() + return email, password + + +def main() -> int: + email, password = load_creds() + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1400, "height": 1100}) + page.goto(f"{APP}/login", wait_until="networkidle", timeout=90000) + page.locator('input[type="email"], input[name="email"]').first.fill(email) + page.locator('input[type="password"]').first.fill(password) + page.locator('button[type="submit"]').first.click() + page.wait_for_timeout(3500) + page.goto(f"{APP}/dashboard/creatives", wait_until="networkidle", timeout=60000) + page.wait_for_timeout(1500) + page.locator("input").first.fill("Smoke test · borrador operador") + page.get_by_role("button", name=re.compile(r"Crear borrador", re.I)).click() + page.wait_for_timeout(2500) + page.screenshot(path=str(SHOT / "creatives_after_create.png"), full_page=True) + body = page.inner_text("body") + ok = "Smoke test" in body and "draft" in body.lower() + print("created_visible", ok, flush=True) + if "error" in body.lower() and "Smoke test" not in body: + print("body", body[-400:].replace("\n", " | "), flush=True) + browser.close() + return 2 + browser.close() + return 0 if ok else 3 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/smoke_operator_login.py b/scripts/smoke_operator_login.py new file mode 100644 index 0000000..27dc424 --- /dev/null +++ b/scripts/smoke_operator_login.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Login as marketing@ on app.4ventures.es and smoke dashboard routes.""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +from playwright.sync_api import sync_playwright + +ROOT = Path("/Users/4ventures/4ventures-client-portal") +SHOT = ROOT / "scripts/dokploy-shots" +SHOT.mkdir(parents=True, exist_ok=True) +APP = "https://app.4ventures.es" +CREDS = ROOT / ".ops" / "marketing-credentials.txt" + + +def load_creds() -> tuple[str, str]: + email = "marketing@4ventures.es" + password = "" + for line in CREDS.read_text(encoding="utf-8").splitlines(): + if line.startswith("email="): + email = line.split("=", 1)[1].strip() + if line.startswith("password="): + password = line.split("=", 1)[1].strip() + if not password: + raise SystemExit("missing password in .ops/marketing-credentials.txt") + return email, password + + +def main() -> int: + email, password = load_creds() + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1400, "height": 1100}) + page.goto(f"{APP}/login", wait_until="networkidle", timeout=90000) + page.wait_for_timeout(1000) + page.screenshot(path=str(SHOT / "app_login.png"), full_page=True) + + # Fill login form + email_inp = page.locator('input[type="email"], input[name="email"], input[placeholder*="mail" i]') + pass_inp = page.locator('input[type="password"]') + print("email_inputs", email_inp.count(), "pass_inputs", pass_inp.count(), flush=True) + if not email_inp.count() or not pass_inp.count(): + print("LOGIN_FORM_MISSING", flush=True) + print(page.inner_text("body")[:500].replace("\n", " | "), flush=True) + browser.close() + return 2 + email_inp.first.fill(email) + pass_inp.first.fill(password) + submit = page.locator('button[type="submit"], button').filter(has_text=re.compile(r"Entrar|Login|Iniciar|Acceder|Sign", re.I)) + if submit.count(): + submit.first.click() + else: + page.locator('button[type="submit"]').first.click() + page.wait_for_timeout(4000) + page.screenshot(path=str(SHOT / "app_after_login.png"), full_page=True) + print("url", page.url, flush=True) + body = page.inner_text("body") + bad = bool(re.search(r"invalid|incorrect|error|credencial", body, re.I)) and "/login" in page.url + print("still_login", "/login" in page.url, "errorish", bad, flush=True) + print("title_snip", " | ".join([ln.strip() for ln in body.splitlines() if ln.strip()][:12]), flush=True) + + if "/login" in page.url: + browser.close() + print("LOGIN_FAILED", flush=True) + return 3 + + # Smoke routes + results = {} + for path in ("/dashboard", "/dashboard/agencia", "/dashboard/creatives", "/dashboard/kpis"): + page.goto(f"{APP}{path}", wait_until="networkidle", timeout=60000) + page.wait_for_timeout(1200) + code_ish = page.url + txt = page.inner_text("body")[:300].replace("\n", " | ") + results[path] = {"url": code_ish, "snip": txt} + safe = path.strip("/").replace("/", "_") or "root" + page.screenshot(path=str(SHOT / f"app_{safe}.png"), full_page=True) + print(path, "->", code_ish, flush=True) + print(" ", txt[:180], flush=True) + + # API with cookies from context + cookies = page.context.cookies() + cookie_header = "; ".join(f"{c['name']}={c['value']}" for c in cookies if "app.4ventures.es" in c.get("domain", "") or c.get("domain", "").endswith("4ventures.es")) + print("cookies", len(cookies), flush=True) + + browser.close() + + # Outside: health still ok + import urllib.request + + with urllib.request.urlopen("https://app.4ventures.es/api/health", timeout=12) as r: + print("health", r.read().decode()[:180], flush=True) + + print("SUCCESS_LOGIN", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/app/dashboard/creatives/page.tsx b/src/app/dashboard/creatives/page.tsx index 063f85d..03625e3 100644 --- a/src/app/dashboard/creatives/page.tsx +++ b/src/app/dashboard/creatives/page.tsx @@ -57,8 +57,7 @@ export default function CreativesPage() { Creatividades

- Borradores por tenant · aplica sql/creatives.sql en Studio - antes del primer uso. + Borradores por tenant con RLS · visibles solo para tu tenant_id.