docs: refresh operator runbook and creatives copy after prod cutover

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-21 17:46:43 +02:00
parent 997ccf66d7
commit 3c741fa457
6 changed files with 397 additions and 37 deletions

1
.gitignore vendored
View File

@@ -13,6 +13,7 @@ dist/
.env .env
.env.* .env.*
!.env.example !.env.example
.ops/
# debug / OS # debug / OS
npm-debug.log* npm-debug.log*

View File

@@ -1,55 +1,45 @@
# Operador marketing@4ventures.es # 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. ## Login
2. **Authentication → Users → Add user**
- Email: `marketing@4ventures.es` 1. Abre https://app.4ventures.es/login
- Password: genera una fuerte (guárdala en el gestor de la agencia) 2. Email: `marketing@4ventures.es`
- Auto Confirm: sí 3. Password: la de `.ops/marketing-credentials.txt` (o el gestor de la agencia)
3. En el usuario → **User Metadata** / **App Metadata** (JSON), por ejemplo:
## Reset / reprovision (si hace falta)
Studio (Kong) o Admin API con service role:
```json ```json
{ {
"role": "admin", "role": "admin",
"company": "4Ventures", "company": "4Ventures",
"slug": "4ventures-hub" "slug": "4ventures-hub",
"tenant_id": "0b3c5fc0-6901-4caf-b26a-b6a0201d8541"
} }
``` ```
App metadata (preferible para rol): ## Rotación de API keys (recomendado)
```json
{
"role": "admin",
"tenant_id": "<uuid-del-tenant-hub-si-existe>"
}
```
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)
Settings → API → Regenerate `anon` y `service_role`. Settings → API → Regenerate `anon` y `service_role`.
Actualizar en Dokploy env del contenedor `app` (y n8n si las usa). Actualizar en Dokploy → Web4Ventures → `client-portal` → Environment (API `application.update`).
Nunca volver a pegar keys en PDFs ni `.env.example`. 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 ## Verificación
- Rol `admin` → ve `/dashboard/agencia` - Rol `admin` → ve `/dashboard/agencia`
- Rol `client` → redirigido fuera de agencia - Rol `client` → redirigido fuera de agencia
- Chat/simulate solo con sesión - Chat/simulate solo con sesión
- Health: `serviceRoleConfigured: true`

215
scripts/dokploy_env_api.py Normal file
View File

@@ -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())

View File

@@ -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())

View File

@@ -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())

View File

@@ -57,8 +57,7 @@ export default function CreativesPage() {
Creatividades Creatividades
</h2> </h2>
<p className="text-sm text-gray-400 mt-1"> <p className="text-sm text-gray-400 mt-1">
Borradores por tenant · aplica <code>sql/creatives.sql</code> en Studio Borradores por tenant con RLS · visibles solo para tu <code>tenant_id</code>.
antes del primer uso.
</p> </p>
</div> </div>