docs: refresh operator runbook and creatives copy after prod cutover
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,6 +13,7 @@ dist/
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
.ops/
|
||||||
|
|
||||||
# debug / OS
|
# debug / OS
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|||||||
@@ -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
215
scripts/dokploy_env_api.py
Normal 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())
|
||||||
55
scripts/smoke_create_creative.py
Normal file
55
scripts/smoke_create_creative.py
Normal 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())
|
||||||
100
scripts/smoke_operator_login.py
Normal file
100
scripts/smoke_operator_login.py
Normal 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())
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user