Compare commits
92 Commits
harden/aut
...
6566b42037
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6566b42037 | ||
|
|
ed18c9d318 | ||
|
|
e3180ceb51 | ||
|
|
c11ec20f26 | ||
|
|
1a58af129d | ||
|
|
8c5acb626f | ||
|
|
82a910330d | ||
|
|
a75f3ae30c | ||
|
|
939c4a3131 | ||
|
|
9270584934 | ||
|
|
ed2afff556 | ||
|
|
c83efa492d | ||
|
|
5e8fd66e35 | ||
|
|
331c19e873 | ||
|
|
6d8dfaa79c | ||
|
|
990915a051 | ||
|
|
c0707ffc1b | ||
|
|
87c887294d | ||
|
|
aa41594e1e | ||
|
|
530b5ac5ae | ||
|
|
4a9c2139f7 | ||
|
|
83e2b59e83 | ||
|
|
5235c88c13 | ||
|
|
062c43325d | ||
|
|
48fb31d247 | ||
|
|
4c8042aa23 | ||
|
|
dff9c46747 | ||
|
|
cf27eba74d | ||
|
|
dd50251de2 | ||
|
|
9f30aa6820 | ||
|
|
b0d19e4da5 | ||
|
|
5e5fc87710 | ||
|
|
224f060e9d | ||
|
|
04ea6de08a | ||
|
|
751673c341 | ||
|
|
06024cf473 | ||
|
|
d896041bed | ||
|
|
4b6f7f1896 | ||
|
|
f4dfc50cc9 | ||
|
|
3425364b07 | ||
|
|
eb1622c88c | ||
|
|
ba05626fed | ||
|
|
7ed6e641cb | ||
|
|
99895fe918 | ||
|
|
81ba5df37d | ||
|
|
5501f45a67 | ||
|
|
5d04a79de7 | ||
|
|
d137b5796a | ||
|
|
aded4c3514 | ||
|
|
81ede58330 | ||
|
|
5c90bcf940 | ||
|
|
dd0e867108 | ||
|
|
21051afda9 | ||
|
|
8fda2996b6 | ||
|
|
068faa3d25 | ||
|
|
6d1602bd68 | ||
|
|
bf9a504c40 | ||
|
|
8c11291396 | ||
|
|
8091498508 | ||
|
|
d05df8c371 | ||
|
|
86225a701e | ||
|
|
be45df300b | ||
|
|
f436276c3e | ||
|
|
76c26c2e69 | ||
|
|
fb313fc38f | ||
|
|
fba025da76 | ||
|
|
a1a00840d5 | ||
|
|
0d286dc66c | ||
|
|
a7704567bd | ||
|
|
b4ee23874e | ||
|
|
a1a3c26c30 | ||
|
|
5460d5bd04 | ||
|
|
982a9e2954 | ||
|
|
64156383ca | ||
|
|
6607a02bff | ||
|
|
c13001c69d | ||
|
|
eb00256bea | ||
|
|
670679d09c | ||
|
|
c4c527072f | ||
|
|
1eb02dac4d | ||
|
|
31ca77558e | ||
|
|
ce9498e63c | ||
|
|
8182402d96 | ||
|
|
791976a3d8 | ||
|
|
9d1609c88b | ||
|
|
5d448c0e71 | ||
|
|
73b3693eeb | ||
|
|
8315a8098f | ||
|
|
aa89df86a1 | ||
|
|
3c741fa457 | ||
|
|
997ccf66d7 | ||
| ec642b057a |
22
.env.example
22
.env.example
@@ -8,3 +8,25 @@ ANTHROPIC_API_KEY=replace-with-anthropic-key
|
||||
OPS_ALERT_EMAIL=
|
||||
CRON_SECRET=
|
||||
N8N_WEBHOOK_BASE=https://n8n-agencia.4ventures.es
|
||||
|
||||
# Google Drive (agency marketing@) — OAuth Web client
|
||||
NEXT_PUBLIC_APP_URL=https://app.4ventures.es
|
||||
GOOGLE_OAUTH_CLIENT_ID=
|
||||
GOOGLE_OAUTH_CLIENT_SECRET=
|
||||
# Optional override; default: {APP_URL}/api/admin/drive/callback
|
||||
# GOOGLE_OAUTH_REDIRECT_URI=https://app.4ventures.es/api/admin/drive/callback
|
||||
|
||||
# Meta Ads discovery (long-lived / system user token)
|
||||
META_ACCESS_TOKEN=
|
||||
|
||||
# Google Ads discovery (MCC via marketing@)
|
||||
GOOGLE_ADS_DEVELOPER_TOKEN=
|
||||
GOOGLE_ADS_REFRESH_TOKEN=
|
||||
# Optional: reuse GOOGLE_OAUTH_CLIENT_* or set dedicated:
|
||||
# GOOGLE_ADS_CLIENT_ID=
|
||||
# GOOGLE_ADS_CLIENT_SECRET=
|
||||
# GOOGLE_ADS_LOGIN_CUSTOMER_ID=
|
||||
|
||||
# Superhuman Docs (optional)
|
||||
# SUPERHUMAN_DOCS_API_KEY=
|
||||
|
||||
|
||||
34
.gitignore
vendored
34
.gitignore
vendored
@@ -13,6 +13,40 @@ dist/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.ops/
|
||||
|
||||
# local ops noise
|
||||
scripts/dokploy-shots/
|
||||
# one-shot cutover / debug (kept locally; not for main)
|
||||
scripts/dokploy_cleanup_and_sql.py
|
||||
scripts/dokploy_configure_deploy_portal.py
|
||||
scripts/dokploy_create_and_deploy_portal.py
|
||||
scripts/dokploy_cutover_portal.py
|
||||
scripts/dokploy_deploy_portal_now.py
|
||||
scripts/dokploy_env_reveal_save.py
|
||||
scripts/dokploy_finalize_portal.py
|
||||
scripts/dokploy_find_app_host.py
|
||||
scripts/dokploy_find_supabase.py
|
||||
scripts/dokploy_inspect_env.py
|
||||
scripts/dokploy_inspect_portal.py
|
||||
scripts/dokploy_inventory_domains.py
|
||||
scripts/dokploy_redeploy_portal.py
|
||||
scripts/dokploy_retarget_traefik.py
|
||||
scripts/dokploy_run_creatives_sql.py
|
||||
scripts/dokploy_set_env_and_ops.py
|
||||
scripts/dokploy_sql_terminal_b64.py
|
||||
scripts/dokploy_sql_via_terminal.py
|
||||
scripts/dokploy_stop_orphan.py
|
||||
scripts/dokploy_traefik_cm.py
|
||||
scripts/dokploy_traefik_expand.py
|
||||
scripts/dokploy_traefik_insert.py
|
||||
scripts/dokploy_traefik_only.py
|
||||
scripts/dokploy_traefik_unlock.py
|
||||
scripts/dokploy_view_deploy_error.py
|
||||
scripts/dokploy_collect_supabase_env.py
|
||||
scripts/dokploy_find_jwt_secret.py
|
||||
scripts/dokploy_prepare_key_rotation.py
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
# debug / OS
|
||||
npm-debug.log*
|
||||
|
||||
@@ -12,8 +12,8 @@ COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN mkdir -p public
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NEXT_PUBLIC_SUPABASE_URL=https://supabase.4ventures.es
|
||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3NzIzMTgzMTAsImV4cCI6MTg5MzQ1NjAwMCwicm9sZSI6ImFub24iLCJpc3MiOiJzdXBhYmFzZSJ9.UWNn1leZNljna9Qwi9U5RtGqsh5_SnEoiPO8HkQIgi8
|
||||
# NEXT_PUBLIC_* must come from Dokploy "Create Environment File" (.env) or build-args.
|
||||
# Do not bake secrets into the image.
|
||||
RUN npm run build
|
||||
|
||||
FROM base AS runner
|
||||
|
||||
@@ -30,6 +30,7 @@ Health: `GET /api/health`
|
||||
|------|-----|
|
||||
| `/login` | Supabase Auth |
|
||||
| `/dashboard/kpis` | KPIs |
|
||||
| `/dashboard/seguimiento` | Timeline Superhuman + evolución canales |
|
||||
| `/dashboard/estrategia` | Targets + simulador |
|
||||
| `/dashboard/activos` | Docs aprobados (RLS) |
|
||||
| `/dashboard/creatives` | Creatividades (en curso) |
|
||||
|
||||
23
SECURITY.md
23
SECURITY.md
@@ -5,11 +5,21 @@
|
||||
El PDF “Agencia 2030” y el antiguo `.env.example` del repo contenían **JWTs reales**
|
||||
(anon + service_role) y passwords de demo.
|
||||
|
||||
1. En Supabase Studio → Settings → API: **regenerar** `anon` y `service_role`.
|
||||
2. Actualizar secrets en Dokploy / env del contenedor `app` y de n8n.
|
||||
3. Cambiar passwords de `admin@…` y clientes demo; invalidar sesiones.
|
||||
4. Rotar `ANTHROPIC_API_KEY` si estuvo en chats/docs.
|
||||
5. Confirmar que `.env.example` solo tiene placeholders (ya corregido en este repo).
|
||||
### Rotación (checklist — hacerlo en ventana corta)
|
||||
|
||||
1. Supabase Studio → Settings → API → **regenerar** `anon` y `service_role`
|
||||
**o** (self-hosted) cambiar `JWT_SECRET` / `GOTRUE_JWT_SECRET` y emitir JWTs nuevos.
|
||||
2. Dokploy → client-portal Environment: pegar anon + service_role nuevos → Save → Deploy.
|
||||
3. n8n (credenciales Supabase / HTTP headers): actualizar API keys.
|
||||
4. Local: actualizar `.env.local` (gitignored); no tocar `.env.example` (placeholders).
|
||||
5. Invalidar sesiones: Auth → Users → sign-out / cambiar password `marketing@4ventures.es` si hubo leak de demo.
|
||||
6. Smoke: `curl /api/health` → `serviceRoleConfigured true`; `python3 scripts/smoke_operator_login.py`.
|
||||
7. Rotar `ANTHROPIC_API_KEY` si estuvo en chats/docs (opcional, independiente).
|
||||
|
||||
**Estado 2026-09-21:** claves nuevas **preparadas** en `.ops/supabase_rotated.env` (gitignored).
|
||||
**Aplicación bloqueada:** el stack vivo (`ventures-web-supabase-gm0nnn-*`) no tiene compose editable en Dokploy (`4ventures-infra` está en `error` / `composeFile` vacío) y SSH al VPS (`187.77.170.176:22`) hace timeout. Sin recrear contenedores con el nuevo `JWT_SECRET`, los JWT filtrados **siguen válidos**.
|
||||
|
||||
**Desbloqueo:** acceso host (SSH/VPN) o restaurar el compose Supabase en Dokploy → sustituir env → recrear servicios auth/kong/rest/studio → Deploy client-portal.
|
||||
|
||||
## Superficies a restringir (amenaza)
|
||||
|
||||
@@ -20,6 +30,9 @@ El PDF “Agencia 2030” y el antiguo `.env.example` del repo contenían **JWTs
|
||||
| `supabase` Studio | Solo red interna / SSO admin |
|
||||
| `app` | Público OK; APIs tras sesión (middleware) |
|
||||
|
||||
Patch Traefik **aplicado** 2026-09-21 (ver `.ops/HARDEN_SURFACES.md`).
|
||||
Allowlist oficina: `90.161.248.78/32` — UI git/n8n; webhooks abiertos. Si el ISP cambia la IP, actualizar middleware `office-allowlist`.
|
||||
|
||||
## Controles en código (este repo)
|
||||
|
||||
- Middleware exige sesión en `/dashboard/*` y `/api/*` (excepto `/api/health`).
|
||||
|
||||
@@ -1,55 +1,79 @@
|
||||
# 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`, verificado 2026-09-21)
|
||||
- Re-aplicar RLS `agent_runs` si hace falta: `sql/agent_runs_rls.sql` (idempotente)
|
||||
- Biblioteca de activos (archivos): `sql/asset_files.sql` + bucket privado `tenant-assets` (máx. 80 MB/archivo). UI: `/dashboard/activos`. Apply: `python3 scripts/dokploy_apply_asset_files.py`
|
||||
- Google Drive (agencia): `sql/google_drive.sql`. OAuth marketing@ → scopes Drive + Analytics readonly + Search Console. Redirect: `https://app.4ventures.es/api/admin/drive/callback`. UI: Agencia → Conectar Google. Tras consent: **auto-bootstrap** (Drive + GA4 + GSC + Google Ads MCC → fichas + sync Orgánico). Repetir: panel 2 → Emparejar todo Google.
|
||||
- Meta (API 4V2030 / system user): pegar token en Agencia → Descubrir ambos. Empareja Meta Ads + páginas Facebook/Instagram a fichas (si el token tiene `pages_show_list` / business).
|
||||
- Cron diario (recomendado): env `CRON_SECRET` en Dokploy + schedule:
|
||||
`curl -sS -H "Authorization: Bearer $CRON_SECRET" https://app.4ventures.es/api/cron/organic-sync`
|
||||
(actualiza últimos 7 días de todos los clientes con GA4/GSC conectados).
|
||||
- Panel control clientes: `sql/agency_control.sql` (`agency_followed` + `integrations.enabled`). Matriz en Agencia + hub por cliente.
|
||||
- Meta/Google Ads discovery: env `META_ACCESS_TOKEN`, `GOOGLE_ADS_DEVELOPER_TOKEN`, `GOOGLE_ADS_REFRESH_TOKEN`, `GOOGLE_ADS_CLIENT_ID/SECRET`. UI: Agencia → Descubrir y emparejar.
|
||||
- Meta App Review: mientras esperas aprobación, usa **Solo Google Ads**. Cuando tengas el system user token, pégalo en Agencia (se guarda en `agency_oauth`, sin redeploy) → Descubrir ambos.
|
||||
|
||||
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": "<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)
|
||||
## 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
|
||||
- OAuth live Meta; Google Ads via env; **GA4/GSC** vía OAuth marketing@ (reconectar Google en Agencia + sync orgánico)
|
||||
- IP allowlist Traefik: mantener IPs oficina actuales en `middleware-hardening.yml`
|
||||
- **`CLIENTIFY_WEBHOOK_SECRET` obligatorio** — sin él el webhook responde 503
|
||||
- **Cron diario (obligatorio en prod)** — ver sección Cron
|
||||
|
||||
## Cron diario (datos siempre frescos para vistas cliente)
|
||||
|
||||
En Dokploy → Environment del `client-portal`:
|
||||
|
||||
1. Define `CRON_SECRET` (string largo aleatorio).
|
||||
2. Schedule diario ~06:00 UTC:
|
||||
|
||||
```bash
|
||||
curl -sS -H "Authorization: Bearer $CRON_SECRET" \
|
||||
"https://app.4ventures.es/api/cron/daily-sync?days=7"
|
||||
```
|
||||
|
||||
Endpoints individuales (mismo secret):
|
||||
|
||||
- `/api/cron/organic-sync` — GA4 + GSC → `organic_daily`
|
||||
- `/api/cron/ads-sync` — Meta + Google Ads → `ad_daily` (API v22)
|
||||
- `/api/cron/superhuman-sync` — Seguimiento / Google Docs
|
||||
|
||||
`last_sync_at` solo se actualiza tras escribir filas reales (no al emparejar).
|
||||
|
||||
## Verificación
|
||||
|
||||
- Rol `admin` → ve `/dashboard/agencia`
|
||||
- Rol `client` → redirigido fuera de agencia
|
||||
- Chat/simulate solo con sesión
|
||||
- Health: `serviceRoleConfigured: true`
|
||||
- Tras cron: Orgánico / Ads / Seguimiento con `last_sync_at` de hoy en ficha
|
||||
|
||||
@@ -1,24 +1,41 @@
|
||||
# Runbook — Centro de Mando 4Ventures
|
||||
|
||||
Operador: `marketing@4ventures.es`
|
||||
Credenciales locales (gitignored): `.ops/marketing-credentials.txt`
|
||||
|
||||
## Health
|
||||
|
||||
```bash
|
||||
curl -sf https://app.4ventures.es/api/health
|
||||
# expect: serviceRoleConfigured true
|
||||
./scripts/smoke.sh https://app.4ventures.es
|
||||
python3 scripts/smoke_operator_login.py
|
||||
python3 scripts/smoke_chat_auth.py
|
||||
```
|
||||
|
||||
## Deploy (Dokploy)
|
||||
|
||||
1. Push a `main` en Gitea `Marketing/4ventures-client-portal`.
|
||||
2. Dokploy rebuild del servicio `app`.
|
||||
3. Env: `NEXT_PUBLIC_SUPABASE_*`, `SUPABASE_SERVICE_ROLE_KEY`, `ANTHROPIC_API_KEY`.
|
||||
4. Smoke: `/login` → KPIs → simulador → chat.
|
||||
2. Dokploy → **Web4Ventures / production / client-portal** → Deploy
|
||||
(id interno `web4ventures-clientportal-jntjhs`).
|
||||
3. Environment (runtime + build via “Create Environment File”):
|
||||
- `NEXT_PUBLIC_SUPABASE_URL`
|
||||
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
||||
- `SUPABASE_SERVICE_ROLE_KEY`
|
||||
- `ANTHROPIC_API_KEY` (opcional, LLM)
|
||||
4. Traefik: `portal.yml` → `http://web4ventures-clientportal-jntjhs:3000`
|
||||
5. Smoke: `/login` → KPIs → creatividades → chat.
|
||||
|
||||
## Alta cliente
|
||||
## Portafolio agencia (admin)
|
||||
|
||||
1. Login `marketing@` → redirige a `/dashboard/agencia`.
|
||||
2. Cabecera **Contexto**: selector de cliente (modo superadmin) o vista global.
|
||||
3. Tarjetas de portafolio: salud de integraciones + **Entrar** / **Integraciones**.
|
||||
4. Ficha `/dashboard/agencia/cliente/[id]`: Meta, Google Ads, GA4, GSC, GTM, Clientify, Klaviyo, Mailchimp (estado manual por ahora).
|
||||
5. SQL: `sql/agency_portfolio.sql` (tablas `integrations` + `agency_alerts`).
|
||||
|
||||
OAuth live Meta/MCC/GA4 = siguiente fase; el esqueleto ya escala por tenant.
|
||||
|
||||
UI `/dashboard/agencia` (roles admin/team) o RPC `provision_client`.
|
||||
|
||||
## Atribución
|
||||
|
||||
@@ -28,6 +45,17 @@ UI `/dashboard/agencia` (roles admin/team) o RPC `provision_client`.
|
||||
|
||||
`POST https://n8n-agencia.4ventures.es/webhook/lead-inbound`
|
||||
|
||||
## Endurecer git / n8n
|
||||
|
||||
Aplicado 2026-09-21: IP allowlist `90.161.248.78/32` en UI de git + n8n; webhooks `/webhook` abiertos.
|
||||
Detalle / revert: `.ops/HARDEN_SURFACES.md`. Re-aplicar: `python3 scripts/dokploy_apply_traefik_harden.py`.
|
||||
|
||||
`app.4ventures.es` y Dokploy **sin** allowlist.
|
||||
|
||||
## Rotación JWT Supabase
|
||||
|
||||
Claves nuevas en `.ops/supabase_rotated.env` (gitignored). Aplicar requiere recrear el stack Supabase (ver `SECURITY.md`).
|
||||
|
||||
## Incidentes
|
||||
|
||||
Ver `SECURITY.md`.
|
||||
|
||||
@@ -24,8 +24,10 @@
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"dotenv": "^18.0.5",
|
||||
"postcss": "^8.5.2",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/logo-4ventures.png
Normal file
BIN
public/logo-4ventures.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 145 KiB |
224
public/preview-seguimiento.html
Normal file
224
public/preview-seguimiento.html
Normal file
@@ -0,0 +1,224 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Preview · Seguimiento · Virginia Molet</title>
|
||||
<style>
|
||||
:root { --bg:#0B0F19; --card:#111827; --side:#0D121F; --line:#1F2937; --brand:#E58A3C; --text:#F3F4F6; --muted:#9CA3AF; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif; background:var(--bg); color:var(--text); }
|
||||
.shell { display:flex; min-height:100vh; }
|
||||
aside { width:240px; background:var(--side); border-right:1px solid #1f2937; padding:20px 14px; }
|
||||
.logo { display:flex; gap:10px; align-items:center; padding:8px; border-bottom:1px solid #1f293780; margin-bottom:18px; }
|
||||
.mark { width:36px; height:36px; border-radius:10px; background:linear-gradient(135deg,#E58A3C,#B35E1B); display:grid; place-items:center; font-weight:800; }
|
||||
nav a { display:flex; gap:10px; align-items:center; color:#D1D5DB; text-decoration:none; padding:10px 12px; border-radius:10px; font-size:14px; margin-bottom:4px; }
|
||||
nav a.active { background:#1f293799; color:#fff; }
|
||||
main { flex:1; padding:28px 36px; max-width:980px; }
|
||||
h1 { margin:0; font-size:28px; letter-spacing:-.02em; }
|
||||
.sub { color:var(--muted); font-size:14px; margin-top:6px; }
|
||||
.badge { font-size:11px; color:#9CA3AF; border:1px solid #1f2937; background:#0d121f80; border-radius:8px; padding:8px 12px; }
|
||||
.card { background:var(--card); border:1px solid #1f2937; border-radius:14px; padding:20px; margin-top:22px; }
|
||||
.head { display:flex; justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap; }
|
||||
.tabs { display:flex; gap:4px; background:#0003; border:1px solid #1f2937; border-radius:10px; padding:3px; }
|
||||
.tabs span { font-size:12px; padding:6px 10px; border-radius:8px; color:#9CA3AF; }
|
||||
.tabs span.on { background:#E58A3C33; color:#F0B27A; }
|
||||
.chs { display:flex; gap:8px; margin:14px 0 8px; flex-wrap:wrap; }
|
||||
.chs span { font-size:12px; border:1px solid #374151; border-radius:999px; padding:4px 10px; text-transform:capitalize; display:inline-flex; gap:6px; align-items:center; }
|
||||
.chs i { width:8px; height:8px; border-radius:50%; display:inline-block; }
|
||||
.preview-note { font-size:11px; color:#FBBF24; margin-top:8px; }
|
||||
ol { list-style:none; margin:18px 0 0; padding:0 0 0 14px; border-left:1px solid #1f2937; }
|
||||
li { position:relative; margin:0 0 14px 12px; }
|
||||
.dot { position:absolute; left:-19px; top:18px; width:12px; height:12px; border-radius:50%; background:var(--brand); box-shadow:0 0 0 4px var(--bg); }
|
||||
article { background:var(--card); border:1px solid #1f2937; border-radius:14px; padding:14px 16px; }
|
||||
.row { display:flex; justify-content:space-between; gap:10px; }
|
||||
.pill { font-size:10px; text-transform:uppercase; letter-spacing:.06em; border:1px solid; border-radius:6px; padding:2px 7px; margin-right:8px; }
|
||||
.when { font-size:11px; color:#6B7280; }
|
||||
h3 { margin:8px 0 0; font-size:14px; }
|
||||
article p { margin:8px 0 0; font-size:12px; color:#9CA3AF; line-height:1.45; }
|
||||
article a { color:#F0B27A; font-size:11px; text-decoration:none; white-space:nowrap; }
|
||||
.top { display:flex; justify-content:space-between; align-items:flex-end; gap:16px; flex-wrap:wrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<aside>
|
||||
<div class="logo"><div class="mark">4V</div><div><strong style="font-size:13px">4VENTURES</strong><div style="font-size:11px;color:#9CA3AF">Centro de Mando</div></div></div>
|
||||
<nav>
|
||||
<a href="#">Métricas</a>
|
||||
<a class="active" href="#">● Seguimiento</a>
|
||||
<a href="#">Objetivos del Mes</a>
|
||||
<a href="#">Calendario Editorial</a>
|
||||
<a href="#">Biblioteca de Activos</a>
|
||||
</nav>
|
||||
</aside>
|
||||
<main>
|
||||
<div class="top">
|
||||
<div>
|
||||
<h1>Seguimiento & evolución</h1>
|
||||
<p class="sub">Virginia Molet · timeline Superhuman + series de canal</p>
|
||||
</div>
|
||||
<div class="badge">Preview UI · datos reales de timeline</div>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<div class="head">
|
||||
<strong>Evolución por canal</strong>
|
||||
<div class="tabs"><span class="on">Leads</span><span>Inversión</span><span>CPL</span></div>
|
||||
</div>
|
||||
<div class="chs">
|
||||
<span><i style="background:#E58A3C"></i>meta</span>
|
||||
<span><i style="background:#38BDF8"></i>google</span>
|
||||
<span><i style="background:#34D399"></i>organic</span>
|
||||
</div>
|
||||
<svg viewBox="0 0 720 260" width="100%" style="max-width:100%">
|
||||
<line x1="44" x2="704" y1="224" y2="224" stroke="#1F2937"/><text x="36" y="227" text-anchor="end" fill="#6B7280" font-size="10">0</text><line x1="44" x2="704" y1="172.0" y2="172.0" stroke="#1F2937"/><text x="36" y="175.0" text-anchor="end" fill="#6B7280" font-size="10">18</text><line x1="44" x2="704" y1="120.0" y2="120.0" stroke="#1F2937"/><text x="36" y="123.0" text-anchor="end" fill="#6B7280" font-size="10">36</text><line x1="44" x2="704" y1="68.0" y2="68.0" stroke="#1F2937"/><text x="36" y="71.0" text-anchor="end" fill="#6B7280" font-size="10">54</text><line x1="44" x2="704" y1="16" y2="16" stroke="#1F2937"/><text x="36" y="19" text-anchor="end" fill="#6B7280" font-size="10">72</text>
|
||||
<path d="M 44.0 102.7 L 138.3 85.3 L 232.6 65.1 L 326.9 76.7 L 421.1 50.7 L 515.4 56.4 L 609.7 30.4 L 704.0 16.0" fill="none" stroke="#E58A3C" stroke-width="2.5"/><circle cx="44.0" cy="102.7" r="3.5" fill="#E58A3C"/><circle cx="138.3" cy="85.3" r="3.5" fill="#E58A3C"/><circle cx="232.6" cy="65.1" r="3.5" fill="#E58A3C"/><circle cx="326.9" cy="76.7" r="3.5" fill="#E58A3C"/><circle cx="421.1" cy="50.7" r="3.5" fill="#E58A3C"/><circle cx="515.4" cy="56.4" r="3.5" fill="#E58A3C"/><circle cx="609.7" cy="30.4" r="3.5" fill="#E58A3C"/><circle cx="704.0" cy="16.0" r="3.5" fill="#E58A3C"/><path d="M 44.0 189.3 L 138.3 183.6 L 232.6 192.2 L 326.9 177.8 L 421.1 172.0 L 515.4 180.7 L 609.7 166.2 L 704.0 160.4" fill="none" stroke="#38BDF8" stroke-width="2.5"/><circle cx="44.0" cy="189.3" r="3.5" fill="#38BDF8"/><circle cx="138.3" cy="183.6" r="3.5" fill="#38BDF8"/><circle cx="232.6" cy="192.2" r="3.5" fill="#38BDF8"/><circle cx="326.9" cy="177.8" r="3.5" fill="#38BDF8"/><circle cx="421.1" cy="172.0" r="3.5" fill="#38BDF8"/><circle cx="515.4" cy="180.7" r="3.5" fill="#38BDF8"/><circle cx="609.7" cy="166.2" r="3.5" fill="#38BDF8"/><circle cx="704.0" cy="160.4" r="3.5" fill="#38BDF8"/><path d="M 44.0 200.9 L 138.3 198.0 L 232.6 203.8 L 326.9 195.1 L 421.1 192.2 L 515.4 198.0 L 609.7 189.3 L 704.0 186.4" fill="none" stroke="#34D399" stroke-width="2.5"/><circle cx="44.0" cy="200.9" r="3.5" fill="#34D399"/><circle cx="138.3" cy="198.0" r="3.5" fill="#34D399"/><circle cx="232.6" cy="203.8" r="3.5" fill="#34D399"/><circle cx="326.9" cy="195.1" r="3.5" fill="#34D399"/><circle cx="421.1" cy="192.2" r="3.5" fill="#34D399"/><circle cx="515.4" cy="198.0" r="3.5" fill="#34D399"/><circle cx="609.7" cy="189.3" r="3.5" fill="#34D399"/><circle cx="704.0" cy="186.4" r="3.5" fill="#34D399"/>
|
||||
<text x="44.0" y="250" text-anchor="middle" fill="#6B7280" font-size="9">07-06</text><text x="138.3" y="250" text-anchor="middle" fill="#6B7280" font-size="9">07-13</text><text x="232.6" y="250" text-anchor="middle" fill="#6B7280" font-size="9">07-20</text><text x="326.9" y="250" text-anchor="middle" fill="#6B7280" font-size="9">07-27</text><text x="421.1" y="250" text-anchor="middle" fill="#6B7280" font-size="9">08-03</text><text x="515.4" y="250" text-anchor="middle" fill="#6B7280" font-size="9">08-10</text><text x="609.7" y="250" text-anchor="middle" fill="#6B7280" font-size="9">08-17</text><text x="704.0" y="250" text-anchor="middle" fill="#6B7280" font-size="9">08-24</text>
|
||||
</svg>
|
||||
<p class="preview-note">La gráfica usa series de ejemplo (Virginia aún no tiene kpi_weekly). El timeline inferior sí es sync real de Superhuman.</p>
|
||||
</section>
|
||||
|
||||
<section style="margin-top:28px">
|
||||
<strong style="font-size:15px">Timeline reunión a reunión <span style="color:#6B7280;font-weight:400">(12)</span></strong>
|
||||
<ol>
|
||||
|
||||
<li>
|
||||
<span class="dot"></span>
|
||||
<article>
|
||||
<div class="row">
|
||||
<div>
|
||||
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
|
||||
<span class="when">18 Aug 2026</span>
|
||||
<h3>Gravitae: Meta Ads/Google ADs</h3>
|
||||
</div>
|
||||
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_suvQLvnL" target="_blank">Superhuman ↗</a>
|
||||
</div>
|
||||
<p>- **[Landing Page del Practitioner en Coaching Ejecutivo](https://gravitaeacademy.com/es/diploma-en-coaching-ejecutivo/)**
|
||||
|
||||
|
||||
📊 KPIs a vigilar las primeras 2 semanas
|
||||
|
||||
## Copy of T</p>
|
||||
</article>
|
||||
</li>
|
||||
<li>
|
||||
<span class="dot"></span>
|
||||
<article>
|
||||
<div class="row">
|
||||
<div>
|
||||
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
|
||||
<span class="when">01 Jul 2026</span>
|
||||
<h3>wbf_mad_sice.pdf</h3>
|
||||
</div>
|
||||
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_suJVY_uk" target="_blank">Superhuman ↗</a>
|
||||
</div>
|
||||
<p>Abrir en Superhuman para el detalle completo.</p>
|
||||
</article>
|
||||
</li>
|
||||
<li>
|
||||
<span class="dot"></span>
|
||||
<article>
|
||||
<div class="row">
|
||||
<div>
|
||||
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
|
||||
<span class="when">01 Jul 2026</span>
|
||||
<h3>Info emails Open Event 9/7/26</h3>
|
||||
</div>
|
||||
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_su8PImAd" target="_blank">Superhuman ↗</a>
|
||||
</div>
|
||||
<p>> Añadimos a Carl Lewis que tiene mucho nombre
|
||||
|
||||
También quería compartir una oportunidad que puede ser de interés para quienes trabajáis en liderazgo, gestión de equipos o transfo</p>
|
||||
</article>
|
||||
</li>
|
||||
<li>
|
||||
<span class="dot"></span>
|
||||
<article>
|
||||
<div class="row">
|
||||
<div>
|
||||
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
|
||||
<span class="when">25 Jun 2026</span>
|
||||
<h3>Email Open Event 9 julio.pdf</h3>
|
||||
</div>
|
||||
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_suQBkgXM" target="_blank">Superhuman ↗</a>
|
||||
</div>
|
||||
<p>Abrir en Superhuman para el detalle completo.</p>
|
||||
</article>
|
||||
</li>
|
||||
<li>
|
||||
<span class="dot"></span>
|
||||
<article>
|
||||
<div class="row">
|
||||
<div>
|
||||
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
|
||||
<span class="when">18 Jun 2026</span>
|
||||
<h3>Email 1</h3>
|
||||
</div>
|
||||
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_suSUWITB" target="_blank">Superhuman ↗</a>
|
||||
</div>
|
||||
<p>1. EMAIL A TODOS LOS QUE SE HAN REGISTRADO AL EVENTO Y NO HAN IDO
|
||||
|
||||
|
||||
|
||||
TITULO: Ponte al día con lo que te perdiste en el I Salón Internacional de Coaching Ejecutivo
|
||||
|
||||
|
||||
|
||||
Hola NOMBRE,</p>
|
||||
</article>
|
||||
</li>
|
||||
<li>
|
||||
<span class="dot"></span>
|
||||
<article>
|
||||
<div class="row">
|
||||
<div>
|
||||
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
|
||||
<span class="when">08 Jun 2026</span>
|
||||
<h3>WhatApp</h3>
|
||||
</div>
|
||||
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_suguIU-Q" target="_blank">Superhuman ↗</a>
|
||||
</div>
|
||||
<p>- Hola nombre. Quedan 4 días, te esperamos el viernes a las 9:30 en el saloninternacionacionalcoaching.com., por favor confirma tu asistencia abajo.
|
||||
- Confirmar Asistencia
|
||||
- N</p>
|
||||
</article>
|
||||
</li>
|
||||
<li>
|
||||
<span class="dot"></span>
|
||||
<article>
|
||||
<div class="row">
|
||||
<div>
|
||||
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
|
||||
<span class="when">24 Apr 2026</span>
|
||||
<h3>Secuencia emails 240426</h3>
|
||||
</div>
|
||||
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_sulZ9VOu" target="_blank">Superhuman ↗</a>
|
||||
</div>
|
||||
<p>Es una [secuencia de 6 emails](https://drive.google.com/drive/folders/1i7717lRLgpgU5Y6FZonbbrpWjS3_pD96?usp=sharing), el primero para hoy si es posible y os ha dado tiempo a prepar</p>
|
||||
</article>
|
||||
</li>
|
||||
<li>
|
||||
<span class="dot"></span>
|
||||
<article>
|
||||
<div class="row">
|
||||
<div>
|
||||
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
|
||||
<span class="when">23 Apr 2026</span>
|
||||
<h3>Web Salón V2</h3>
|
||||
</div>
|
||||
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_sumI6wo-" target="_blank">Superhuman ↗</a>
|
||||
</div>
|
||||
<p>**Pestanas: Registro / Agenda / Organiza / Contacto**
|
||||
|
||||
### **AGENDA**
|
||||
|
||||
Te mando por aquí el texto para la agenda: primero poner un texto introductorio, luego los conferenciantes c</p>
|
||||
</article>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
202
scripts/backfill_deal_pipelines.ts
Normal file
202
scripts/backfill_deal_pipelines.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Backfill deals.pipeline_id / pipeline_name / source / name from Clientify
|
||||
* for one tenant so KPI Online filter + attribution work for MoM/YoY.
|
||||
*
|
||||
* TENANT_SLUG=bps npx tsx scripts/backfill_deal_pipelines.ts
|
||||
*/
|
||||
import { config } from 'dotenv';
|
||||
config({ path: '.env.local' });
|
||||
|
||||
const CFY = 'https://api.clientify.com/v1';
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
function envKey(slug: string) {
|
||||
const s = slug.toUpperCase().replace(/-/g, '_');
|
||||
return (
|
||||
process.env[`CLIENTIFY_API_KEY_${s}`] ||
|
||||
process.env.CLIENTIFY_API_KEY ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function isPaidAds(s: string) {
|
||||
const x = s.toLowerCase();
|
||||
return (
|
||||
/^(meta|facebook|instagram|google|google ads|google-ads|google_ads)/.test(
|
||||
x
|
||||
) ||
|
||||
x.includes('facebook') ||
|
||||
x.includes('google ads')
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeSource(s: string | null | undefined): string | null {
|
||||
const x = (s || '').toLowerCase().trim();
|
||||
if (!x) return null;
|
||||
if (isPaidAds(x)) return x.includes('google') ? 'google' : 'meta';
|
||||
if (x.includes('blue parrot') || x.includes('woocommerce') || x === 'ecommerce')
|
||||
return 'ecommerce';
|
||||
if (x.includes('recomend') || x.includes('amigo')) return 'referral';
|
||||
if (
|
||||
x.includes('nivel') ||
|
||||
x.includes('prueba') ||
|
||||
x.startsWith('test') ||
|
||||
x === 'general' ||
|
||||
x === 'organic' ||
|
||||
x === 'unknown' ||
|
||||
x === 'import' ||
|
||||
x === 'manual'
|
||||
)
|
||||
return 'organic';
|
||||
if (x.startsWith('integrations')) return 'integrations-api';
|
||||
return x.slice(0, 80);
|
||||
}
|
||||
|
||||
function inferSource(d: {
|
||||
deal_source?: string;
|
||||
contact_source?: string;
|
||||
tags?: string[];
|
||||
name?: string;
|
||||
}): string | null {
|
||||
const fromDeal = d.deal_source || d.contact_source || '';
|
||||
const tags = (d.tags || []).join(' ').toLowerCase();
|
||||
if (isPaidAds(fromDeal)) return normalizeSource(fromDeal);
|
||||
if (/facebook|instagram|\bmeta\b|\bfb\b/.test(tags)) return 'meta';
|
||||
if (/google/.test(tags)) return 'google';
|
||||
if (fromDeal) return normalizeSource(fromDeal);
|
||||
if (/woocommerce|ecommerce/.test(tags) || /^order\s+\d+/i.test(d.name || ''))
|
||||
return 'ecommerce';
|
||||
return null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const slug = (process.env.TENANT_SLUG || 'bps').trim();
|
||||
const token = envKey(slug);
|
||||
if (!token || !url || !key) throw new Error('missing env');
|
||||
|
||||
const tenants = await (
|
||||
await fetch(
|
||||
`${url}/rest/v1/tenants?slug=eq.${encodeURIComponent(slug)}&select=id,name`,
|
||||
{ headers: { apikey: key, Authorization: `Bearer ${key}` } }
|
||||
)
|
||||
).json();
|
||||
const tid = tenants[0]?.id as string;
|
||||
if (!tid) throw new Error(`tenant ${slug} not found`);
|
||||
console.log('tenant', tenants[0].name, tid);
|
||||
|
||||
// Load warehouse won deals needing backfill (or all)
|
||||
const wh: {
|
||||
id: string;
|
||||
crm_deal_id: string;
|
||||
lead_id: string | null;
|
||||
pipeline_id: string | null;
|
||||
}[] = [];
|
||||
for (let offset = 0; offset < 100000; offset += 1000) {
|
||||
const chunk = await (
|
||||
await fetch(
|
||||
`${url}/rest/v1/deals?tenant_id=eq.${tid}&stage=eq.won&select=id,crm_deal_id,lead_id,pipeline_id&order=won_at.desc.nullslast&limit=1000&offset=${offset}`,
|
||||
{ headers: { apikey: key, Authorization: `Bearer ${key}` } }
|
||||
)
|
||||
).json();
|
||||
if (!Array.isArray(chunk) || !chunk.length) break;
|
||||
wh.push(...chunk);
|
||||
if (chunk.length < 1000) break;
|
||||
}
|
||||
const byCrm = new Map(wh.map((d) => [d.crm_deal_id.replace(/^clientify:/, ''), d]));
|
||||
console.log('warehouse won', wh.length);
|
||||
|
||||
// Lead sources for attribution priority
|
||||
const leadSrc = new Map<string, string>();
|
||||
const leadIds = [...new Set(wh.map((d) => d.lead_id).filter(Boolean))] as string[];
|
||||
for (let i = 0; i < leadIds.length; i += 200) {
|
||||
const ids = leadIds.slice(i, i + 200);
|
||||
const rows = await (
|
||||
await fetch(
|
||||
`${url}/rest/v1/leads?id=in.(${ids.join(',')})&select=id,source`,
|
||||
{ headers: { apikey: key, Authorization: `Bearer ${key}` } }
|
||||
)
|
||||
).json();
|
||||
if (Array.isArray(rows)) {
|
||||
for (const r of rows) if (r.source) leadSrc.set(r.id, r.source);
|
||||
}
|
||||
}
|
||||
|
||||
let page = 1;
|
||||
let patched = 0;
|
||||
let seen = 0;
|
||||
while (page <= 200) {
|
||||
const j = await (
|
||||
await fetch(
|
||||
`${CFY}/deals/?page_size=100&page=${page}&status=3&ordering=-actual_closed_date`,
|
||||
{ headers: { Authorization: `Token ${token}` } }
|
||||
)
|
||||
).json();
|
||||
const results = j.results || [];
|
||||
if (!results.length) break;
|
||||
|
||||
for (const d of results) {
|
||||
seen++;
|
||||
const did = String(d.id);
|
||||
const row = byCrm.get(did);
|
||||
if (!row) continue;
|
||||
|
||||
const pipeUrl = String(d.pipeline || '');
|
||||
const pipeline_id = pipeUrl.includes('/pipelines/')
|
||||
? pipeUrl.replace(/\/$/, '').split('/').pop() || null
|
||||
: null;
|
||||
const pipeline_name = d.pipeline_desc || null;
|
||||
|
||||
const fromLead = row.lead_id ? leadSrc.get(row.lead_id) : null;
|
||||
let source: string | null = null;
|
||||
if (fromLead && isPaidAds(fromLead)) source = normalizeSource(fromLead);
|
||||
else {
|
||||
const inferred = inferSource(d);
|
||||
if (inferred && isPaidAds(inferred)) source = inferred;
|
||||
else if (fromLead) source = normalizeSource(fromLead);
|
||||
else source = inferred;
|
||||
}
|
||||
if (
|
||||
!source &&
|
||||
(pipeline_id === '77547' || /online/i.test(pipeline_name || ''))
|
||||
) {
|
||||
source = 'ecommerce';
|
||||
}
|
||||
|
||||
const r = await fetch(`${url}/rest/v1/deals?id=eq.${row.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
apikey: key,
|
||||
Authorization: `Bearer ${key}`,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
pipeline_id,
|
||||
pipeline_name,
|
||||
source,
|
||||
name: d.name || null,
|
||||
}),
|
||||
});
|
||||
if (r.ok) patched++;
|
||||
}
|
||||
|
||||
const oldest = (results[results.length - 1].actual_closed_date || '').slice(
|
||||
0,
|
||||
10
|
||||
);
|
||||
console.log(`page ${page} seen=${seen} patched=${patched} oldest=${oldest}`);
|
||||
if (!j.next) break;
|
||||
// Stop if we've gone past all warehouse deals' likely range
|
||||
if (oldest && oldest < '2024-01-01') break;
|
||||
page++;
|
||||
}
|
||||
|
||||
console.log('DONE', { seen, patched, warehouse: wh.length });
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
38
scripts/blue-parrot-pipeline-seed.json
Normal file
38
scripts/blue-parrot-pipeline-seed.json
Normal file
@@ -0,0 +1,38 @@
|
||||
[
|
||||
{ "id": "oct-01", "format": "carrusel", "month": "2026-10", "category": "aprende inglés", "title": "3 false friends que te delatan" },
|
||||
{ "id": "oct-02", "format": "story", "month": "2026-10", "category": "aprende inglés", "title": "Palabra del día: deadline" },
|
||||
{ "id": "oct-03", "format": "reel", "month": "2026-10", "category": "speaking", "title": "Preséntate en inglés en 30 segundos" },
|
||||
{ "id": "oct-04", "format": "post", "month": "2026-10", "category": "cursos y certificaciones", "title": "Cursos intensivos: certifica tu nivel" },
|
||||
{ "id": "oct-05", "format": "story", "month": "2026-10", "category": "test de nivel", "title": "¿No sabes por dónde empezar?" },
|
||||
{ "id": "oct-06", "format": "carrusel", "month": "2026-10", "category": "cursos y certificaciones", "title": "Aptis, Cambridge o IELTS: ¿cuál te conviene?" },
|
||||
{ "id": "oct-07", "format": "reel", "month": "2026-10", "category": "aprende inglés", "title": "Make o do: el truco para no fallar" },
|
||||
{ "id": "oct-08", "format": "story", "month": "2026-10", "category": "comunidad", "title": "¿Qué idioma quieres aprender?" },
|
||||
{ "id": "oct-09", "format": "carrusel", "month": "2026-10", "category": "inglés para el trabajo", "title": "3 frases para sonar más natural en una reunión" },
|
||||
{ "id": "oct-10", "format": "reel", "month": "2026-10", "category": "comunidad", "title": "Un día en la academia" },
|
||||
{ "id": "oct-11", "format": "post", "month": "2026-10", "category": "cursos y certificaciones", "title": "Grupos de máximo 8 alumnos" },
|
||||
{ "id": "oct-12", "format": "carrusel", "month": "2026-10", "category": "aprende inglés", "title": "Phrasal verbs con «get» que usarás cada día" },
|
||||
|
||||
{ "id": "nov-01", "format": "carrusel", "month": "2026-11", "category": "inglés con series", "title": "Friendsgiving: el inglés de Acción de Gracias con Friends" },
|
||||
{ "id": "nov-02", "format": "reel", "month": "2026-11", "category": "inglés para el día a día", "title": "Black Friday: 5 palabras que verás en todas las tiendas" },
|
||||
{ "id": "nov-03", "format": "story", "month": "2026-11", "category": "comunidad", "title": "¿Deal o bargain? Tú decides" },
|
||||
{ "id": "nov-04", "format": "carrusel", "month": "2026-11", "category": "inglés con series", "title": "El inglés de Stranger Things" },
|
||||
{ "id": "nov-05", "format": "post", "month": "2026-11", "category": "cursos y certificaciones", "title": "Certifica tu nivel antes de que acabe el año" },
|
||||
{ "id": "nov-06", "format": "reel", "month": "2026-11", "category": "speaking", "title": "Thanks, cheers, much appreciated: cómo dar las gracias en inglés" },
|
||||
{ "id": "nov-07", "format": "story", "month": "2026-11", "category": "aprende inglés", "title": "Palabra del día: leftovers" },
|
||||
|
||||
{ "id": "dic-01", "format": "carrusel", "month": "2026-12", "category": "inglés con películas", "title": "El inglés de Solo en casa" },
|
||||
{ "id": "dic-02", "format": "story", "month": "2026-12", "category": "aprende inglés", "title": "Palabra del día: cosy" },
|
||||
{ "id": "dic-03", "format": "reel", "month": "2026-12", "category": "speaking", "title": "Felicita la Navidad en inglés sin decir solo Merry Christmas" },
|
||||
{ "id": "dic-04", "format": "carrusel", "month": "2026-12", "category": "inglés con películas", "title": "Love Actually: frases para practicar tu inglés" },
|
||||
{ "id": "dic-05", "format": "post", "month": "2026-12", "category": "cursos y certificaciones", "title": "Estas Navidades, regala un idioma" },
|
||||
{ "id": "dic-06", "format": "reel", "month": "2026-12", "category": "aprende inglés", "title": "Christmas vocabulary: mistletoe, stocking, Boxing Day" },
|
||||
{ "id": "dic-07", "format": "story", "month": "2026-12", "category": "comunidad", "title": "¿Tu propósito de 2027 es aprender un idioma?" },
|
||||
|
||||
{ "id": "ene27-01", "format": "carrusel", "month": "2027-01", "category": "aprende inglés", "title": "New Year's resolutions: di tus propósitos en inglés" },
|
||||
{ "id": "ene27-02", "format": "story", "month": "2027-01", "category": "test de nivel", "title": "Empieza el año sabiendo tu nivel" },
|
||||
{ "id": "ene27-03", "format": "reel", "month": "2027-01", "category": "aprende inglés", "title": "Take up, give up, keep up: 3 phrasal verbs para tus propósitos" },
|
||||
{ "id": "ene27-04", "format": "post", "month": "2027-01", "category": "cursos y certificaciones", "title": "Nuevo año, nuevo idioma" },
|
||||
{ "id": "ene27-05", "format": "carrusel", "month": "2027-01", "category": "aprende inglés", "title": "Blue Monday: vocabulario para los días grises" },
|
||||
{ "id": "ene27-06", "format": "reel", "month": "2027-01", "category": "aprende inglés", "title": "Winter vocabulary: freezing, chilly, to bundle up" },
|
||||
{ "id": "ene27-07", "format": "story", "month": "2027-01", "category": "aprende inglés", "title": "Palabra del día: chilly" }
|
||||
]
|
||||
96
scripts/dokploy_apply_ad_daily_leads_numeric.py
Normal file
96
scripts/dokploy_apply_ad_daily_leads_numeric.py
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply sql/ad_daily_leads_numeric.sql via Dokploy supabase-db terminal."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
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")
|
||||
SQL = ROOT / "sql" / "ad_daily_leads_numeric.sql"
|
||||
BASE = "https://dokploy.4ventures.es"
|
||||
|
||||
|
||||
def env(key: str) -> str:
|
||||
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().splitlines():
|
||||
if line.startswith(f"{key}="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||
password = env("DOKPLOY_PASSWORD")
|
||||
if not password:
|
||||
print("NO_PASSWORD", flush=True)
|
||||
return 2
|
||||
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
|
||||
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
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)
|
||||
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
|
||||
page.wait_for_timeout(800)
|
||||
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
|
||||
"supabase-db"
|
||||
)
|
||||
page.wait_for_timeout(1000)
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(200)
|
||||
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
|
||||
page.wait_for_timeout(400)
|
||||
page.get_by_text("Terminal", exact=True).click()
|
||||
page.wait_for_timeout(1000)
|
||||
if page.locator("button", has_text="Bash").count():
|
||||
page.locator("button", has_text="Bash").first.click()
|
||||
page.wait_for_timeout(2800)
|
||||
if page.locator("textarea.xterm-helper-textarea").count():
|
||||
page.locator("textarea.xterm-helper-textarea").first.click()
|
||||
|
||||
def run(cmd: str, wait: int = 800) -> None:
|
||||
page.keyboard.type(cmd, delay=1)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_timeout(wait)
|
||||
|
||||
run("rm -f /tmp/adl.b64 /tmp/adl.sql; echo READY", 1200)
|
||||
for i, ch in enumerate(chunks):
|
||||
op = ">" if i == 0 else ">>"
|
||||
run(f"printf '%s' '{ch}' {op} /tmp/adl.b64", 400)
|
||||
run(
|
||||
"base64 -d /tmp/adl.b64 > /tmp/adl.sql && "
|
||||
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/adl.sql; echo EXIT:$?",
|
||||
8000,
|
||||
)
|
||||
text = page.evaluate(
|
||||
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
|
||||
)
|
||||
browser.close()
|
||||
ok = "EXIT:0" in text
|
||||
print("SQL_OK", ok, flush=True)
|
||||
if not ok:
|
||||
print(text[-1500:], flush=True)
|
||||
return 0 if ok else 6
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
103
scripts/dokploy_apply_ad_daily_link_clicks.py
Normal file
103
scripts/dokploy_apply_ad_daily_link_clicks.py
Normal file
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply sql/ad_daily_link_clicks.sql via Dokploy supabase-db terminal."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
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")
|
||||
SQL = ROOT / "sql" / "ad_daily_link_clicks.sql"
|
||||
BASE = "https://dokploy.4ventures.es"
|
||||
|
||||
|
||||
def env(key: str) -> str:
|
||||
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().splitlines():
|
||||
if line.startswith(f"{key}="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||
password = env("DOKPLOY_PASSWORD")
|
||||
if not password:
|
||||
print("NO_PASSWORD", flush=True)
|
||||
return 2
|
||||
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
|
||||
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
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)
|
||||
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
|
||||
page.wait_for_timeout(800)
|
||||
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
|
||||
"supabase-db"
|
||||
)
|
||||
page.wait_for_timeout(1000)
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(200)
|
||||
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
|
||||
page.wait_for_timeout(400)
|
||||
page.get_by_text("Terminal", exact=True).click()
|
||||
page.wait_for_timeout(1000)
|
||||
if page.locator("button", has_text="Bash").count():
|
||||
page.locator("button", has_text="Bash").first.click()
|
||||
page.wait_for_timeout(2800)
|
||||
if page.locator("textarea.xterm-helper-textarea").count():
|
||||
page.locator("textarea.xterm-helper-textarea").first.click()
|
||||
|
||||
def run(cmd: str, wait: int = 800) -> None:
|
||||
page.keyboard.type(cmd, delay=1)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_timeout(wait)
|
||||
|
||||
run("rm -f /tmp/adlc.b64 /tmp/adlc.sql; echo READY", 1200)
|
||||
for i, ch in enumerate(chunks):
|
||||
op = ">" if i == 0 else ">>"
|
||||
run(f"printf '%s' '{ch}' {op} /tmp/adlc.b64", 400)
|
||||
run(
|
||||
"base64 -d /tmp/adlc.b64 > /tmp/adlc.sql && "
|
||||
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/adlc.sql; echo EXIT:$?",
|
||||
12000,
|
||||
)
|
||||
run(
|
||||
"psql -U postgres -d postgres -c "
|
||||
"\"SELECT column_name,data_type FROM information_schema.columns "
|
||||
"WHERE table_schema='public' AND table_name='ad_daily' "
|
||||
"AND column_name='link_clicks';\" ; echo COL_DONE",
|
||||
4000,
|
||||
)
|
||||
text = page.evaluate(
|
||||
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
|
||||
)
|
||||
browser.close()
|
||||
ok = "EXIT:0" in text and "link_clicks" in text
|
||||
print("SQL_OK", ok, flush=True)
|
||||
if not ok:
|
||||
print(text[-2000:], flush=True)
|
||||
return 0 if ok else 6
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
91
scripts/dokploy_apply_agency_control.py
Normal file
91
scripts/dokploy_apply_agency_control.py
Normal file
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply sql/agency_control.sql via Dokploy supabase-db terminal."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
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")
|
||||
SQL = ROOT / "sql" / "agency_control.sql"
|
||||
BASE = "https://dokploy.4ventures.es"
|
||||
|
||||
|
||||
def env(key: str) -> str:
|
||||
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().splitlines():
|
||||
if line.startswith(f"{key}="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||
password = env("DOKPLOY_PASSWORD")
|
||||
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
|
||||
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
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)
|
||||
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
|
||||
page.wait_for_timeout(800)
|
||||
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
|
||||
"supabase-db"
|
||||
)
|
||||
page.wait_for_timeout(1000)
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(200)
|
||||
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
|
||||
page.wait_for_timeout(400)
|
||||
page.get_by_text("Terminal", exact=True).click()
|
||||
page.wait_for_timeout(1000)
|
||||
if page.locator("button", has_text="Bash").count():
|
||||
page.locator("button", has_text="Bash").first.click()
|
||||
page.wait_for_timeout(2800)
|
||||
if page.locator("textarea.xterm-helper-textarea").count():
|
||||
page.locator("textarea.xterm-helper-textarea").first.click()
|
||||
|
||||
def run(cmd: str, wait: int = 800) -> None:
|
||||
page.keyboard.type(cmd, delay=1)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_timeout(wait)
|
||||
|
||||
run("rm -f /tmp/ac.b64 /tmp/ac.sql; echo READY", 1200)
|
||||
for i, ch in enumerate(chunks):
|
||||
op = ">" if i == 0 else ">>"
|
||||
run(f"printf '%s' '{ch}' {op} /tmp/ac.b64", 400)
|
||||
run(
|
||||
"base64 -d /tmp/ac.b64 > /tmp/ac.sql && "
|
||||
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/ac.sql; echo EXIT:$?",
|
||||
8000,
|
||||
)
|
||||
text = page.evaluate(
|
||||
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
|
||||
)
|
||||
browser.close()
|
||||
ok = "EXIT:0" in text
|
||||
print("SQL_OK", ok, flush=True)
|
||||
return 0 if ok else 6
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
107
scripts/dokploy_apply_agency_sql.py
Normal file
107
scripts/dokploy_apply_agency_sql.py
Normal file
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply sql/agency_portfolio.sql via Dokploy supabase-db terminal."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
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")
|
||||
SQL = ROOT / "sql" / "agency_portfolio.sql"
|
||||
SHOT = ROOT / "scripts" / "dokploy-shots"
|
||||
BASE = "https://dokploy.4ventures.es"
|
||||
|
||||
|
||||
def env(key: str) -> str:
|
||||
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().splitlines():
|
||||
if line.startswith(f"{key}="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||
password = env("DOKPLOY_PASSWORD")
|
||||
if not password:
|
||||
print("NO_PASSWORD", flush=True)
|
||||
return 2
|
||||
SHOT.mkdir(parents=True, exist_ok=True)
|
||||
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
|
||||
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1500, "height": 1100})
|
||||
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
|
||||
page.wait_for_timeout(500)
|
||||
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)
|
||||
|
||||
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
|
||||
page.wait_for_timeout(800)
|
||||
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
|
||||
"supabase-db"
|
||||
)
|
||||
page.wait_for_timeout(1200)
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(200)
|
||||
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
|
||||
page.wait_for_timeout(400)
|
||||
page.get_by_text("Terminal", exact=True).click()
|
||||
page.wait_for_timeout(1200)
|
||||
# db image usually has bash
|
||||
if page.locator("button", has_text="Bash").count():
|
||||
page.locator("button", has_text="Bash").first.click()
|
||||
page.wait_for_timeout(3000)
|
||||
if page.locator("textarea.xterm-helper-textarea").count():
|
||||
page.locator("textarea.xterm-helper-textarea").first.click()
|
||||
|
||||
def run(cmd: str, wait: int = 900) -> None:
|
||||
page.keyboard.type(cmd, delay=1)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_timeout(wait)
|
||||
|
||||
run("rm -f /tmp/agency.b64 /tmp/agency.sql; echo READY", 1500)
|
||||
for i, ch in enumerate(chunks):
|
||||
op = ">" if i == 0 else ">>"
|
||||
run(f"printf '%s' '{ch}' {op} /tmp/agency.b64", 500)
|
||||
run(
|
||||
"base64 -d /tmp/agency.b64 > /tmp/agency.sql && wc -c /tmp/agency.sql && "
|
||||
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/agency.sql; echo EXIT:$?",
|
||||
10000,
|
||||
)
|
||||
run(
|
||||
"psql -U postgres -d postgres -c \"select count(*) as integrations from public.integrations;\" ; echo DONE",
|
||||
4000,
|
||||
)
|
||||
text = page.evaluate(
|
||||
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
|
||||
)
|
||||
page.screenshot(path=str(SHOT / "agency_sql.png"), full_page=True)
|
||||
browser.close()
|
||||
|
||||
ok = "EXIT:0" in text
|
||||
print("EXIT0", ok, flush=True)
|
||||
for ln in text.splitlines():
|
||||
if re.search(r"EXIT:|ERROR|integrations|DONE|READY", ln, re.I):
|
||||
print(">", ln.strip()[:200], flush=True)
|
||||
return 0 if ok else 6
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
127
scripts/dokploy_apply_asset_files.py
Normal file
127
scripts/dokploy_apply_asset_files.py
Normal file
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply sql/asset_files.sql + ensure storage bucket via Dokploy / API."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
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")
|
||||
SQL = ROOT / "sql" / "asset_files.sql"
|
||||
BASE = "https://dokploy.4ventures.es"
|
||||
|
||||
|
||||
def env(key: str) -> str:
|
||||
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().splitlines():
|
||||
if line.startswith(f"{key}="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||
password = env("DOKPLOY_PASSWORD")
|
||||
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
|
||||
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
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)
|
||||
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
|
||||
page.wait_for_timeout(800)
|
||||
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
|
||||
"supabase-db"
|
||||
)
|
||||
page.wait_for_timeout(1000)
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(200)
|
||||
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
|
||||
page.wait_for_timeout(400)
|
||||
page.get_by_text("Terminal", exact=True).click()
|
||||
page.wait_for_timeout(1000)
|
||||
if page.locator("button", has_text="Bash").count():
|
||||
page.locator("button", has_text="Bash").first.click()
|
||||
page.wait_for_timeout(2800)
|
||||
if page.locator("textarea.xterm-helper-textarea").count():
|
||||
page.locator("textarea.xterm-helper-textarea").first.click()
|
||||
|
||||
def run(cmd: str, wait: int = 800) -> None:
|
||||
page.keyboard.type(cmd, delay=1)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_timeout(wait)
|
||||
|
||||
run("rm -f /tmp/af.b64 /tmp/af.sql; echo READY", 1200)
|
||||
for i, ch in enumerate(chunks):
|
||||
op = ">" if i == 0 else ">>"
|
||||
run(f"printf '%s' '{ch}' {op} /tmp/af.b64", 400)
|
||||
run(
|
||||
"base64 -d /tmp/af.b64 > /tmp/af.sql && "
|
||||
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/af.sql; echo EXIT:$?",
|
||||
9000,
|
||||
)
|
||||
text = page.evaluate(
|
||||
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
|
||||
)
|
||||
browser.close()
|
||||
ok = "EXIT:0" in text
|
||||
print("SQL_OK", ok, flush=True)
|
||||
|
||||
# Create bucket via service role from .env.local
|
||||
vals = {}
|
||||
for ln in (ROOT / ".env.local").read_text().splitlines():
|
||||
if "=" in ln and not ln.strip().startswith("#"):
|
||||
k, v = ln.split("=", 1)
|
||||
vals[k.strip()] = v.strip().strip('"').strip("'")
|
||||
url = vals["NEXT_PUBLIC_SUPABASE_URL"].rstrip("/")
|
||||
key = vals["SUPABASE_SERVICE_ROLE_KEY"]
|
||||
req = urllib.request.Request(
|
||||
url + "/storage/v1/bucket",
|
||||
data=json.dumps(
|
||||
{
|
||||
"id": "tenant-assets",
|
||||
"name": "tenant-assets",
|
||||
"public": False,
|
||||
"file_size_limit": 83886080,
|
||||
}
|
||||
).encode(),
|
||||
headers={
|
||||
"apikey": key,
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
print("BUCKET", r.status, flush=True)
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode()[:200]
|
||||
print("BUCKET", e.code, body, flush=True)
|
||||
if e.code not in (409, 400) and "exist" not in body.lower():
|
||||
return 5 if not ok else 0
|
||||
return 0 if ok else 6
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
92
scripts/dokploy_apply_content_collab.py
Normal file
92
scripts/dokploy_apply_content_collab.py
Normal file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply sql/content_collab.sql via Dokploy supabase-db terminal."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
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")
|
||||
SQL = ROOT / "sql" / "content_collab.sql"
|
||||
BASE = "https://dokploy.4ventures.es"
|
||||
|
||||
|
||||
def env(key: str) -> str:
|
||||
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().splitlines():
|
||||
if line.startswith(f"{key}="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||
password = env("DOKPLOY_PASSWORD")
|
||||
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
|
||||
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
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)
|
||||
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
|
||||
page.wait_for_timeout(800)
|
||||
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
|
||||
"supabase-db"
|
||||
)
|
||||
page.wait_for_timeout(1000)
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(200)
|
||||
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
|
||||
page.wait_for_timeout(400)
|
||||
page.get_by_text("Terminal", exact=True).click()
|
||||
page.wait_for_timeout(1000)
|
||||
if page.locator("button", has_text="Bash").count():
|
||||
page.locator("button", has_text="Bash").first.click()
|
||||
page.wait_for_timeout(2800)
|
||||
if page.locator("textarea.xterm-helper-textarea").count():
|
||||
page.locator("textarea.xterm-helper-textarea").first.click()
|
||||
|
||||
def run(cmd: str, wait: int = 800) -> None:
|
||||
page.keyboard.type(cmd, delay=1)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_timeout(wait)
|
||||
|
||||
run("rm -f /tmp/cc.b64 /tmp/cc.sql; echo READY", 1200)
|
||||
for i, ch in enumerate(chunks):
|
||||
op = ">" if i == 0 else ">>"
|
||||
run(f"printf '%s' '{ch}' {op} /tmp/cc.b64", 400)
|
||||
run(
|
||||
"base64 -d /tmp/cc.b64 > /tmp/cc.sql && "
|
||||
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/cc.sql; echo EXIT:$?",
|
||||
14000,
|
||||
)
|
||||
text = page.evaluate(
|
||||
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
|
||||
)
|
||||
browser.close()
|
||||
ok = "EXIT:0" in text
|
||||
print("SQL_OK", ok, flush=True)
|
||||
print(text[-600:], flush=True)
|
||||
return 0 if ok else 6
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
92
scripts/dokploy_apply_creatives_preview.py
Normal file
92
scripts/dokploy_apply_creatives_preview.py
Normal file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply sql/creatives_preview.sql via Dokploy supabase-db terminal."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
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")
|
||||
SQL = ROOT / "sql" / "creatives_preview.sql"
|
||||
BASE = "https://dokploy.4ventures.es"
|
||||
|
||||
|
||||
def env(key: str) -> str:
|
||||
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().splitlines():
|
||||
if line.startswith(f"{key}="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||
password = env("DOKPLOY_PASSWORD")
|
||||
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
|
||||
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
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)
|
||||
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
|
||||
page.wait_for_timeout(800)
|
||||
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
|
||||
"supabase-db"
|
||||
)
|
||||
page.wait_for_timeout(1000)
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(200)
|
||||
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
|
||||
page.wait_for_timeout(400)
|
||||
page.get_by_text("Terminal", exact=True).click()
|
||||
page.wait_for_timeout(1000)
|
||||
if page.locator("button", has_text="Bash").count():
|
||||
page.locator("button", has_text="Bash").first.click()
|
||||
page.wait_for_timeout(2800)
|
||||
if page.locator("textarea.xterm-helper-textarea").count():
|
||||
page.locator("textarea.xterm-helper-textarea").first.click()
|
||||
|
||||
def run(cmd: str, wait: int = 800) -> None:
|
||||
page.keyboard.type(cmd, delay=1)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_timeout(wait)
|
||||
|
||||
run("rm -f /tmp/cc.b64 /tmp/cc.sql; echo READY", 1200)
|
||||
for i, ch in enumerate(chunks):
|
||||
op = ">" if i == 0 else ">>"
|
||||
run(f"printf '%s' '{ch}' {op} /tmp/cc.b64", 400)
|
||||
run(
|
||||
"base64 -d /tmp/cc.b64 > /tmp/cc.sql && "
|
||||
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/cc.sql; echo EXIT:$?",
|
||||
14000,
|
||||
)
|
||||
text = page.evaluate(
|
||||
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
|
||||
)
|
||||
browser.close()
|
||||
ok = "EXIT:0" in text
|
||||
print("SQL_OK", ok, flush=True)
|
||||
print(text[-600:], flush=True)
|
||||
return 0 if ok else 6
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
92
scripts/dokploy_apply_creatives_upload_pmax.py
Normal file
92
scripts/dokploy_apply_creatives_upload_pmax.py
Normal file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply sql/creatives_upload_pmax.sql via Dokploy supabase-db terminal."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
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")
|
||||
SQL = ROOT / "sql" / "creatives_upload_pmax.sql"
|
||||
BASE = "https://dokploy.4ventures.es"
|
||||
|
||||
|
||||
def env(key: str) -> str:
|
||||
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().splitlines():
|
||||
if line.startswith(f"{key}="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||
password = env("DOKPLOY_PASSWORD")
|
||||
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
|
||||
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
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)
|
||||
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
|
||||
page.wait_for_timeout(800)
|
||||
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
|
||||
"supabase-db"
|
||||
)
|
||||
page.wait_for_timeout(1000)
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(200)
|
||||
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
|
||||
page.wait_for_timeout(400)
|
||||
page.get_by_text("Terminal", exact=True).click()
|
||||
page.wait_for_timeout(1000)
|
||||
if page.locator("button", has_text="Bash").count():
|
||||
page.locator("button", has_text="Bash").first.click()
|
||||
page.wait_for_timeout(2800)
|
||||
if page.locator("textarea.xterm-helper-textarea").count():
|
||||
page.locator("textarea.xterm-helper-textarea").first.click()
|
||||
|
||||
def run(cmd: str, wait: int = 800) -> None:
|
||||
page.keyboard.type(cmd, delay=1)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_timeout(wait)
|
||||
|
||||
run("rm -f /tmp/cc.b64 /tmp/cc.sql; echo READY", 1200)
|
||||
for i, ch in enumerate(chunks):
|
||||
op = ">" if i == 0 else ">>"
|
||||
run(f"printf '%s' '{ch}' {op} /tmp/cc.b64", 400)
|
||||
run(
|
||||
"base64 -d /tmp/cc.b64 > /tmp/cc.sql && "
|
||||
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/cc.sql; echo EXIT:$?",
|
||||
14000,
|
||||
)
|
||||
text = page.evaluate(
|
||||
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
|
||||
)
|
||||
browser.close()
|
||||
ok = "EXIT:0" in text
|
||||
print("SQL_OK", ok, flush=True)
|
||||
print(text[-600:], flush=True)
|
||||
return 0 if ok else 6
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
96
scripts/dokploy_apply_deals_pipeline.py
Normal file
96
scripts/dokploy_apply_deals_pipeline.py
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply sql/deals_pipeline.sql via Dokploy supabase-db terminal."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
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")
|
||||
SQL = ROOT / "sql" / "deals_pipeline.sql"
|
||||
BASE = "https://dokploy.4ventures.es"
|
||||
|
||||
|
||||
def env(key: str) -> str:
|
||||
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().splitlines():
|
||||
if line.startswith(f"{key}="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||
password = env("DOKPLOY_PASSWORD")
|
||||
if not password:
|
||||
print("NO_PASSWORD", flush=True)
|
||||
return 2
|
||||
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
|
||||
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
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)
|
||||
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
|
||||
page.wait_for_timeout(800)
|
||||
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
|
||||
"supabase-db"
|
||||
)
|
||||
page.wait_for_timeout(1000)
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(200)
|
||||
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
|
||||
page.wait_for_timeout(400)
|
||||
page.get_by_text("Terminal", exact=True).click()
|
||||
page.wait_for_timeout(1000)
|
||||
if page.locator("button", has_text="Bash").count():
|
||||
page.locator("button", has_text="Bash").first.click()
|
||||
page.wait_for_timeout(2800)
|
||||
if page.locator("textarea.xterm-helper-textarea").count():
|
||||
page.locator("textarea.xterm-helper-textarea").first.click()
|
||||
|
||||
def run(cmd: str, wait: int = 800) -> None:
|
||||
page.keyboard.type(cmd, delay=1)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_timeout(wait)
|
||||
|
||||
run("rm -f /tmp/dp.b64 /tmp/dp.sql; echo READY", 1200)
|
||||
for i, ch in enumerate(chunks):
|
||||
op = ">" if i == 0 else ">>"
|
||||
run(f"printf '%s' '{ch}' {op} /tmp/dp.b64", 400)
|
||||
run(
|
||||
"base64 -d /tmp/dp.b64 > /tmp/dp.sql && "
|
||||
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/dp.sql; echo EXIT:$?",
|
||||
8000,
|
||||
)
|
||||
text = page.evaluate(
|
||||
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
|
||||
)
|
||||
browser.close()
|
||||
ok = "EXIT:0" in text
|
||||
print("SQL_OK", ok, flush=True)
|
||||
if not ok:
|
||||
print(text[-1200:], flush=True)
|
||||
return 0 if ok else 6
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
92
scripts/dokploy_apply_google_drive.py
Normal file
92
scripts/dokploy_apply_google_drive.py
Normal file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply sql/google_drive.sql via Dokploy supabase-db terminal."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
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")
|
||||
SQL = ROOT / "sql" / "google_drive.sql"
|
||||
BASE = "https://dokploy.4ventures.es"
|
||||
|
||||
|
||||
def env(key: str) -> str:
|
||||
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().splitlines():
|
||||
if line.startswith(f"{key}="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||
password = env("DOKPLOY_PASSWORD")
|
||||
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
|
||||
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
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)
|
||||
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
|
||||
page.wait_for_timeout(800)
|
||||
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
|
||||
"supabase-db"
|
||||
)
|
||||
page.wait_for_timeout(1000)
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(200)
|
||||
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
|
||||
page.wait_for_timeout(400)
|
||||
page.get_by_text("Terminal", exact=True).click()
|
||||
page.wait_for_timeout(1000)
|
||||
if page.locator("button", has_text="Bash").count():
|
||||
page.locator("button", has_text="Bash").first.click()
|
||||
page.wait_for_timeout(2800)
|
||||
if page.locator("textarea.xterm-helper-textarea").count():
|
||||
page.locator("textarea.xterm-helper-textarea").first.click()
|
||||
|
||||
def run(cmd: str, wait: int = 800) -> None:
|
||||
page.keyboard.type(cmd, delay=1)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_timeout(wait)
|
||||
|
||||
run("rm -f /tmp/gd.b64 /tmp/gd.sql; echo READY", 1200)
|
||||
for i, ch in enumerate(chunks):
|
||||
op = ">" if i == 0 else ">>"
|
||||
run(f"printf '%s' '{ch}' {op} /tmp/gd.b64", 400)
|
||||
run(
|
||||
"base64 -d /tmp/gd.b64 > /tmp/gd.sql && "
|
||||
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/gd.sql; echo EXIT:$?",
|
||||
12000,
|
||||
)
|
||||
text = page.evaluate(
|
||||
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
|
||||
)
|
||||
browser.close()
|
||||
ok = "EXIT:0" in text
|
||||
print("SQL_OK", ok, flush=True)
|
||||
print(text[-600:], flush=True)
|
||||
return 0 if ok else 6
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
91
scripts/dokploy_apply_organic_geo.py
Normal file
91
scripts/dokploy_apply_organic_geo.py
Normal file
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply sql/organic_geo.sql via Dokploy supabase-db terminal."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
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")
|
||||
SQL = ROOT / "sql" / "organic_geo.sql"
|
||||
BASE = "https://dokploy.4ventures.es"
|
||||
|
||||
|
||||
def env(key: str) -> str:
|
||||
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().splitlines():
|
||||
if line.startswith(f"{key}="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||
password = env("DOKPLOY_PASSWORD")
|
||||
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
|
||||
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
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)
|
||||
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
|
||||
page.wait_for_timeout(800)
|
||||
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
|
||||
"supabase-db"
|
||||
)
|
||||
page.wait_for_timeout(1000)
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(200)
|
||||
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
|
||||
page.wait_for_timeout(400)
|
||||
page.get_by_text("Terminal", exact=True).click()
|
||||
page.wait_for_timeout(1000)
|
||||
if page.locator("button", has_text="Bash").count():
|
||||
page.locator("button", has_text="Bash").first.click()
|
||||
page.wait_for_timeout(2800)
|
||||
if page.locator("textarea.xterm-helper-textarea").count():
|
||||
page.locator("textarea.xterm-helper-textarea").first.click()
|
||||
|
||||
def run(cmd: str, wait: int = 800) -> None:
|
||||
page.keyboard.type(cmd, delay=1)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_timeout(wait)
|
||||
|
||||
run("rm -f /tmp/og.b64 /tmp/og.sql; echo READY", 1200)
|
||||
for i, ch in enumerate(chunks):
|
||||
op = ">" if i == 0 else ">>"
|
||||
run(f"printf '%s' '{ch}' {op} /tmp/og.b64", 400)
|
||||
run(
|
||||
"base64 -d /tmp/og.b64 > /tmp/og.sql && "
|
||||
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/og.sql; echo EXIT:$?",
|
||||
8000,
|
||||
)
|
||||
text = page.evaluate(
|
||||
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
|
||||
)
|
||||
browser.close()
|
||||
ok = "EXIT:0" in text
|
||||
print("SQL_OK", ok, flush=True)
|
||||
return 0 if ok else 6
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
92
scripts/dokploy_apply_seguimiento.py
Normal file
92
scripts/dokploy_apply_seguimiento.py
Normal file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply sql/seguimiento.sql via Dokploy supabase-db terminal."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
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")
|
||||
SQL = ROOT / "sql" / "seguimiento.sql"
|
||||
BASE = "https://dokploy.4ventures.es"
|
||||
|
||||
|
||||
def env(key: str) -> str:
|
||||
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().splitlines():
|
||||
if line.startswith(f"{key}="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||
password = env("DOKPLOY_PASSWORD")
|
||||
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
|
||||
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
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)
|
||||
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
|
||||
page.wait_for_timeout(800)
|
||||
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
|
||||
"supabase-db"
|
||||
)
|
||||
page.wait_for_timeout(1000)
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(200)
|
||||
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
|
||||
page.wait_for_timeout(400)
|
||||
page.get_by_text("Terminal", exact=True).click()
|
||||
page.wait_for_timeout(1000)
|
||||
if page.locator("button", has_text="Bash").count():
|
||||
page.locator("button", has_text="Bash").first.click()
|
||||
page.wait_for_timeout(2800)
|
||||
if page.locator("textarea.xterm-helper-textarea").count():
|
||||
page.locator("textarea.xterm-helper-textarea").first.click()
|
||||
|
||||
def run(cmd: str, wait: int = 800) -> None:
|
||||
page.keyboard.type(cmd, delay=1)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_timeout(wait)
|
||||
|
||||
run("rm -f /tmp/seg.b64 /tmp/seg.sql; echo READY", 1200)
|
||||
for i, ch in enumerate(chunks):
|
||||
op = ">" if i == 0 else ">>"
|
||||
run(f"printf '%s' '{ch}' {op} /tmp/seg.b64", 400)
|
||||
run(
|
||||
"base64 -d /tmp/seg.b64 > /tmp/seg.sql && "
|
||||
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/seg.sql; echo EXIT:$?",
|
||||
14000,
|
||||
)
|
||||
text = page.evaluate(
|
||||
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
|
||||
)
|
||||
browser.close()
|
||||
ok = "EXIT:0" in text
|
||||
print("SQL_OK", ok, flush=True)
|
||||
print(text[-600:], flush=True)
|
||||
return 0 if ok else 6
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
99
scripts/dokploy_apply_seo_audits.py
Normal file
99
scripts/dokploy_apply_seo_audits.py
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply sql/seo_audits.sql via Dokploy supabase-db terminal."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
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")
|
||||
SQL = ROOT / "sql" / "seo_audits.sql"
|
||||
BASE = "https://dokploy.4ventures.es"
|
||||
|
||||
|
||||
def env(key: str) -> str:
|
||||
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().splitlines():
|
||||
if line.startswith(f"{key}="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||
password = env("DOKPLOY_PASSWORD")
|
||||
if not password:
|
||||
print("missing DOKPLOY_PASSWORD", flush=True)
|
||||
return 2
|
||||
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
|
||||
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(
|
||||
headless=True,
|
||||
channel="chrome",
|
||||
)
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
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)
|
||||
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
|
||||
page.wait_for_timeout(800)
|
||||
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
|
||||
"supabase-db"
|
||||
)
|
||||
page.wait_for_timeout(1000)
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(200)
|
||||
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
|
||||
page.wait_for_timeout(400)
|
||||
page.get_by_text("Terminal", exact=True).click()
|
||||
page.wait_for_timeout(1000)
|
||||
if page.locator("button", has_text="Bash").count():
|
||||
page.locator("button", has_text="Bash").first.click()
|
||||
page.wait_for_timeout(2800)
|
||||
if page.locator("textarea.xterm-helper-textarea").count():
|
||||
page.locator("textarea.xterm-helper-textarea").first.click()
|
||||
|
||||
def run(cmd: str, wait: int = 800) -> None:
|
||||
page.keyboard.type(cmd, delay=1)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_timeout(wait)
|
||||
|
||||
run("rm -f /tmp/sa.b64 /tmp/sa.sql; echo READY", 1200)
|
||||
for i, ch in enumerate(chunks):
|
||||
op = ">" if i == 0 else ">>"
|
||||
run(f"printf '%s' '{ch}' {op} /tmp/sa.b64", 400)
|
||||
run(
|
||||
"base64 -d /tmp/sa.b64 > /tmp/sa.sql && "
|
||||
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/sa.sql; echo EXIT:$?",
|
||||
8000,
|
||||
)
|
||||
text = page.evaluate(
|
||||
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
|
||||
)
|
||||
browser.close()
|
||||
ok = "EXIT:0" in text
|
||||
print("SQL_OK", ok, flush=True)
|
||||
if not ok:
|
||||
print(text[-2000:], flush=True)
|
||||
return 0 if ok else 6
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
92
scripts/dokploy_apply_targets_budget.py
Normal file
92
scripts/dokploy_apply_targets_budget.py
Normal file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply sql/targets_budget.sql via Dokploy supabase-db terminal."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
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")
|
||||
SQL = ROOT / "sql" / "targets_budget.sql"
|
||||
BASE = "https://dokploy.4ventures.es"
|
||||
|
||||
|
||||
def env(key: str) -> str:
|
||||
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().splitlines():
|
||||
if line.startswith(f"{key}="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||
password = env("DOKPLOY_PASSWORD")
|
||||
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
|
||||
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
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)
|
||||
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
|
||||
page.wait_for_timeout(800)
|
||||
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
|
||||
"supabase-db"
|
||||
)
|
||||
page.wait_for_timeout(1000)
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(200)
|
||||
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
|
||||
page.wait_for_timeout(400)
|
||||
page.get_by_text("Terminal", exact=True).click()
|
||||
page.wait_for_timeout(1000)
|
||||
if page.locator("button", has_text="Bash").count():
|
||||
page.locator("button", has_text="Bash").first.click()
|
||||
page.wait_for_timeout(2800)
|
||||
if page.locator("textarea.xterm-helper-textarea").count():
|
||||
page.locator("textarea.xterm-helper-textarea").first.click()
|
||||
|
||||
def run(cmd: str, wait: int = 800) -> None:
|
||||
page.keyboard.type(cmd, delay=1)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_timeout(wait)
|
||||
|
||||
run("rm -f /tmp/tb.b64 /tmp/tb.sql; echo READY", 1200)
|
||||
for i, ch in enumerate(chunks):
|
||||
op = ">" if i == 0 else ">>"
|
||||
run(f"printf '%s' '{ch}' {op} /tmp/tb.b64", 400)
|
||||
run(
|
||||
"base64 -d /tmp/tb.b64 > /tmp/tb.sql && "
|
||||
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/tb.sql; echo EXIT:$?",
|
||||
14000,
|
||||
)
|
||||
text = page.evaluate(
|
||||
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
|
||||
)
|
||||
browser.close()
|
||||
ok = "EXIT:0" in text
|
||||
print("SQL_OK", ok, flush=True)
|
||||
print(text[-600:], flush=True)
|
||||
return 0 if ok else 6
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
91
scripts/dokploy_apply_tenant_ficha.py
Normal file
91
scripts/dokploy_apply_tenant_ficha.py
Normal file
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply sql/tenant_ficha.sql via Dokploy supabase-db terminal."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
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")
|
||||
SQL = ROOT / "sql" / "tenant_ficha.sql"
|
||||
BASE = "https://dokploy.4ventures.es"
|
||||
|
||||
|
||||
def env(key: str) -> str:
|
||||
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().splitlines():
|
||||
if line.startswith(f"{key}="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||
password = env("DOKPLOY_PASSWORD")
|
||||
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
|
||||
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
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)
|
||||
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
|
||||
page.wait_for_timeout(800)
|
||||
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
|
||||
"supabase-db"
|
||||
)
|
||||
page.wait_for_timeout(1000)
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(200)
|
||||
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
|
||||
page.wait_for_timeout(400)
|
||||
page.get_by_text("Terminal", exact=True).click()
|
||||
page.wait_for_timeout(1000)
|
||||
if page.locator("button", has_text="Bash").count():
|
||||
page.locator("button", has_text="Bash").first.click()
|
||||
page.wait_for_timeout(2800)
|
||||
if page.locator("textarea.xterm-helper-textarea").count():
|
||||
page.locator("textarea.xterm-helper-textarea").first.click()
|
||||
|
||||
def run(cmd: str, wait: int = 800) -> None:
|
||||
page.keyboard.type(cmd, delay=1)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_timeout(wait)
|
||||
|
||||
run("rm -f /tmp/tf.b64 /tmp/tf.sql; echo READY", 1200)
|
||||
for i, ch in enumerate(chunks):
|
||||
op = ">" if i == 0 else ">>"
|
||||
run(f"printf '%s' '{ch}' {op} /tmp/tf.b64", 400)
|
||||
run(
|
||||
"base64 -d /tmp/tf.b64 > /tmp/tf.sql && "
|
||||
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/tf.sql; echo EXIT:$?",
|
||||
8000,
|
||||
)
|
||||
text = page.evaluate(
|
||||
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
|
||||
)
|
||||
browser.close()
|
||||
ok = "EXIT:0" in text
|
||||
print("SQL_OK", ok, flush=True)
|
||||
return 0 if ok else 6
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
164
scripts/dokploy_apply_traefik_harden.py
Normal file
164
scripts/dokploy_apply_traefik_harden.py
Normal file
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply Traefik harden patches (office IP allowlist). Does NOT touch portal.yml / app / supabase."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
|
||||
ROOT = Path("/Users/4ventures/4ventures-client-portal")
|
||||
PATCH = ROOT / ".ops" / "traefik-patches"
|
||||
SHOT = ROOT / "scripts" / "dokploy-shots"
|
||||
BASE = "https://dokploy.4ventures.es"
|
||||
|
||||
# (ui label in Traefik file list, local patch file)
|
||||
FILES = [
|
||||
("middleware-hardening.yml", "middleware-hardening.yml"),
|
||||
("gitea.yml", "gitea.yml"),
|
||||
("n8n-hardened.yml", "n8n-hardened.yml"),
|
||||
("rutas-manuales.yml", "rutas-manuales.yml"),
|
||||
]
|
||||
|
||||
|
||||
def env(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 set_monaco(page, val: str) -> str:
|
||||
mode = page.evaluate(
|
||||
"""(val) => {
|
||||
const m = window.monaco;
|
||||
if (m && m.editor) {
|
||||
const eds = m.editor.getEditors();
|
||||
if (eds && eds[0]) { eds[0].setValue(val); return 'monaco'; }
|
||||
}
|
||||
const cm = document.querySelector('.cm-content');
|
||||
if (cm) {
|
||||
cm.focus();
|
||||
return 'cm';
|
||||
}
|
||||
return 'none';
|
||||
}""",
|
||||
val,
|
||||
)
|
||||
if mode == "cm":
|
||||
page.locator(".cm-content").first.click(force=True)
|
||||
page.keyboard.press("Meta+A")
|
||||
page.keyboard.insert_text(val)
|
||||
return mode
|
||||
|
||||
|
||||
def probe(url: str) -> tuple[int, str]:
|
||||
try:
|
||||
req = urllib.request.Request(url, method="GET", headers={"User-Agent": "4v-harden-probe"})
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
return r.status, (r.read(80) or b"").decode("utf-8", "replace")
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, (e.read(80) or b"").decode("utf-8", "replace")
|
||||
except Exception as e:
|
||||
return 0, type(e).__name__
|
||||
|
||||
|
||||
def main() -> int:
|
||||
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||
password = env("DOKPLOY_PASSWORD")
|
||||
if not password:
|
||||
print("NO_DOKPLOY_PASSWORD", flush=True)
|
||||
return 2
|
||||
SHOT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for label, fname in FILES:
|
||||
if not (PATCH / fname).is_file():
|
||||
print("MISSING_PATCH", fname, flush=True)
|
||||
return 3
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1600, "height": 1200})
|
||||
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
|
||||
page.wait_for_timeout(700)
|
||||
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)
|
||||
|
||||
page.goto(f"{BASE}/dashboard/traefik", wait_until="networkidle", timeout=120000)
|
||||
page.wait_for_timeout(1000)
|
||||
page.get_by_text(re.compile(r"^dynamic$", re.I)).first.click()
|
||||
page.wait_for_timeout(900)
|
||||
|
||||
for label, fname in FILES:
|
||||
yaml = (PATCH / fname).read_text(encoding="utf-8")
|
||||
# find file in list
|
||||
loc = page.get_by_text(re.compile(rf"^{re.escape(label)}$", re.I))
|
||||
if not loc.count():
|
||||
loc = page.locator(f"text={label}")
|
||||
if not loc.count():
|
||||
print("FILE_MISSING_IN_UI", label, flush=True)
|
||||
page.screenshot(path=str(SHOT / f"harden_missing_{fname}.png"), full_page=True)
|
||||
browser.close()
|
||||
return 4
|
||||
loc.first.dblclick()
|
||||
page.wait_for_timeout(1200)
|
||||
unlock = page.get_by_role("button", name=re.compile(r"^Unlock$", re.I))
|
||||
if unlock.count():
|
||||
unlock.first.click()
|
||||
page.wait_for_timeout(800)
|
||||
mode = set_monaco(page, yaml)
|
||||
print("set", label, mode, "chars", len(yaml), flush=True)
|
||||
upd = page.get_by_role("button", name=re.compile(r"^Update$", re.I))
|
||||
if not upd.count():
|
||||
upd = page.get_by_role("button", name=re.compile(r"Update|Save", re.I))
|
||||
upd.first.click()
|
||||
page.wait_for_timeout(2000)
|
||||
page.screenshot(path=str(SHOT / f"harden_applied_{fname.replace('.','_')}.png"), full_page=True)
|
||||
# back to file list
|
||||
page.goto(f"{BASE}/dashboard/traefik", wait_until="networkidle", timeout=120000)
|
||||
page.wait_for_timeout(700)
|
||||
page.get_by_text(re.compile(r"^dynamic$", re.I)).first.click()
|
||||
page.wait_for_timeout(700)
|
||||
|
||||
browser.close()
|
||||
|
||||
time.sleep(3)
|
||||
checks = [
|
||||
("git", "https://git.4ventures.es/"),
|
||||
("n8n_ui", "https://n8n.4ventures.es/"),
|
||||
("n8n_agencia_ui", "https://n8n-agencia.4ventures.es/"),
|
||||
("app", "https://app.4ventures.es/api/health"),
|
||||
("dokploy", "https://dokploy.4ventures.es/"),
|
||||
]
|
||||
ok = True
|
||||
for name, url in checks:
|
||||
code, head = probe(url)
|
||||
print(f"probe {name} {code} {head[:60]!r}", flush=True)
|
||||
# From allowlisted IP: git/n8n should NOT be 403
|
||||
if name in ("git", "n8n_ui", "n8n_agencia_ui") and code == 403:
|
||||
ok = False
|
||||
if name == "app" and code != 200:
|
||||
ok = False
|
||||
print("SUCCESS" if ok else "CHECK_FAILED", flush=True)
|
||||
return 0 if ok else 5
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
89
scripts/dokploy_dump_traefik_harden.py
Normal file
89
scripts/dokploy_dump_traefik_harden.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dump Traefik gitea.yml + n8n-hardened.yml + middlewares for harden prep."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
|
||||
OUT = Path("/Users/4ventures/4ventures-client-portal/.ops/traefik-dumps")
|
||||
SHOT = Path("/Users/4ventures/4ventures-client-portal/scripts/dokploy-shots")
|
||||
BASE = "https://dokploy.4ventures.es"
|
||||
FILES = ("middlewares.yml", "middleware-hardening.yml", "gitea.yml", "n8n-hardened.yml")
|
||||
|
||||
|
||||
def env(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 main() -> int:
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||
password = env("DOKPLOY_PASSWORD")
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1600, "height": 1200})
|
||||
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
|
||||
page.wait_for_timeout(700)
|
||||
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)
|
||||
|
||||
page.goto(f"{BASE}/dashboard/traefik", wait_until="networkidle", timeout=120000)
|
||||
page.wait_for_timeout(1000)
|
||||
page.get_by_text(re.compile(r"^dynamic$", re.I)).first.click()
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
for name in FILES:
|
||||
page.locator(f"text={name}").first.dblclick()
|
||||
page.wait_for_timeout(1500)
|
||||
# Unlock not needed for read; try extract from body line numbers area
|
||||
body = page.inner_text("body")
|
||||
# Heuristic: capture from 'http:' or 'http\n' yaml start
|
||||
m = re.search(r"(?ms)^(http:|tcp:|udp:|tls:).*(?=Unlock|Skip YAML|Update\n|$)", body)
|
||||
# Better: get cm content
|
||||
text = page.evaluate(
|
||||
"""() => {
|
||||
const rows = document.querySelector('.cm-content');
|
||||
return rows ? rows.innerText : '';
|
||||
}"""
|
||||
)
|
||||
if not text or len(text) < 20:
|
||||
# fallback strip UI chrome
|
||||
lines = []
|
||||
capture = False
|
||||
for ln in body.splitlines():
|
||||
if ln.strip() in ("http:", "tcp:", "tls:") or ln.startswith("http:"):
|
||||
capture = True
|
||||
if capture:
|
||||
if ln.strip() in ("Unlock", "Update", "Skip YAML validation (for Go templating)"):
|
||||
break
|
||||
lines.append(ln)
|
||||
text = "\n".join(lines)
|
||||
(OUT / name).write_text(text + "\n", encoding="utf-8")
|
||||
print("dumped", name, "chars", len(text), flush=True)
|
||||
page.screenshot(path=str(SHOT / f"dump_{name.replace('.','_')}.png"), full_page=True)
|
||||
|
||||
browser.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
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())
|
||||
90
scripts/dokploy_rls_inventory.py
Normal file
90
scripts/dokploy_rls_inventory.py
Normal file
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run RLS checklist query in supabase-db; print table/RLS summary."""
|
||||
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")
|
||||
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
|
||||
SHOT = ROOT / "scripts/dokploy-shots"
|
||||
BASE = "https://dokploy.4ventures.es"
|
||||
OUT = ROOT / ".ops" / "rls_inventory.txt"
|
||||
|
||||
QUERY = r"""
|
||||
SELECT c.relname AS table_name, c.relrowsecurity AS rls_enabled
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public' AND c.relkind = 'r'
|
||||
ORDER BY 1;
|
||||
"""
|
||||
|
||||
|
||||
def env(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 main() -> int:
|
||||
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||
password = env("DOKPLOY_PASSWORD")
|
||||
# compact to one line for psql -c
|
||||
q = " ".join(QUERY.split())
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1600, "height": 1200})
|
||||
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
|
||||
page.wait_for_timeout(700)
|
||||
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)
|
||||
|
||||
page.goto(f"{BASE}/dashboard/docker", wait_until="networkidle", timeout=120000)
|
||||
page.wait_for_timeout(1000)
|
||||
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill("supabase-db")
|
||||
page.wait_for_timeout(1000)
|
||||
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
|
||||
page.wait_for_timeout(500)
|
||||
page.get_by_text(re.compile(r"^Terminal$", re.I)).first.click()
|
||||
page.wait_for_timeout(3000)
|
||||
|
||||
helper = page.locator("textarea.xterm-helper-textarea")
|
||||
if helper.count():
|
||||
helper.first.click()
|
||||
cmd = f"psql -U postgres -d postgres -c \"{q}\" ; echo RLS_DONE"
|
||||
page.keyboard.type(cmd, delay=2)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_timeout(6000)
|
||||
page.screenshot(path=str(SHOT / "rls_term.png"), full_page=True)
|
||||
text = page.evaluate(
|
||||
"""() => {
|
||||
const rows = document.querySelector('.xterm-rows');
|
||||
return rows ? rows.innerText : '';
|
||||
}"""
|
||||
)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
OUT.write_text(text or "", encoding="utf-8")
|
||||
print(text[-2500:] if text else "NO_TEXT", flush=True)
|
||||
browser.close()
|
||||
print("wrote", OUT, flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
159
scripts/dokploy_set_cron_secret.py
Normal file
159
scripts/dokploy_set_cron_secret.py
Normal file
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Merge CRON_SECRET into client-portal Dokploy env, then Deploy."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
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")
|
||||
BASE = "https://dokploy.4ventures.es"
|
||||
APP_ID = "NZS797t9Rnq9Pj4mHiii3"
|
||||
APP = (
|
||||
f"{BASE}/dashboard/project/Z_4M-uDOH1_--itGSTh4l"
|
||||
f"/environment/bGja70l_a7HQ1dxyXqSuf/services/application/{APP_ID}"
|
||||
)
|
||||
HEALTH = "https://app.4ventures.es/api/health"
|
||||
|
||||
|
||||
def cred(key: str) -> str:
|
||||
import os
|
||||
|
||||
v = os.environ.get(key, "").strip()
|
||||
if v:
|
||||
return v
|
||||
for p in (BPS / ".env", ROOT / ".env.local"):
|
||||
if not p.is_file():
|
||||
continue
|
||||
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 main() -> int:
|
||||
cron = cred("CRON_SECRET")
|
||||
if not cron:
|
||||
print("missing CRON_SECRET locally", flush=True)
|
||||
return 2
|
||||
email = cred("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||
password = cred("DOKPLOY_PASSWORD")
|
||||
if not password:
|
||||
print("missing DOKPLOY_PASSWORD", flush=True)
|
||||
return 2
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
|
||||
page.wait_for_timeout(600)
|
||||
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)
|
||||
|
||||
page.goto(APP, wait_until="networkidle", timeout=120000)
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
result = page.evaluate(
|
||||
"""async ({ appId, cronSecret }) => {
|
||||
const oneUrl = '/api/trpc/application.one?batch=1&input=' + encodeURIComponent(JSON.stringify({ '0': { json: { applicationId: appId } } }));
|
||||
const one = await fetch(oneUrl, { credentials: 'include' });
|
||||
const oneText = await one.text();
|
||||
if (!one.ok) return { ok: false, step: 'one', status: one.status, body: oneText.slice(0, 400) };
|
||||
let envText = '';
|
||||
try {
|
||||
const j = JSON.parse(oneText);
|
||||
const data = j[0]?.result?.data?.json || j[0]?.result?.data || j;
|
||||
envText = data?.env || data?.environment || '';
|
||||
if (typeof envText !== 'string') envText = '';
|
||||
} catch (e) {
|
||||
return { ok: false, step: 'parse', error: String(e), body: oneText.slice(0, 400) };
|
||||
}
|
||||
const lines = envText.split('\\n').filter(Boolean);
|
||||
const map = {};
|
||||
for (const line of lines) {
|
||||
const i = line.indexOf('=');
|
||||
if (i < 0) continue;
|
||||
map[line.slice(0, i)] = line.slice(i + 1);
|
||||
}
|
||||
map['CRON_SECRET'] = cronSecret;
|
||||
const merged = Object.keys(map).map(k => k + '=' + map[k]).join('\\n') + '\\n';
|
||||
const keys = Object.keys(map);
|
||||
|
||||
const tries = [];
|
||||
const payloads = [
|
||||
{ path: '/api/application.update', body: { applicationId: appId, env: merged } },
|
||||
{ path: '/api/trpc/application.update?batch=1', body: { '0': { json: { applicationId: appId, env: merged } } } },
|
||||
{ path: '/api/trpc/application.saveEnvironment?batch=1', body: { '0': { json: { applicationId: appId, env: merged } } } },
|
||||
];
|
||||
for (const t of payloads) {
|
||||
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, 180) });
|
||||
if (r.ok) return { ok: true, keys, tries, hadCron: keys.includes('CRON_SECRET') };
|
||||
}
|
||||
return { ok: false, keys, tries, envLen: envText.length };
|
||||
}""",
|
||||
{"appId": APP_ID, "cronSecret": cron},
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"ok": result.get("ok"),
|
||||
"hadCron": result.get("hadCron"),
|
||||
"keyCount": len(result.get("keys") or []),
|
||||
"tries": result.get("tries"),
|
||||
"step": result.get("step"),
|
||||
"status": result.get("status"),
|
||||
},
|
||||
indent=2,
|
||||
)[:2000],
|
||||
flush=True,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
browser.close()
|
||||
return 6
|
||||
|
||||
page.goto(APP, wait_until="domcontentloaded", timeout=90000)
|
||||
page.wait_for_timeout(700)
|
||||
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(600)
|
||||
conf = page.get_by_role("button", name=re.compile(r"^Confirm$", re.I))
|
||||
if conf.count():
|
||||
conf.first.click()
|
||||
print("DEPLOY_CLICKED", flush=True)
|
||||
|
||||
for i in range(30):
|
||||
time.sleep(10)
|
||||
try:
|
||||
import urllib.request
|
||||
|
||||
code = urllib.request.urlopen(HEALTH, timeout=12).getcode()
|
||||
except Exception as e:
|
||||
code = str(e)
|
||||
print(f"health_try{i}", code, flush=True)
|
||||
if code == 200 and i >= 6:
|
||||
# give container time to pick new env after first healthy
|
||||
break
|
||||
|
||||
browser.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
121
scripts/dokploy_set_gads_login_cid.py
Normal file
121
scripts/dokploy_set_gads_login_cid.py
Normal file
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Merge GOOGLE_ADS_LOGIN_CUSTOMER_ID into client-portal Dokploy env."""
|
||||
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")
|
||||
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
|
||||
BASE = "https://dokploy.4ventures.es"
|
||||
APP_ID = "NZS797t9Rnq9Pj4mHiii3"
|
||||
APP = (
|
||||
f"{BASE}/dashboard/project/Z_4M-uDOH1_--itGSTh4l"
|
||||
f"/environment/bGja70l_a7HQ1dxyXqSuf/services/application/{APP_ID}"
|
||||
)
|
||||
KEY = "GOOGLE_ADS_LOGIN_CUSTOMER_ID"
|
||||
VALUE = "5963150101"
|
||||
|
||||
|
||||
def 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 main() -> int:
|
||||
email = cred("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||
password = cred("DOKPLOY_PASSWORD")
|
||||
if not password:
|
||||
print("missing DOKPLOY_PASSWORD", flush=True)
|
||||
return 2
|
||||
|
||||
chromium = Path.home() / "Library/Caches/ms-playwright/chromium-1148/chrome-mac/Chromium.app/Contents/MacOS/Chromium"
|
||||
launch_kwargs = {"headless": True}
|
||||
if chromium.is_file():
|
||||
launch_kwargs["executable_path"] = str(chromium)
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(**launch_kwargs)
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
|
||||
page.wait_for_timeout(600)
|
||||
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)
|
||||
|
||||
page.goto(APP, wait_until="networkidle", timeout=120000)
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
result = page.evaluate(
|
||||
"""async ({ appId, key, value }) => {
|
||||
const oneUrl = '/api/trpc/application.one?batch=1&input=' + encodeURIComponent(JSON.stringify({ '0': { json: { applicationId: appId } } }));
|
||||
const one = await fetch(oneUrl, { credentials: 'include' });
|
||||
const oneText = await one.text();
|
||||
if (!one.ok) return { ok: false, step: 'one', status: one.status, body: oneText.slice(0, 400) };
|
||||
let envText = '';
|
||||
try {
|
||||
const j = JSON.parse(oneText);
|
||||
const data = j[0]?.result?.data?.json || j[0]?.result?.data || j;
|
||||
envText = data?.env || data?.environment || '';
|
||||
if (typeof envText !== 'string') envText = '';
|
||||
} catch (e) {
|
||||
return { ok: false, step: 'parse', error: String(e), body: oneText.slice(0, 400) };
|
||||
}
|
||||
const lines = envText.split('\\n').filter(Boolean);
|
||||
const map = {};
|
||||
for (const line of lines) {
|
||||
const i = line.indexOf('=');
|
||||
if (i < 0) continue;
|
||||
map[line.slice(0, i)] = line.slice(i + 1);
|
||||
}
|
||||
const prev = map[key] || null;
|
||||
map[key] = value;
|
||||
const merged = Object.keys(map).map(k => k + '=' + map[k]).join('\\n') + '\\n';
|
||||
|
||||
const tries = [];
|
||||
const payloads = [
|
||||
{ path: '/api/application.update', body: { applicationId: appId, env: merged } },
|
||||
{ path: '/api/trpc/application.update?batch=1', body: { '0': { json: { applicationId: appId, env: merged } } } },
|
||||
{ path: '/api/trpc/application.saveEnvironment?batch=1', body: { '0': { json: { applicationId: appId, env: merged } } } },
|
||||
];
|
||||
for (const t of payloads) {
|
||||
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, 160) });
|
||||
if (r.ok) {
|
||||
return { ok: true, prev, set: value, keys: Object.keys(map).length, tries };
|
||||
}
|
||||
}
|
||||
return { ok: false, step: 'update', prev, tries };
|
||||
}""",
|
||||
{"appId": APP_ID, "key": KEY, "value": VALUE},
|
||||
)
|
||||
browser.close()
|
||||
|
||||
print(result, flush=True)
|
||||
return 0 if result.get("ok") else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
177
scripts/dokploy_set_superhuman_env.py
Normal file
177
scripts/dokploy_set_superhuman_env.py
Normal file
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Merge SUPERHUMAN_DOCS_API_KEY into client-portal Dokploy env, then Deploy."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
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")
|
||||
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 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 local_key() -> str:
|
||||
for line in (ROOT / ".env.local").read_text(encoding="utf-8").splitlines():
|
||||
if line.startswith("SUPERHUMAN_DOCS_API_KEY="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
p = ROOT / ".ops" / "superhuman-docs-api-key.txt"
|
||||
if p.is_file():
|
||||
return p.read_text(encoding="utf-8").strip()
|
||||
return ""
|
||||
|
||||
|
||||
def parse_env(text: str) -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
for line in (text or "").splitlines():
|
||||
if not line.strip() or line.strip().startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
out[k.strip()] = v.strip()
|
||||
return out
|
||||
|
||||
|
||||
def dump_env(vals: dict[str, str]) -> str:
|
||||
return "\n".join(f"{k}={v}" for k, v in vals.items()) + "\n"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
sh_key = local_key()
|
||||
if not sh_key:
|
||||
print("missing SUPERHUMAN_DOCS_API_KEY locally", flush=True)
|
||||
return 2
|
||||
email = cred("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||
password = cred("DOKPLOY_PASSWORD")
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
|
||||
page.wait_for_timeout(600)
|
||||
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)
|
||||
|
||||
page.goto(APP, wait_until="networkidle", timeout=120000)
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
result = page.evaluate(
|
||||
"""async ({ appId, shKey }) => {
|
||||
const oneUrl = '/api/trpc/application.one?batch=1&input=' + encodeURIComponent(JSON.stringify({ '0': { json: { applicationId: appId } } }));
|
||||
const one = await fetch(oneUrl, { credentials: 'include' });
|
||||
const oneText = await one.text();
|
||||
if (!one.ok) return { ok: false, step: 'one', status: one.status, body: oneText.slice(0, 400) };
|
||||
let envText = '';
|
||||
try {
|
||||
const j = JSON.parse(oneText);
|
||||
const data = j[0]?.result?.data?.json || j[0]?.result?.data || j;
|
||||
envText = data?.env || data?.environment || '';
|
||||
if (typeof envText !== 'string') envText = '';
|
||||
} catch (e) {
|
||||
return { ok: false, step: 'parse', error: String(e), body: oneText.slice(0, 400) };
|
||||
}
|
||||
const lines = envText.split('\\n').filter(Boolean);
|
||||
const map = {};
|
||||
for (const line of lines) {
|
||||
const i = line.indexOf('=');
|
||||
if (i < 0) continue;
|
||||
map[line.slice(0, i)] = line.slice(i + 1);
|
||||
}
|
||||
map['SUPERHUMAN_DOCS_API_KEY'] = shKey;
|
||||
const merged = Object.keys(map).map(k => k + '=' + map[k]).join('\\n') + '\\n';
|
||||
const keys = Object.keys(map);
|
||||
|
||||
const tries = [];
|
||||
const payloads = [
|
||||
{ path: '/api/application.update', body: { applicationId: appId, env: merged } },
|
||||
{ path: '/api/trpc/application.update?batch=1', body: { '0': { json: { applicationId: appId, env: merged } } } },
|
||||
{ path: '/api/trpc/application.saveEnvironment?batch=1', body: { '0': { json: { applicationId: appId, env: merged } } } },
|
||||
];
|
||||
for (const t of payloads) {
|
||||
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, 180) });
|
||||
if (r.ok) return { ok: true, keys, tries, hadKey: keys.includes('SUPERHUMAN_DOCS_API_KEY') };
|
||||
}
|
||||
return { ok: false, keys, tries, envLen: envText.length };
|
||||
}""",
|
||||
{"appId": APP_ID, "shKey": sh_key},
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"ok": result.get("ok"),
|
||||
"hadKey": result.get("hadKey"),
|
||||
"keyCount": len(result.get("keys") or []),
|
||||
"tries": result.get("tries"),
|
||||
"step": result.get("step"),
|
||||
"status": result.get("status"),
|
||||
},
|
||||
indent=2,
|
||||
)[:2000],
|
||||
flush=True,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
browser.close()
|
||||
return 6
|
||||
|
||||
# Deploy
|
||||
page.goto(APP, wait_until="domcontentloaded", timeout=90000)
|
||||
page.wait_for_timeout(700)
|
||||
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(600)
|
||||
conf = page.get_by_role("button", name=re.compile(r"^Confirm$", re.I))
|
||||
if conf.count():
|
||||
conf.first.click()
|
||||
print("DEPLOY_CLICKED", flush=True)
|
||||
|
||||
for i in range(24):
|
||||
time.sleep(10)
|
||||
try:
|
||||
import urllib.request
|
||||
|
||||
code = urllib.request.urlopen(HEALTH, timeout=12).getcode()
|
||||
except Exception as e:
|
||||
code = str(e)
|
||||
print(f"health_try{i}", code, flush=True)
|
||||
if code == 200:
|
||||
break
|
||||
|
||||
browser.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
137
scripts/dokploy_verify_agent_rls.py
Normal file
137
scripts/dokploy_verify_agent_rls.py
Normal file
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify agent_runs RLS; apply sql/agent_runs_rls.sql only if still disabled."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import re
|
||||
import sys
|
||||
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"
|
||||
OUT = ROOT / ".ops" / "rls_inventory.txt"
|
||||
BASE = "https://dokploy.4ventures.es"
|
||||
SQL_FILE = ROOT / "sql/agent_runs_rls.sql"
|
||||
|
||||
INVENTORY = (
|
||||
"SELECT c.relname AS table_name, c.relrowsecurity AS rls_enabled "
|
||||
"FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
|
||||
"WHERE n.nspname = 'public' AND c.relkind = 'r' ORDER BY 1;"
|
||||
)
|
||||
|
||||
|
||||
def env(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 term_run(page, cmd: str, wait_ms: int = 4000) -> None:
|
||||
helper = page.locator("textarea.xterm-helper-textarea")
|
||||
if helper.count():
|
||||
helper.first.click()
|
||||
page.keyboard.type(cmd, delay=2)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_timeout(wait_ms)
|
||||
|
||||
|
||||
def term_text(page) -> str:
|
||||
return page.evaluate(
|
||||
"""() => {
|
||||
const rows = document.querySelector('.xterm-rows');
|
||||
return rows ? rows.innerText : '';
|
||||
}"""
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||
password = env("DOKPLOY_PASSWORD")
|
||||
if not password:
|
||||
print("NO_DOKPLOY_PASSWORD", flush=True)
|
||||
return 2
|
||||
SHOT.mkdir(parents=True, exist_ok=True)
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1600, "height": 1200})
|
||||
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
|
||||
page.wait_for_timeout(700)
|
||||
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)
|
||||
|
||||
page.goto(f"{BASE}/dashboard/docker", wait_until="networkidle", timeout=120000)
|
||||
page.wait_for_timeout(1000)
|
||||
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
|
||||
"supabase-db"
|
||||
)
|
||||
page.wait_for_timeout(1200)
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(300)
|
||||
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
|
||||
page.wait_for_timeout(600)
|
||||
page.get_by_text(re.compile(r"^Terminal$", re.I)).first.click()
|
||||
page.wait_for_timeout(3500)
|
||||
|
||||
term_run(page, f'psql -U postgres -d postgres -c "{INVENTORY}" ; echo RLS_INV', 7000)
|
||||
text = term_text(page)
|
||||
OUT.write_text(text[-4000:], encoding="utf-8")
|
||||
print("inventory_written", flush=True)
|
||||
|
||||
agent_on = bool(re.search(r"agent_runs\s+\|\s+t\b", text))
|
||||
agent_off = bool(re.search(r"agent_runs\s+\|\s+f\b", text))
|
||||
print("agent_runs_rls", "on" if agent_on else ("off" if agent_off else "unknown"), flush=True)
|
||||
|
||||
if agent_on:
|
||||
print("OK_ALREADY", flush=True)
|
||||
browser.close()
|
||||
return 0
|
||||
if not agent_off:
|
||||
print("PARSE_FAIL — see .ops/rls_inventory.txt", flush=True)
|
||||
page.screenshot(path=str(SHOT / "rls_parse_fail.png"), full_page=True)
|
||||
browser.close()
|
||||
return 4
|
||||
|
||||
# Apply SQL via base64 chunks
|
||||
b64 = base64.b64encode(SQL_FILE.read_text(encoding="utf-8").encode()).decode()
|
||||
chunks = [b64[i : i + 200] for i in range(0, len(b64), 200)]
|
||||
term_run(page, "rm -f /tmp/agent_rls.b64 /tmp/agent_rls.sql; echo READY", 2000)
|
||||
for i, ch in enumerate(chunks):
|
||||
op = ">" if i == 0 else ">>"
|
||||
term_run(page, f"printf '%s' '{ch}' {op} /tmp/agent_rls.b64", 800)
|
||||
term_run(
|
||||
page,
|
||||
"base64 -d /tmp/agent_rls.b64 > /tmp/agent_rls.sql && "
|
||||
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/agent_rls.sql; "
|
||||
"echo EXIT:$?",
|
||||
8000,
|
||||
)
|
||||
term_run(page, f'psql -U postgres -d postgres -c "{INVENTORY}" ; echo RLS_INV2', 7000)
|
||||
text2 = term_text(page)
|
||||
OUT.write_text(text2[-4000:], encoding="utf-8")
|
||||
page.screenshot(path=str(SHOT / "rls_after.png"), full_page=True)
|
||||
ok = bool(re.search(r"agent_runs\s+\|\s+t\b", text2))
|
||||
print("agent_runs_after", "on" if ok else "still_off", flush=True)
|
||||
browser.close()
|
||||
return 0 if ok else 6
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
63
scripts/export_bps_calendar_doc.ts
Normal file
63
scripts/export_bps_calendar_doc.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
import { writeFileSync, mkdirSync } from 'fs';
|
||||
config({ path: resolve(process.cwd(), '.env.local') });
|
||||
import { getValidAccessToken } from '../src/lib/google-drive';
|
||||
|
||||
const IDS = [
|
||||
'1YPcHBjioiHHQy22Q2xevkvUqA5jMq87v6IviV7VTL2s',
|
||||
'1-Zss1suBLDxAgqUuP9DrJYWHiicyin8aQqMOTIIekAg',
|
||||
];
|
||||
|
||||
async function main() {
|
||||
const token = await getValidAccessToken();
|
||||
mkdirSync('/tmp/bps-cal', { recursive: true });
|
||||
for (const id of IDS) {
|
||||
const metaR = await fetch(
|
||||
`https://www.googleapis.com/drive/v3/files/${id}?fields=id,name,mimeType&supportsAllDrives=true`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
const meta = (await metaR.json()) as {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
};
|
||||
console.log('META', meta);
|
||||
const mime = meta.mimeType || '';
|
||||
let buf: Buffer;
|
||||
if (mime.includes('spreadsheet')) {
|
||||
const r = await fetch(
|
||||
`https://www.googleapis.com/drive/v3/files/${id}/export?mimeType=${encodeURIComponent('text/csv')}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
buf = Buffer.from(await r.arrayBuffer());
|
||||
writeFileSync(`/tmp/bps-cal/${id}.csv`, buf);
|
||||
} else if (mime.includes('document')) {
|
||||
const r = await fetch(
|
||||
`https://www.googleapis.com/drive/v3/files/${id}/export?mimeType=${encodeURIComponent('text/plain')}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
buf = Buffer.from(await r.arrayBuffer());
|
||||
writeFileSync(`/tmp/bps-cal/${id}.txt`, buf);
|
||||
} else {
|
||||
const r = await fetch(
|
||||
`https://www.googleapis.com/drive/v3/files/${id}?alt=media&supportsAllDrives=true`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
buf = Buffer.from(await r.arrayBuffer());
|
||||
writeFileSync(`/tmp/bps-cal/${id}.bin`, buf);
|
||||
}
|
||||
const text = buf.toString('utf8');
|
||||
console.log('size', buf.length);
|
||||
console.log(text.slice(0, 3000));
|
||||
console.log('----\n');
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
33
scripts/export_bps_xlsx.ts
Normal file
33
scripts/export_bps_xlsx.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
import { writeFileSync, mkdirSync } from 'fs';
|
||||
config({ path: resolve(process.cwd(), '.env.local') });
|
||||
import { getValidAccessToken } from '../src/lib/google-drive';
|
||||
|
||||
const ID = '1YPcHBjioiHHQy22Q2xevkvUqA5jMq87v6IviV7VTL2s';
|
||||
const OCT = '1-Zss1suBLDxAgqUuP9DrJYWHiicyin8aQqMOTIIekAg';
|
||||
|
||||
async function exportXlsx(token: string, id: string, path: string) {
|
||||
const mime =
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
const r = await fetch(
|
||||
`https://www.googleapis.com/drive/v3/files/${id}/export?mimeType=${encodeURIComponent(mime)}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
if (!r.ok) throw new Error(`${id} ${r.status} ${(await r.text()).slice(0, 300)}`);
|
||||
const buf = Buffer.from(await r.arrayBuffer());
|
||||
writeFileSync(path, buf);
|
||||
console.log('wrote', path, buf.length);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const token = await getValidAccessToken();
|
||||
mkdirSync('/tmp/bps-cal', { recursive: true });
|
||||
await exportXlsx(token, ID, '/tmp/bps-cal/bps-calendario.xlsx');
|
||||
await exportXlsx(token, OCT, '/tmp/bps-cal/bps-octubre.xlsx');
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
450
scripts/gap_report_connections.py
Normal file
450
scripts/gap_report_connections.py
Normal file
@@ -0,0 +1,450 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exact gap report: visible assets vs tenant matches + name candidates."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
META = "https://graph.facebook.com/v21.0"
|
||||
|
||||
|
||||
def load_env() -> dict[str, 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("'")
|
||||
return vals
|
||||
|
||||
|
||||
ENV = load_env()
|
||||
BASE = ENV["NEXT_PUBLIC_SUPABASE_URL"].rstrip("/")
|
||||
KEY = ENV["SUPABASE_SERVICE_ROLE_KEY"]
|
||||
SH = {"apikey": KEY, "Authorization": f"Bearer {KEY}"}
|
||||
|
||||
|
||||
def sb(path: str):
|
||||
req = urllib.request.Request(BASE + path, headers=SH)
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
return json.loads(r.read().decode())
|
||||
|
||||
|
||||
def norm(s: str) -> str:
|
||||
s = s.lower()
|
||||
for a, b in (
|
||||
("á", "a"),
|
||||
("é", "e"),
|
||||
("í", "i"),
|
||||
("ó", "o"),
|
||||
("ú", "u"),
|
||||
("ñ", "n"),
|
||||
):
|
||||
s = s.replace(a, b)
|
||||
return re.sub(r"[^a-z0-9]+", " ", s).strip()
|
||||
|
||||
|
||||
def score_name(tenant_name: str, tenant_slug: str, account_name: str) -> int:
|
||||
t = norm(tenant_name)
|
||||
s = norm(tenant_slug.replace("-", " "))
|
||||
a = norm(account_name)
|
||||
if not a:
|
||||
return 0
|
||||
if a == t or a == s:
|
||||
return 100
|
||||
if t and (t in a or a in t):
|
||||
return 85
|
||||
if s and (s in a or a in s):
|
||||
return 80
|
||||
tt = {x for x in t.split() if len(x) > 2}
|
||||
aa = {x for x in a.split() if len(x) > 2}
|
||||
if not tt or not aa:
|
||||
return 0
|
||||
hit = len(tt & aa) / len(tt)
|
||||
if hit >= 0.8:
|
||||
return 70
|
||||
if hit >= 0.5:
|
||||
return 55
|
||||
return int(hit * 50)
|
||||
|
||||
|
||||
def top_candidates(tenants, account_name, n=3):
|
||||
scored = []
|
||||
for t in tenants:
|
||||
sc = score_name(t["name"], t["slug"], account_name)
|
||||
if sc > 0:
|
||||
scored.append((sc, t["name"]))
|
||||
scored.sort(key=lambda x: -x[0])
|
||||
return scored[:n]
|
||||
|
||||
|
||||
def meta_paginate(token: str, path: str):
|
||||
out = []
|
||||
url = f"{META}/{path}"
|
||||
sep = "&" if "?" in path else "?"
|
||||
url = url + sep + "access_token=" + urllib.parse.quote(token)
|
||||
while url:
|
||||
req = urllib.request.Request(url)
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
j = json.loads(r.read().decode())
|
||||
out.extend(j.get("data") or [])
|
||||
url = (j.get("paging") or {}).get("next")
|
||||
return out
|
||||
|
||||
|
||||
def google_refresh(refresh: str) -> str:
|
||||
body = urllib.parse.urlencode(
|
||||
{
|
||||
"refresh_token": refresh,
|
||||
"client_id": ENV["GOOGLE_OAUTH_CLIENT_ID"],
|
||||
"client_secret": ENV["GOOGLE_OAUTH_CLIENT_SECRET"],
|
||||
"grant_type": "refresh_token",
|
||||
}
|
||||
).encode()
|
||||
req = urllib.request.Request(
|
||||
"https://oauth2.googleapis.com/token",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
return json.loads(r.read().decode())["access_token"]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
tenants = sb("/rest/v1/tenants?select=id,name,slug&order=name.asc")
|
||||
integs = sb(
|
||||
"/rest/v1/integrations?provider=in.(ga4,search_console,google_drive,google_ads,meta_ads,facebook,instagram)"
|
||||
"&select=tenant_id,provider,status,external_account_id,meta,error_message"
|
||||
)
|
||||
by = defaultdict(dict)
|
||||
for i in integs:
|
||||
by[i["tenant_id"]][i["provider"]] = i
|
||||
|
||||
grow = sb(
|
||||
"/rest/v1/agency_oauth?provider=eq.google_drive&select=access_token,refresh_token,account_email"
|
||||
)[0]
|
||||
mrow = sb(
|
||||
"/rest/v1/agency_oauth?provider=eq.meta_ads&select=access_token,account_email,scopes"
|
||||
)[0]
|
||||
gtoken = google_refresh(grow["refresh_token"])
|
||||
mtoken = mrow["access_token"]
|
||||
|
||||
print("=== TOKENS ===")
|
||||
print("Google:", grow.get("account_email"))
|
||||
print("Meta:", mrow.get("account_email"))
|
||||
|
||||
# Meta inventory
|
||||
ads = []
|
||||
for row in meta_paginate(
|
||||
mtoken, "me/adaccounts?fields=id,account_id,name&limit=200"
|
||||
):
|
||||
aid = row.get("account_id") or row["id"].replace("act_", "")
|
||||
ads.append({"id": f"act_{aid}", "name": row.get("name") or aid})
|
||||
pages = []
|
||||
igs = []
|
||||
for page in meta_paginate(
|
||||
mtoken,
|
||||
"me/accounts?fields=id,name,instagram_business_account{id,username,name}&limit=100",
|
||||
):
|
||||
pages.append({"id": page["id"], "name": page.get("name") or page["id"]})
|
||||
ig = page.get("instagram_business_account") or {}
|
||||
if ig.get("id"):
|
||||
igs.append(
|
||||
{
|
||||
"id": ig["id"],
|
||||
"name": f"@{ig['username']}"
|
||||
if ig.get("username")
|
||||
else (ig.get("name") or page.get("name") or ig["id"]),
|
||||
"page": page.get("name"),
|
||||
}
|
||||
)
|
||||
|
||||
print(f"\n=== META VISIBLE === ads={len(ads)} pages={len(pages)} ig={len(igs)}")
|
||||
print("Ad accounts:")
|
||||
for a in ads:
|
||||
cands = top_candidates(tenants, a["name"])
|
||||
linked = next(
|
||||
(
|
||||
t["name"]
|
||||
for t in tenants
|
||||
if (by[t["id"]].get("meta_ads") or {}).get("external_account_id")
|
||||
== a["id"]
|
||||
),
|
||||
None,
|
||||
)
|
||||
print(
|
||||
f" {a['name']!r} ({a['id']}) → linked={linked or 'NO'} best={cands}"
|
||||
)
|
||||
print("Pages:")
|
||||
for a in pages:
|
||||
cands = top_candidates(tenants, a["name"])
|
||||
linked = next(
|
||||
(
|
||||
t["name"]
|
||||
for t in tenants
|
||||
if (by[t["id"]].get("facebook") or {}).get("external_account_id")
|
||||
== a["id"]
|
||||
),
|
||||
None,
|
||||
)
|
||||
print(
|
||||
f" {a['name']!r} ({a['id']}) → linked={linked or 'NO'} best={cands}"
|
||||
)
|
||||
print("Instagram:")
|
||||
for a in igs:
|
||||
cands = top_candidates(tenants, a["name"]) + top_candidates(
|
||||
tenants, a.get("page") or ""
|
||||
)
|
||||
# dedupe keep best
|
||||
seen = {}
|
||||
for sc, name in cands:
|
||||
seen[name] = max(sc, seen.get(name, 0))
|
||||
cands = sorted(((v, k) for k, v in seen.items()), reverse=True)[:3]
|
||||
linked = next(
|
||||
(
|
||||
t["name"]
|
||||
for t in tenants
|
||||
if (by[t["id"]].get("instagram") or {}).get("external_account_id")
|
||||
== a["id"]
|
||||
),
|
||||
None,
|
||||
)
|
||||
print(
|
||||
f" {a['name']!r} page={a.get('page')!r} ({a['id']}) → linked={linked or 'NO'} best={cands}"
|
||||
)
|
||||
|
||||
# GA4 / GSC inventory
|
||||
req = urllib.request.Request(
|
||||
"https://analyticsadmin.googleapis.com/v1beta/accountSummaries",
|
||||
headers={"Authorization": f"Bearer {gtoken}"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
summaries = json.loads(r.read().decode())
|
||||
ga4 = []
|
||||
for acc in summaries.get("accountSummaries") or []:
|
||||
for p in acc.get("propertySummaries") or []:
|
||||
if p.get("property"):
|
||||
ga4.append(
|
||||
{
|
||||
"id": p["property"],
|
||||
"name": p.get("displayName") or p["property"],
|
||||
"account": acc.get("displayName"),
|
||||
}
|
||||
)
|
||||
|
||||
req = urllib.request.Request(
|
||||
"https://www.googleapis.com/webmasters/v3/sites",
|
||||
headers={"Authorization": f"Bearer {gtoken}"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
sites_j = json.loads(r.read().decode())
|
||||
gsc = [
|
||||
{"id": s["siteUrl"], "name": s["siteUrl"]}
|
||||
for s in (sites_j.get("siteEntry") or [])
|
||||
if s.get("siteUrl")
|
||||
]
|
||||
|
||||
print(f"\n=== GOOGLE VISIBLE === ga4={len(ga4)} gsc={len(gsc)}")
|
||||
|
||||
# Test Data API on first property
|
||||
if ga4:
|
||||
prop = ga4[0]["id"].replace("properties/", "")
|
||||
end = (datetime.now(timezone.utc) - timedelta(days=1)).date()
|
||||
start = end - timedelta(days=2)
|
||||
body = json.dumps(
|
||||
{
|
||||
"dateRanges": [{"startDate": str(start), "endDate": str(end)}],
|
||||
"dimensions": [{"name": "date"}],
|
||||
"metrics": [{"name": "sessions"}],
|
||||
}
|
||||
).encode()
|
||||
req = urllib.request.Request(
|
||||
f"https://analyticsdata.googleapis.com/v1beta/properties/{prop}:runReport",
|
||||
data=body,
|
||||
headers={
|
||||
"Authorization": f"Bearer {gtoken}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
print("Data API test: OK on", ga4[0]["name"], prop)
|
||||
except urllib.error.HTTPError as e:
|
||||
print("Data API test: FAIL", e.read().decode()[:250])
|
||||
|
||||
# Unmatched GA4 with best candidates (only those not linked)
|
||||
linked_ga4_ids = {
|
||||
(by[t["id"]].get("ga4") or {}).get("external_account_id")
|
||||
for t in tenants
|
||||
}
|
||||
linked_ga4_props = set()
|
||||
for t in tenants:
|
||||
meta = (by[t["id"]].get("ga4") or {}).get("meta") or {}
|
||||
if meta.get("property_id"):
|
||||
linked_ga4_props.add(meta["property_id"])
|
||||
|
||||
print("\n=== GA4 sin ficha clara (score<50 o no enlazado) — top candidatos ===")
|
||||
for a in ga4:
|
||||
linked = next(
|
||||
(
|
||||
t["name"]
|
||||
for t in tenants
|
||||
if (by[t["id"]].get("ga4") or {}).get("meta", {}).get("property_id")
|
||||
== a["id"]
|
||||
or (by[t["id"]].get("ga4") or {}).get("external_account_id")
|
||||
in (a["id"], a["name"])
|
||||
),
|
||||
None,
|
||||
)
|
||||
cands = top_candidates(tenants, a["name"])
|
||||
best = cands[0][0] if cands else 0
|
||||
if linked and best >= 50:
|
||||
continue
|
||||
if linked:
|
||||
continue
|
||||
print(f" GA4 {a['name']!r} ({a['id']}) acct={a.get('account')!r} best={cands}")
|
||||
|
||||
print("\n=== GSC sin ficha clara ===")
|
||||
for a in gsc:
|
||||
linked = next(
|
||||
(
|
||||
t["name"]
|
||||
for t in tenants
|
||||
if (by[t["id"]].get("search_console") or {}).get("external_account_id")
|
||||
== a["id"]
|
||||
),
|
||||
None,
|
||||
)
|
||||
if linked:
|
||||
continue
|
||||
cands = top_candidates(tenants, a["name"])
|
||||
# also try domain without sc-domain
|
||||
bare = a["name"].replace("sc-domain:", "").replace("https://", "").replace("http://", "").strip("/")
|
||||
cands2 = top_candidates(tenants, bare)
|
||||
merged = {}
|
||||
for sc, name in cands + cands2:
|
||||
merged[name] = max(sc, merged.get(name, 0))
|
||||
cands = sorted(((v, k) for k, v in merged.items()), reverse=True)[:3]
|
||||
print(f" GSC {a['name']!r} best={cands}")
|
||||
|
||||
# Per-tenant gaps with why
|
||||
print("\n=== POR CLIENTE: qué falta y por qué ===")
|
||||
providers = [
|
||||
"google_ads",
|
||||
"meta_ads",
|
||||
"ga4",
|
||||
"search_console",
|
||||
"google_drive",
|
||||
"facebook",
|
||||
"instagram",
|
||||
]
|
||||
|
||||
def ok(i):
|
||||
return bool(i and i.get("status") == "connected" and i.get("external_account_id"))
|
||||
|
||||
for t in tenants:
|
||||
m = by[t["id"]]
|
||||
gaps = []
|
||||
for p in providers:
|
||||
if ok(m.get(p)):
|
||||
continue
|
||||
if p == "meta_ads":
|
||||
cands = [(score_name(t["name"], t["slug"], a["name"]), a["name"], a["id"]) for a in ads]
|
||||
cands = [c for c in sorted(cands, reverse=True) if c[0] > 0][:2]
|
||||
if not ads:
|
||||
gaps.append("meta_ads: token no ve ad accounts")
|
||||
elif not cands:
|
||||
gaps.append("meta_ads: no hay ad account cuyo nombre se parezca (¿no está en las 9 visibles?)")
|
||||
else:
|
||||
gaps.append(f"meta_ads: no auto-match; candidatos {cands}")
|
||||
elif p == "facebook":
|
||||
cands = [(score_name(t["name"], t["slug"], a["name"]), a["name"], a["id"]) for a in pages]
|
||||
cands = [c for c in sorted(cands, reverse=True) if c[0] > 0][:2]
|
||||
if not cands:
|
||||
gaps.append("facebook: ninguna Page visible se parece al nombre")
|
||||
else:
|
||||
gaps.append(f"facebook: no auto-match; candidatos {cands}")
|
||||
elif p == "instagram":
|
||||
cands = []
|
||||
for a in igs:
|
||||
sc = max(
|
||||
score_name(t["name"], t["slug"], a["name"]),
|
||||
score_name(t["name"], t["slug"], a.get("page") or ""),
|
||||
)
|
||||
if sc > 0:
|
||||
cands.append((sc, a["name"], a.get("page"), a["id"]))
|
||||
cands = sorted(cands, reverse=True)[:2]
|
||||
if not cands:
|
||||
gaps.append("instagram: ningún @ / page visible se parece")
|
||||
else:
|
||||
gaps.append(f"instagram: no auto-match; candidatos {cands}")
|
||||
elif p == "ga4":
|
||||
cands = [(score_name(t["name"], t["slug"], a["name"]), a["name"], a["id"]) for a in ga4]
|
||||
cands = [c for c in sorted(cands, reverse=True) if c[0] > 0][:2]
|
||||
if not cands:
|
||||
gaps.append("ga4: ninguna propiedad visible se parece al nombre")
|
||||
else:
|
||||
gaps.append(f"ga4: no auto-match (umbral 50); candidatos {cands}")
|
||||
elif p == "search_console":
|
||||
cands = []
|
||||
for a in gsc:
|
||||
bare = a["name"].replace("sc-domain:", "").replace("https://", "").replace("http://", "").strip("/")
|
||||
sc = max(
|
||||
score_name(t["name"], t["slug"], a["name"]),
|
||||
score_name(t["name"], t["slug"], bare),
|
||||
)
|
||||
if sc > 0:
|
||||
cands.append((sc, a["name"]))
|
||||
cands = sorted(cands, reverse=True)[:2]
|
||||
if not cands:
|
||||
gaps.append("gsc: ningún site visible se parece")
|
||||
else:
|
||||
gaps.append(f"gsc: no auto-match (umbral 40); candidatos {cands}")
|
||||
elif p == "google_drive":
|
||||
gaps.append("drive: sin carpeta auto-matched (revisar nombre carpeta Drive)")
|
||||
elif p == "google_ads":
|
||||
gaps.append("google_ads: sin cuenta enlazada (MCC / nombre)")
|
||||
if gaps:
|
||||
print(f"\n{t['name']}:")
|
||||
for g in gaps:
|
||||
print(f" - {g}")
|
||||
else:
|
||||
print(f"\n{t['name']}: COMPLETO (todos los canales con ID)")
|
||||
|
||||
# BPS GSC permission probe
|
||||
bps = next(t for t in tenants if t["name"] == "BPS")
|
||||
gsc_id = (by[bps["id"]].get("search_console") or {}).get("external_account_id")
|
||||
if gsc_id:
|
||||
end = (datetime.now(timezone.utc) - timedelta(days=1)).date()
|
||||
start = end - timedelta(days=3)
|
||||
body = json.dumps(
|
||||
{"startDate": str(start), "endDate": str(end), "dimensions": ["date"]}
|
||||
).encode()
|
||||
req = urllib.request.Request(
|
||||
f"https://www.googleapis.com/webmasters/v3/sites/{urllib.parse.quote(gsc_id, safe='')}/searchAnalytics/query",
|
||||
data=body,
|
||||
headers={
|
||||
"Authorization": f"Bearer {gtoken}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
print("\nBPS GSC probe: OK")
|
||||
except urllib.error.HTTPError as e:
|
||||
print("\nBPS GSC probe: FAIL", e.read().decode()[:220])
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
457
scripts/import_bps_calendars.ts
Normal file
457
scripts/import_bps_calendars.ts
Normal file
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* Import BPS editorial calendars from Drive xlsx into documents (type=calendar).
|
||||
* Includes hidden month tabs (JULIO…JUNIO, ENERO 2026, *26).
|
||||
*
|
||||
* Prerequisite: /tmp/bps-cal/bps-calendario.xlsx (+ optional bps-octubre.xlsx)
|
||||
* npx tsx scripts/export_bps_xlsx.ts
|
||||
* npx tsx scripts/import_bps_calendars.ts
|
||||
*/
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
import { writeFileSync } from 'fs';
|
||||
import { execFileSync } from 'child_process';
|
||||
config({ path: resolve(process.cwd(), '.env.local') });
|
||||
|
||||
import { serviceHeaders, supabaseUrl } from '../src/lib/agency';
|
||||
import type { RrssMonthPlan, RrssPiece } from '../src/components/RrssMonthView';
|
||||
|
||||
const BPS_ID = '8bec977a-6032-4759-aa7f-b5042081f450';
|
||||
|
||||
/**
|
||||
* Explicit year/month for every editorial month sheet (incl. hidden).
|
||||
* Workbook order: JUL–DIC 2025 → ENE–JUN 2026 → JUL–OCT 26.
|
||||
*/
|
||||
const SHEET_YM: Record<string, string> = {
|
||||
JULIO: '2025-07',
|
||||
AGOSTO: '2025-08',
|
||||
SEPTIEMBRE: '2025-09',
|
||||
OCTUBRE: '2025-10',
|
||||
NOVIEMBRE: '2025-11',
|
||||
DICIEMBRE: '2025-12',
|
||||
'ENERO 2026': '2026-01',
|
||||
FEBRERO: '2026-02',
|
||||
MARZO: '2026-03',
|
||||
ABRIL: '2026-04',
|
||||
MAYO: '2026-05',
|
||||
JUNIO: '2026-06',
|
||||
'JULIO 26': '2026-07',
|
||||
'AGOSTO 26': '2026-08',
|
||||
'SEPTIMEBRE 26': '2026-09',
|
||||
'OCTUBRE 26': '2026-10',
|
||||
};
|
||||
|
||||
const MONTH_SHEET_NAMES = new Set(Object.keys(SHEET_YM));
|
||||
|
||||
function slugify(s: string) {
|
||||
return s
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 48);
|
||||
}
|
||||
|
||||
function parseDayHeader(h: string): number | null {
|
||||
const raw = String(h || '').trim();
|
||||
if (!raw || /^x\b/i.test(raw) || /^columna/i.test(raw)) return null;
|
||||
const m = raw.match(/(\d{1,2})\s*$/);
|
||||
if (!m) return null;
|
||||
const n = Number(m[1]);
|
||||
return n >= 1 && n <= 31 ? n : null;
|
||||
}
|
||||
|
||||
type SheetRows = (string | number | null)[][];
|
||||
|
||||
/** Parse SEMANA/STORIES/POST grid into pieces. */
|
||||
function parseWeekGrid(
|
||||
sheetName: string,
|
||||
rows: SheetRows,
|
||||
year: number,
|
||||
month: number
|
||||
): RrssPiece[] {
|
||||
const pieces: RrssPiece[] = [];
|
||||
let dayCols: { col: number; day: number }[] = [];
|
||||
let kind: 'STORIES' | 'POST' | null = null;
|
||||
/** Week wraps when header has e.g. LUNES 30 … MARTES 1 (spillover from prev month). */
|
||||
let weekWraps = false;
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row || !row.some((c) => c != null && String(c).trim())) continue;
|
||||
const c0 = String(row[0] ?? '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
|
||||
if (
|
||||
c0.startsWith('SEMANA') ||
|
||||
/^(LUNES|MARTES|MI[EÉ]RCOLES)/i.test(String(row[1] ?? ''))
|
||||
) {
|
||||
dayCols = [];
|
||||
for (let col = 1; col < Math.min(row.length, 10); col++) {
|
||||
const day = parseDayHeader(String(row[col] ?? ''));
|
||||
if (day) dayCols.push({ col, day });
|
||||
}
|
||||
weekWraps = dayCols.some(
|
||||
(d, i) =>
|
||||
i > 0 &&
|
||||
d.day < dayCols[i - 1].day &&
|
||||
dayCols[i - 1].day >= 28 &&
|
||||
d.day <= 7
|
||||
);
|
||||
kind = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c0.startsWith('STORIES') || c0 === 'STORY') {
|
||||
kind = 'STORIES';
|
||||
} else if (c0.startsWith('POST')) {
|
||||
kind = 'POST';
|
||||
} else if (!kind) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const { col, day } of dayCols) {
|
||||
const raw = row[col];
|
||||
if (raw == null) continue;
|
||||
const text = String(raw).trim();
|
||||
if (!text || text === 'X' || /^columna/i.test(text)) continue;
|
||||
|
||||
let y = year;
|
||||
let m = month;
|
||||
if (weekWraps && day >= 28) {
|
||||
m = month - 1;
|
||||
if (m < 1) {
|
||||
m = 12;
|
||||
y = year - 1;
|
||||
}
|
||||
}
|
||||
|
||||
const mm = String(m).padStart(2, '0');
|
||||
const dd = String(day).padStart(2, '0');
|
||||
const date = `${y}-${mm}-${dd}`;
|
||||
const dt = new Date(`${date}T12:00:00Z`);
|
||||
if (Number.isNaN(dt.getTime()) || dt.getUTCMonth() + 1 !== m) continue;
|
||||
|
||||
const id = `bps-${date}-${kind === 'STORIES' ? 'st' : 'post'}-${slugify(text).slice(0, 24)}`;
|
||||
pieces.push({
|
||||
id,
|
||||
date,
|
||||
platform: 'Instagram',
|
||||
format: kind === 'STORIES' ? 'Stories' : 'Post',
|
||||
pillar: kind === 'STORIES' ? 'stories' : 'feed',
|
||||
title: text.split('\n')[0].slice(0, 120),
|
||||
body: text,
|
||||
status: 'planned',
|
||||
kind: kind === 'STORIES' ? 'story' : 'post',
|
||||
color: kind === 'STORIES' ? '#D9821F' : '#2071cf',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
return pieces.filter((p) => {
|
||||
if (seen.has(p.id)) return false;
|
||||
seen.add(p.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSheetsFromPython(): Promise<
|
||||
{ name: string; rows: SheetRows; state: string }[]
|
||||
> {
|
||||
const py = `
|
||||
import openpyxl, json
|
||||
wb=openpyxl.load_workbook('/tmp/bps-cal/bps-calendario.xlsx', data_only=True)
|
||||
out=[]
|
||||
month_names={${[...MONTH_SHEET_NAMES].map((n) => JSON.stringify(n)).join(',')}}
|
||||
for name in wb.sheetnames:
|
||||
ws=wb[name]
|
||||
keep = name in month_names or name == 'Calendario'
|
||||
if not keep:
|
||||
continue
|
||||
rows=[]
|
||||
# Read enough rows; openpyxl max_row is often inflated (1000) — stop after 80 blank streak
|
||||
blank=0
|
||||
for i,row in enumerate(ws.iter_rows(min_row=1, max_row=min(ws.max_row, 200), values_only=True),1):
|
||||
vals=[None if c is None else (c.isoformat() if hasattr(c,'isoformat') else c) for c in row[:14]]
|
||||
if not any(v is not None and str(v).strip() for v in vals):
|
||||
blank+=1
|
||||
if blank>15 and i>30: break
|
||||
continue
|
||||
blank=0
|
||||
rows.append(vals)
|
||||
out.append({'name': name, 'rows': rows, 'state': ws.sheet_state})
|
||||
try:
|
||||
wb2=openpyxl.load_workbook('/tmp/bps-cal/bps-octubre.xlsx', data_only=True)
|
||||
for name in wb2.sheetnames:
|
||||
ws=wb2[name]
|
||||
rows=[]
|
||||
blank=0
|
||||
for i,row in enumerate(ws.iter_rows(min_row=1, max_row=min(ws.max_row, 80), values_only=True),1):
|
||||
vals=[None if c is None else (c.isoformat() if hasattr(c,'isoformat') else c) for c in row[:14]]
|
||||
if not any(v is not None and str(v).strip() for v in vals):
|
||||
blank+=1
|
||||
if blank>10: break
|
||||
continue
|
||||
blank=0
|
||||
rows.append(vals)
|
||||
out.append({'name': name+' (prov)', 'rows': rows, 'state': 'visible'})
|
||||
except Exception:
|
||||
pass
|
||||
print(json.dumps(out, ensure_ascii=False, default=str))
|
||||
`;
|
||||
const raw = execFileSync('python3', ['-c', py], { maxBuffer: 20_000_000 });
|
||||
return JSON.parse(raw.toString('utf8'));
|
||||
}
|
||||
|
||||
function parseStructuredCalendario(rows: SheetRows): Map<string, RrssPiece[]> {
|
||||
const byMonth = new Map<string, RrssPiece[]>();
|
||||
for (const r of rows.slice(1)) {
|
||||
if (!r) continue;
|
||||
const dia = r[2];
|
||||
const formato = String(r[3] ?? '');
|
||||
const contenido = String(r[4] ?? '').trim();
|
||||
const estado = String(r[6] ?? '');
|
||||
if (!contenido && !formato) continue;
|
||||
let dateStr = '';
|
||||
if (typeof dia === 'string' && /^\d{4}-\d{2}-\d{2}/.test(dia)) {
|
||||
dateStr = dia.slice(0, 10);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
const monthKey = dateStr.slice(0, 7);
|
||||
const isStory = /storie/i.test(formato);
|
||||
const isBlog = /blog/i.test(formato);
|
||||
const piece: RrssPiece = {
|
||||
id: `bps-${dateStr}-${isStory ? 'st' : isBlog ? 'blog' : 'post'}-${slugify(contenido || formato)}`,
|
||||
date: dateStr,
|
||||
platform: isBlog ? 'Blog' : 'Instagram',
|
||||
format: isStory ? 'Stories' : isBlog ? 'Blog' : 'Post',
|
||||
pillar: isStory ? 'stories' : 'feed',
|
||||
title: (contenido || formato).split('\n')[0].slice(0, 120) || formato,
|
||||
body: contenido || undefined,
|
||||
status: /subido/i.test(estado)
|
||||
? 'published'
|
||||
: /pendiente/i.test(estado)
|
||||
? 'planned'
|
||||
: 'planned',
|
||||
kind: isStory ? 'story' : 'post',
|
||||
color: isStory ? '#D9821F' : '#2071cf',
|
||||
};
|
||||
const list = byMonth.get(monthKey) || [];
|
||||
list.push(piece);
|
||||
byMonth.set(monthKey, list);
|
||||
}
|
||||
return byMonth;
|
||||
}
|
||||
|
||||
function monthLabel(ym: string) {
|
||||
const [y, m] = ym.split('-').map(Number);
|
||||
const names = [
|
||||
'',
|
||||
'Enero',
|
||||
'Febrero',
|
||||
'Marzo',
|
||||
'Abril',
|
||||
'Mayo',
|
||||
'Junio',
|
||||
'Julio',
|
||||
'Agosto',
|
||||
'Septiembre',
|
||||
'Octubre',
|
||||
'Noviembre',
|
||||
'Diciembre',
|
||||
];
|
||||
return `${names[m]} ${y}`;
|
||||
}
|
||||
|
||||
function buildPlan(ym: string, pieces: RrssPiece[]): RrssMonthPlan {
|
||||
const posts = pieces.filter((p) => p.kind !== 'story').length;
|
||||
const stories = pieces.filter((p) => p.kind === 'story').length;
|
||||
return {
|
||||
kind: 'rrss_month',
|
||||
month: ym,
|
||||
label: monthLabel(ym),
|
||||
hero: {
|
||||
eyebrow: '4VENTURES × BLUE PARROT SCHOOL · PLAN RRSS',
|
||||
title: `Calendario editorial · ${monthLabel(ym)}`,
|
||||
lede: 'Importado desde Drive (CALENDARIO Blue Parrot School), incluidas pestañas ocultas.',
|
||||
chips: [
|
||||
`${posts} posts`,
|
||||
`${stories} stories`,
|
||||
'Fuente: Google Drive',
|
||||
],
|
||||
},
|
||||
pieces: pieces.sort((a, b) => a.date.localeCompare(b.date)),
|
||||
};
|
||||
}
|
||||
|
||||
async function upsertPlan(plan: RrssMonthPlan) {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) throw new Error('no service role');
|
||||
const base = supabaseUrl();
|
||||
const title = `RRSS ${plan.label}`;
|
||||
const existing = await fetch(
|
||||
`${base}/rest/v1/documents?tenant_id=eq.${BPS_ID}&type=eq.calendar&select=id,title,content`,
|
||||
{ headers }
|
||||
);
|
||||
const docs = (await existing.json()) as {
|
||||
id: string;
|
||||
title: string;
|
||||
content: unknown;
|
||||
}[];
|
||||
for (const d of docs) {
|
||||
let c = d.content as RrssMonthPlan | string | null;
|
||||
if (typeof c === 'string') {
|
||||
try {
|
||||
c = JSON.parse(c) as RrssMonthPlan;
|
||||
} catch {
|
||||
c = null;
|
||||
}
|
||||
}
|
||||
const month = c && typeof c === 'object' ? c.month : null;
|
||||
const sameTitle = d.title === title || d.title === `RRSS ${plan.label}`;
|
||||
if (month === plan.month || sameTitle) {
|
||||
await fetch(`${base}/rest/v1/documents?id=eq.${d.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { ...headers, Prefer: 'return=minimal' },
|
||||
});
|
||||
}
|
||||
}
|
||||
const row = {
|
||||
tenant_id: BPS_ID,
|
||||
type: 'calendar',
|
||||
title,
|
||||
content: plan,
|
||||
status: 'approved',
|
||||
};
|
||||
const ins = await fetch(`${base}/rest/v1/documents`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation',
|
||||
},
|
||||
body: JSON.stringify(row),
|
||||
});
|
||||
if (!ins.ok) throw new Error(`insert ${plan.month}: ${(await ins.text()).slice(0, 300)}`);
|
||||
const saved = (await ins.json()) as { id: string }[];
|
||||
console.log('OK', plan.month, plan.pieces.length, 'pieces', saved[0]?.id);
|
||||
}
|
||||
|
||||
function mergePieces(a: RrssPiece[], b: RrssPiece[]): RrssPiece[] {
|
||||
const ids = new Set(a.map((p) => p.id));
|
||||
return a.concat(b.filter((p) => !ids.has(p.id)));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const sheets = await loadSheetsFromPython();
|
||||
const byMonth = new Map<string, RrssPiece[]>();
|
||||
const gridMonths = new Set<string>();
|
||||
|
||||
// 1) All month tabs (hidden + visible) — primary source
|
||||
for (const sh of sheets) {
|
||||
const baseName = sh.name.replace(' (prov)', '');
|
||||
const ym = SHEET_YM[baseName];
|
||||
if (!ym) continue;
|
||||
const [year, month] = ym.split('-').map(Number);
|
||||
const pieces = parseWeekGrid(sh.name, sh.rows as SheetRows, year, month);
|
||||
const cur = byMonth.get(ym) || [];
|
||||
if (sh.name.includes('(prov)') && pieces.length >= cur.length) {
|
||||
byMonth.set(ym, pieces);
|
||||
} else {
|
||||
byMonth.set(ym, mergePieces(cur, pieces));
|
||||
}
|
||||
if (pieces.length) gridMonths.add(ym);
|
||||
console.log(
|
||||
`parsed ${sh.name} [${sh.state}] → ${ym} ${pieces.length} pieces`
|
||||
);
|
||||
}
|
||||
|
||||
// 2) Structured "Calendario" sheet: only fill months that have NO week-grid tab
|
||||
// (May–Aug 2025 detail) or merge blog rows into existing months
|
||||
for (const sh of sheets) {
|
||||
if (sh.name !== 'Calendario') continue;
|
||||
const structured = parseStructuredCalendario(sh.rows as SheetRows);
|
||||
for (const [ym, pieces] of structured) {
|
||||
if (gridMonths.has(ym)) {
|
||||
// keep grid as source of truth; still add blog-only pieces
|
||||
const blogs = pieces.filter((p) => p.platform === 'Blog');
|
||||
if (blogs.length) {
|
||||
byMonth.set(ym, mergePieces(byMonth.get(ym) || [], blogs));
|
||||
console.log(`calendario blogs → ${ym} +${blogs.length}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
byMonth.set(ym, mergePieces(byMonth.get(ym) || [], pieces));
|
||||
console.log(`calendario only → ${ym} ${pieces.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Drop wrongly-year'd leftovers from prior import (e.g. Feb–Apr as 2025)
|
||||
const keepMonths = new Set(byMonth.keys());
|
||||
|
||||
writeFileSync(
|
||||
'/tmp/bps-cal/plans-summary.json',
|
||||
JSON.stringify(
|
||||
[...byMonth.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([m, p]) => ({ month: m, n: p.length })),
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const headers = serviceHeaders();
|
||||
const base = supabaseUrl();
|
||||
if (headers && base) {
|
||||
const existing = await fetch(
|
||||
`${base}/rest/v1/documents?tenant_id=eq.${BPS_ID}&type=eq.calendar&select=id,title,content`,
|
||||
{ headers }
|
||||
);
|
||||
const docs = (await existing.json()) as {
|
||||
id: string;
|
||||
content: unknown;
|
||||
}[];
|
||||
for (const d of docs) {
|
||||
let c = d.content as { month?: string } | string | null;
|
||||
if (typeof c === 'string') {
|
||||
try {
|
||||
c = JSON.parse(c) as { month?: string };
|
||||
} catch {
|
||||
c = null;
|
||||
}
|
||||
}
|
||||
const m = c && typeof c === 'object' ? c.month : null;
|
||||
if (!m) continue;
|
||||
// Remove old 2025-02..04 if we now have 2026-02..04 from hidden sheets
|
||||
if (
|
||||
/^2025-0[2-4]$/.test(m) &&
|
||||
keepMonths.has(m.replace('2025', '2026'))
|
||||
) {
|
||||
await fetch(`${base}/rest/v1/documents?id=eq.${d.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { ...headers, Prefer: 'return=minimal' },
|
||||
});
|
||||
console.log('deleted stale', m, d.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [ym, pieces] of [...byMonth.entries()].sort()) {
|
||||
if (!pieces.length) continue;
|
||||
await upsertPlan(buildPlan(ym, pieces));
|
||||
}
|
||||
|
||||
console.log(
|
||||
'months',
|
||||
[...byMonth.keys()].sort().join(', '),
|
||||
'total pieces',
|
||||
[...byMonth.values()].reduce((s, p) => s + p.length, 0)
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
95
scripts/ingest_gads_json.py
Executable file
95
scripts/ingest_gads_json.py
Executable file
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ingest .ops/gads/{customer_id}.json MCP dumps into ad_daily."""
|
||||
from __future__ import annotations
|
||||
import json, os, sys, urllib.request, urllib.error
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
ROOT = Path('/Users/4ventures/4ventures-client-portal')
|
||||
# load .env.local
|
||||
for line in (ROOT/'.env.local').read_text().splitlines():
|
||||
if '=' in line and not line.startswith('#') and line.split('=',1)[0] not in os.environ:
|
||||
k,v=line.split('=',1); os.environ[k]=v.strip().strip('"').strip("'")
|
||||
|
||||
url=os.environ['NEXT_PUBLIC_SUPABASE_URL'].rstrip('/')
|
||||
key=os.environ['SUPABASE_SERVICE_ROLE_KEY']
|
||||
h={'apikey':key,'Authorization':f'Bearer {key}','Content-Type':'application/json'}
|
||||
|
||||
MAP={
|
||||
'3019377678':'a7a6eb49-435c-411e-bdb0-a45b6a75ffab',
|
||||
'7658651782':'2f6587f1-5dab-4994-b99f-d62f4300dd92',
|
||||
'4975185233':'25d3cdda-a3fc-4af7-b733-b38304b489df',
|
||||
'3183140753':'506a6d00-dc5a-4d84-bedc-73a35e3014a6',
|
||||
'8746480744':'98d023ba-1c5c-4cab-8ab9-4e70c4367bb1',
|
||||
'7181044127':'6f5c19e4-bf8b-4ef0-8c7e-55c9d0c0a041',
|
||||
'8436023380':'97def34e-e1c4-41b4-a579-036b14cd5091',
|
||||
'2986275882':'229687b5-6861-4b6d-b321-e09197629731',
|
||||
'6481978772':'8e5761b7-6347-4fd1-acbf-4572730a783a',
|
||||
'9069365731':'73a1b415-2325-4cab-9769-823021a5e888',
|
||||
'8177742252':'730feaf2-f2a3-4a18-963b-12b853551d8d',
|
||||
'9627541456':'fd7d361b-c4d0-47ca-bc0e-75d21ac50da3',
|
||||
'8592129580':'7df5293e-b954-4059-94da-d18534a08767',
|
||||
'6871069003':'c538a248-4ef0-437b-b4a0-544cff597659',
|
||||
'2508925045':'a5a250b4-e79a-4cb3-bfa1-4f9a6d908a03',
|
||||
'4418211066':'48919ad2-6af0-4ef0-96a3-fd9ca0a46cd2',
|
||||
'1323363601':'0b3c5fc0-6901-4caf-b26a-b6a0201d8541',
|
||||
'1064441284':'8bec977a-6032-4759-aa7f-b5042081f450', # BPS / Blue Parrot School
|
||||
}
|
||||
|
||||
def call(method, path, body=None):
|
||||
headers=dict(h); headers['Prefer']='return=minimal'
|
||||
data=None if body is None else json.dumps(body).encode()
|
||||
req=urllib.request.Request(f'{url}{path}', data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as r:
|
||||
return r.status, r.read().decode()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode()[:400]
|
||||
|
||||
since=(datetime.now(timezone.utc)-timedelta(days=30)).date().isoformat()
|
||||
gads=ROOT/'.ops'/'gads'
|
||||
total_rows=0; total_spend=0.0
|
||||
for path in sorted(gads.glob('*.json')):
|
||||
cid=path.stem
|
||||
tid=MAP.get(cid)
|
||||
if not tid:
|
||||
print('skip unknown', cid); continue
|
||||
rows_raw=json.loads(path.read_text())
|
||||
if not rows_raw:
|
||||
print(cid, 'EMPTY'); continue
|
||||
out=[]
|
||||
for r in rows_raw:
|
||||
date=r.get('segments.date') or (r.get('segments') or {}).get('date')
|
||||
if not date: continue
|
||||
camp_id=r.get('campaign.id') or (r.get('campaign') or {}).get('id')
|
||||
camp_name=r.get('campaign.name') or (r.get('campaign') or {}).get('name')
|
||||
imp=r.get('metrics.impressions') or (r.get('metrics') or {}).get('impressions') or 0
|
||||
clicks=r.get('metrics.clicks') or (r.get('metrics') or {}).get('clicks') or 0
|
||||
cost=r.get('metrics.cost_micros') or (r.get('metrics') or {}).get('costMicros') or (r.get('metrics') or {}).get('cost_micros') or 0
|
||||
conv=r.get('metrics.conversions') or (r.get('metrics') or {}).get('conversions') or 0
|
||||
cid_s = str(camp_id) if camp_id is not None else ''
|
||||
out.append({
|
||||
'tenant_id': tid,
|
||||
'date': date,
|
||||
'platform': 'google',
|
||||
'campaign_id': cid_s or None,
|
||||
'campaign_name': camp_name,
|
||||
# ad_daily.ad_id is NOT NULL — campaign-level rows reuse campaign id
|
||||
'ad_id': cid_s,
|
||||
'ad_name': '',
|
||||
'impressions': int(float(imp or 0)),
|
||||
'clicks': int(float(clicks or 0)),
|
||||
'spend': round(float(cost or 0)/1_000_000, 2),
|
||||
'platform_leads': int(round(float(conv or 0))),
|
||||
})
|
||||
call('DELETE', f'/rest/v1/ad_daily?tenant_id=eq.{tid}&platform=eq.google&date=gte.{since}')
|
||||
ok=True
|
||||
for i in range(0,len(out),400):
|
||||
st,res=call('POST','/rest/v1/ad_daily', out[i:i+400])
|
||||
if st not in (200,201):
|
||||
print('FAIL', cid, st, res); ok=False; break
|
||||
if ok:
|
||||
spend=sum(x['spend'] for x in out)
|
||||
total_rows+=len(out); total_spend+=spend
|
||||
print(f'OK {cid} rows={len(out)} spend={spend:.2f}')
|
||||
print('TOTAL', total_rows, round(total_spend,2))
|
||||
53
scripts/list_bps_calendar_sheets.ts
Normal file
53
scripts/list_bps_calendar_sheets.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
import { writeFileSync, mkdirSync } from 'fs';
|
||||
config({ path: resolve(process.cwd(), '.env.local') });
|
||||
import { getValidAccessToken } from '../src/lib/google-drive';
|
||||
|
||||
const ID = '1YPcHBjioiHHQy22Q2xevkvUqA5jMq87v6IviV7VTL2s';
|
||||
const OCT = '1-Zss1suBLDxAgqUuP9DrJYWHiicyin8aQqMOTIIekAg';
|
||||
|
||||
async function main() {
|
||||
const token = await getValidAccessToken();
|
||||
mkdirSync('/tmp/bps-cal', { recursive: true });
|
||||
for (const sid of [ID, OCT]) {
|
||||
const r = await fetch(
|
||||
`https://sheets.googleapis.com/v4/spreadsheets/${sid}?fields=properties.title,sheets.properties`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
const j = (await r.json()) as {
|
||||
properties?: { title?: string };
|
||||
sheets?: { properties?: { title?: string; sheetId?: number } }[];
|
||||
error?: unknown;
|
||||
};
|
||||
console.log('status', r.status, JSON.stringify(j).slice(0, 500));
|
||||
console.log('\n===', j.properties?.title, '===');
|
||||
for (const s of j.sheets || []) {
|
||||
const title = s.properties?.title || '';
|
||||
console.log(' sheet:', title);
|
||||
const csv = await fetch(
|
||||
`https://docs.google.com/spreadsheets/d/${sid}/export?format=csv&gid=${s.properties?.sheetId ?? 0}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` }, redirect: 'follow' }
|
||||
);
|
||||
// Sheets export via docs URL may need alternate - use values API
|
||||
const vals = await fetch(
|
||||
`https://sheets.googleapis.com/v4/spreadsheets/${sid}/values/${encodeURIComponent(title)}!A1:Z80`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
const vj = (await vals.json()) as { values?: string[][] };
|
||||
const rows = vj.values || [];
|
||||
const safe = title.replace(/[^\w.-]+/g, '_');
|
||||
writeFileSync(
|
||||
`/tmp/bps-cal/${sid.slice(0, 6)}_${safe}.json`,
|
||||
JSON.stringify(rows, null, 2)
|
||||
);
|
||||
console.log(' rows', rows.length, 'head0', (rows[0] || []).slice(0, 8).join(' | '));
|
||||
console.log(' head1', (rows[1] || []).slice(0, 8).join(' | '));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
64
scripts/list_drive_calendars.ts
Normal file
64
scripts/list_drive_calendars.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/** List Drive calendar folder and find BPS files. */
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
config({ path: resolve(process.cwd(), '.env.local') });
|
||||
|
||||
import { getValidAccessToken } from '../src/lib/google-drive';
|
||||
|
||||
const FOLDER = '12tPM0_nuVEf9MBUeyipmcWAJ-gFFgqXh';
|
||||
|
||||
async function listChildren(token: string, parentId: string) {
|
||||
const out: { id: string; name: string; mimeType: string }[] = [];
|
||||
let pageToken: string | undefined;
|
||||
do {
|
||||
const params = new URLSearchParams({
|
||||
q: `'${parentId}' in parents and trashed=false`,
|
||||
fields: 'nextPageToken,files(id,name,mimeType,modifiedTime)',
|
||||
pageSize: '100',
|
||||
supportsAllDrives: 'true',
|
||||
includeItemsFromAllDrives: 'true',
|
||||
corpora: 'allDrives',
|
||||
});
|
||||
if (pageToken) params.set('pageToken', pageToken);
|
||||
const r = await fetch(`https://www.googleapis.com/drive/v3/files?${params}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const j = (await r.json()) as {
|
||||
files?: { id: string; name: string; mimeType: string }[];
|
||||
nextPageToken?: string;
|
||||
};
|
||||
if (!r.ok) throw new Error(JSON.stringify(j).slice(0, 400));
|
||||
out.push(...(j.files || []));
|
||||
pageToken = j.nextPageToken;
|
||||
} while (pageToken);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const token = await getValidAccessToken();
|
||||
console.log('token ok');
|
||||
const top = await listChildren(token, FOLDER);
|
||||
console.log('top count', top.length);
|
||||
for (const f of top.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const kind = f.mimeType.includes('folder') ? 'DIR' : 'FILE';
|
||||
console.log(`${kind}\t${f.name}\t${f.id}`);
|
||||
}
|
||||
const bps = top.filter((f) => /bps|blue.?parrot|parrot/i.test(f.name));
|
||||
console.log('\nBPS matches', bps.length);
|
||||
for (const f of bps) {
|
||||
if (f.mimeType.includes('folder')) {
|
||||
const kids = await listChildren(token, f.id);
|
||||
console.log(`\n--- ${f.name} (${kids.length}) ---`);
|
||||
for (const k of kids.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
console.log(
|
||||
` ${k.mimeType.includes('folder') ? 'DIR' : 'FILE'}\t${k.name}\t${k.id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
101
scripts/probe_bps_seguimiento_drive.ts
Normal file
101
scripts/probe_bps_seguimiento_drive.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
config({ path: resolve(process.cwd(), '.env.local') });
|
||||
|
||||
import { getValidAccessToken } from '../src/lib/google-drive';
|
||||
import {
|
||||
buildSeguimientoRowsForDoc,
|
||||
upsertSeguimientoRows,
|
||||
patchSuperhumanIntegration,
|
||||
findSeguimientoGoogleDoc,
|
||||
exportGoogleDocHtml,
|
||||
splitHtmlNotesByDay,
|
||||
} from '../src/lib/superhuman-docs';
|
||||
|
||||
const BPS = '8bec977a-6032-4759-aa7f-b5042081f450';
|
||||
const DOC = '7TGywaNiUY';
|
||||
|
||||
async function driveSearch(q: string) {
|
||||
const token = await getValidAccessToken();
|
||||
const params = new URLSearchParams({
|
||||
q,
|
||||
fields: 'files(id,name,mimeType,webViewLink,modifiedTime)',
|
||||
pageSize: '30',
|
||||
corpora: 'allDrives',
|
||||
includeItemsFromAllDrives: 'true',
|
||||
supportsAllDrives: 'true',
|
||||
});
|
||||
const r = await fetch(`https://www.googleapis.com/drive/v3/files?${params}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
return ((await r.json()) as { files?: unknown[] }).files || [];
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(
|
||||
'search seguimiento+parrot',
|
||||
await driveSearch(
|
||||
"name contains 'Seguimiento' and name contains 'Parrot' and trashed=false"
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
'search seguimiento+BPS',
|
||||
await driveSearch(
|
||||
"name contains 'Seguimiento' and name contains 'BPS' and trashed=false"
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
'search Blue Parrot docs',
|
||||
await driveSearch(
|
||||
"name contains 'Blue Parrot' and mimeType='application/vnd.google-apps.document' and trashed=false"
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
'findSeguimiento',
|
||||
await findSeguimientoGoogleDoc([
|
||||
'Seguimiento Semanal Blue Parrot School',
|
||||
'Seguimiento Semanal BPS',
|
||||
'Seguimiento Semanal Blue Parrot',
|
||||
'Blue Parrot School',
|
||||
'BPS',
|
||||
])
|
||||
);
|
||||
|
||||
// Re-run full build (includes Drive embed discovery from Superhuman pages)
|
||||
const rows = await buildSeguimientoRowsForDoc({
|
||||
tenantId: BPS,
|
||||
tenantName: 'Blue Parrot School',
|
||||
docId: DOC,
|
||||
maxEntries: 120,
|
||||
exportBodies: true,
|
||||
});
|
||||
console.log(
|
||||
'rows',
|
||||
rows.length,
|
||||
rows.slice(0, 8).map((r) => ({
|
||||
title: r.title,
|
||||
occurred: r.occurred_at,
|
||||
ext: r.external_id,
|
||||
bodyLen: (r.body_md || '').length,
|
||||
}))
|
||||
);
|
||||
const n = await upsertSeguimientoRows(rows);
|
||||
await patchSuperhumanIntegration(BPS, {
|
||||
status: 'connected',
|
||||
external_account_id: DOC,
|
||||
label: '01.BLUE PARROT SCHOOL',
|
||||
last_sync_at: new Date().toISOString(),
|
||||
error_message: null,
|
||||
meta: {
|
||||
doc_name: '01.BLUE PARROT SCHOOL',
|
||||
entries: n,
|
||||
synced_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
console.log('upserted', n);
|
||||
}
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
52
scripts/probe_bps_superhuman.ts
Normal file
52
scripts/probe_bps_superhuman.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
config({ path: resolve(process.cwd(), '.env.local') });
|
||||
|
||||
import {
|
||||
listDocs,
|
||||
bestDocForTenant,
|
||||
cleanClientDocName,
|
||||
} from '../src/lib/superhuman-docs';
|
||||
import { scoreNameToTenant } from '../src/lib/match';
|
||||
import { findSeguimientoGoogleDoc } from '../src/lib/superhuman-docs';
|
||||
|
||||
async function main() {
|
||||
const docs = await listDocs();
|
||||
console.log('docs', docs.length);
|
||||
for (const d of docs) {
|
||||
const clean = cleanClientDocName(d.name);
|
||||
const s1 = scoreNameToTenant(clean, 'BPS', 'bps');
|
||||
const s2 = scoreNameToTenant(clean, 'Blue Parrot School', 'blue-parrot');
|
||||
if (
|
||||
s1 >= 15 ||
|
||||
s2 >= 15 ||
|
||||
/parrot|bps|blue/i.test(d.name)
|
||||
) {
|
||||
console.log({ name: d.name, clean, s1, s2, id: d.id });
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
'best BPS',
|
||||
bestDocForTenant({ name: 'BPS', slug: 'bps' }, docs)
|
||||
);
|
||||
console.log(
|
||||
'best Blue Parrot School',
|
||||
bestDocForTenant(
|
||||
{ name: 'Blue Parrot School', slug: 'blue-parrot-school' },
|
||||
docs
|
||||
)
|
||||
);
|
||||
|
||||
const drive = await findSeguimientoGoogleDoc([
|
||||
'Seguimiento Semanal BPS',
|
||||
'Seguimiento Semanal Blue Parrot School',
|
||||
'Seguimiento Semanal Blue Parrot',
|
||||
'BPS',
|
||||
'Blue Parrot School',
|
||||
]);
|
||||
console.log('drive hit', drive);
|
||||
}
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
41
scripts/probe_seguimiento_html.ts
Normal file
41
scripts/probe_seguimiento_html.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
config({ path: resolve(process.cwd(), '.env.local') });
|
||||
|
||||
import {
|
||||
exportGoogleDocHtml,
|
||||
splitHtmlNotesByDay,
|
||||
splitNotesByDay,
|
||||
} from '../src/lib/superhuman-docs';
|
||||
|
||||
const FILE = '1uDnRPWf1Dqx5b9L3TuhfvutyZpSaifz7YFNrRy_eBhw';
|
||||
|
||||
async function main() {
|
||||
const html = await exportGoogleDocHtml(FILE);
|
||||
console.log('html len', html.length);
|
||||
console.log('sample', html.slice(0, 1500));
|
||||
// find date-like patterns
|
||||
const dates = html.match(/\d{1,2}[\/\-.]\d{1,2}[\/\-.]\d{2,4}/g) || [];
|
||||
console.log('date-like', [...new Set(dates)].slice(0, 40));
|
||||
const headings = html.match(/<h[1-6][^>]*>[\s\S]*?<\/h[1-6]>/gi) || [];
|
||||
console.log(
|
||||
'headings',
|
||||
headings.slice(0, 20).map((h) => h.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim())
|
||||
);
|
||||
const secs = splitHtmlNotesByDay(html);
|
||||
console.log('html sections', secs.length, secs.slice(0, 3).map((s) => s.label));
|
||||
const plain = html
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<[^>]+>/g, '\n');
|
||||
const plainSecs = splitNotesByDay(plain);
|
||||
console.log(
|
||||
'plain sections',
|
||||
plainSecs.length,
|
||||
plainSecs.slice(0, 5).map((s) => s.label)
|
||||
);
|
||||
console.log('plain sample\n', plain.slice(0, 2000));
|
||||
}
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
96
scripts/provision_victor_bps.ts
Normal file
96
scripts/provision_victor_bps.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Provision victor@4ventures.es as BPS client portal user.
|
||||
* npx tsx scripts/provision_victor_bps.ts
|
||||
*/
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
import { writeFileSync } from 'fs';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
config({ path: resolve(process.cwd(), '.env.local') });
|
||||
|
||||
const BPS = '8bec977a-6032-4759-aa7f-b5042081f450';
|
||||
const EMAIL = 'victor@4ventures.es';
|
||||
|
||||
async function main() {
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
|
||||
const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
|
||||
if (!url || !key) throw new Error('Faltan NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY');
|
||||
|
||||
const admin = createClient(url, key, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
|
||||
const password = `BpsVictor-${randomBytes(4).toString('hex')}!9`;
|
||||
|
||||
let found: { id: string; app_metadata?: Record<string, unknown>; user_metadata?: Record<string, unknown> } | null =
|
||||
null;
|
||||
for (let page = 1; page <= 30; page++) {
|
||||
const { data, error } = await admin.auth.admin.listUsers({ page, perPage: 200 });
|
||||
if (error) throw error;
|
||||
const hit = data.users.find((u) => (u.email || '').toLowerCase() === EMAIL);
|
||||
if (hit) {
|
||||
found = hit;
|
||||
break;
|
||||
}
|
||||
if (data.users.length < 200) break;
|
||||
}
|
||||
|
||||
const app_metadata = {
|
||||
...(found?.app_metadata || {}),
|
||||
role: 'client',
|
||||
tenant_id: BPS,
|
||||
company: 'Blue Parrot School',
|
||||
};
|
||||
const user_metadata = {
|
||||
...(found?.user_metadata || {}),
|
||||
role: 'client',
|
||||
tenant_id: BPS,
|
||||
company: 'Blue Parrot School',
|
||||
};
|
||||
|
||||
if (found) {
|
||||
const { error } = await admin.auth.admin.updateUserById(found.id, {
|
||||
password,
|
||||
email_confirm: true,
|
||||
app_metadata,
|
||||
user_metadata,
|
||||
});
|
||||
if (error) throw error;
|
||||
console.log('updated', found.id);
|
||||
} else {
|
||||
const { data, error } = await admin.auth.admin.createUser({
|
||||
email: EMAIL,
|
||||
password,
|
||||
email_confirm: true,
|
||||
app_metadata,
|
||||
user_metadata,
|
||||
});
|
||||
if (error) throw error;
|
||||
console.log('created', data.user?.id);
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
resolve(process.cwd(), '.ops/victor-bps-credentials.txt'),
|
||||
[
|
||||
`email=${EMAIL}`,
|
||||
`password=${password}`,
|
||||
`tenant=Blue Parrot School (${BPS})`,
|
||||
`role=client`,
|
||||
`updated_at=${new Date().toISOString()}`,
|
||||
'',
|
||||
].join('\n'),
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
|
||||
console.log('---');
|
||||
console.log('url https://app.4ventures.es/login');
|
||||
console.log('email', EMAIL);
|
||||
console.log('password', password);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
592
scripts/refresh_agency_connections.py
Normal file
592
scripts/refresh_agency_connections.py
Normal file
@@ -0,0 +1,592 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Refresh Meta + Google agency matches into client fichas and audit."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
META = "https://graph.facebook.com/v21.0"
|
||||
GADS = "https://googleads.googleapis.com/v18"
|
||||
|
||||
|
||||
def load_env() -> dict[str, str]:
|
||||
vals: dict[str, str] = {}
|
||||
for p in (ROOT / ".env.local", Path("/Users/4ventures/Projects/bps-facturacion/.env")):
|
||||
if not p.is_file():
|
||||
continue
|
||||
for line in p.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.setdefault(k.strip(), v.strip().strip('"').strip("'"))
|
||||
return vals
|
||||
|
||||
|
||||
ENV = load_env()
|
||||
BASE = ENV["NEXT_PUBLIC_SUPABASE_URL"].rstrip("/")
|
||||
KEY = ENV["SUPABASE_SERVICE_ROLE_KEY"]
|
||||
SH = {"apikey": KEY, "Authorization": f"Bearer {KEY}"}
|
||||
|
||||
|
||||
def sb_get(path: str):
|
||||
req = urllib.request.Request(BASE + path, headers=SH)
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
return json.loads(r.read().decode())
|
||||
|
||||
|
||||
def sb_patch(path: str, body: dict) -> bool:
|
||||
data = json.dumps(body).encode()
|
||||
req = urllib.request.Request(
|
||||
BASE + path,
|
||||
data=data,
|
||||
headers={**SH, "Content-Type": "application/json", "Prefer": "return=minimal"},
|
||||
method="PATCH",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
return 200 <= r.status < 300
|
||||
except urllib.error.HTTPError:
|
||||
return False
|
||||
|
||||
|
||||
def sb_upsert_integ(tenant_id: str, provider: str, patch: dict) -> bool:
|
||||
path = (
|
||||
f"/rest/v1/integrations?tenant_id=eq.{urllib.parse.quote(tenant_id)}"
|
||||
f"&provider=eq.{urllib.parse.quote(provider)}"
|
||||
)
|
||||
body = {**patch, "updated_at": datetime.now(timezone.utc).isoformat()}
|
||||
if sb_patch(path, body):
|
||||
return True
|
||||
data = json.dumps(
|
||||
{"tenant_id": tenant_id, "provider": provider, "enabled": True, **body}
|
||||
).encode()
|
||||
req = urllib.request.Request(
|
||||
BASE + "/rest/v1/integrations",
|
||||
data=data,
|
||||
headers={
|
||||
**SH,
|
||||
"Content-Type": "application/json",
|
||||
"Prefer": "resolution=merge-duplicates,return=minimal",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as _:
|
||||
return True
|
||||
except urllib.error.HTTPError as e:
|
||||
print("upsert fail", provider, e.read().decode()[:200])
|
||||
return False
|
||||
|
||||
|
||||
def norm(s: str) -> str:
|
||||
s = s.lower()
|
||||
s = (
|
||||
s.replace("á", "a")
|
||||
.replace("é", "e")
|
||||
.replace("í", "i")
|
||||
.replace("ó", "o")
|
||||
.replace("ú", "u")
|
||||
.replace("ñ", "n")
|
||||
)
|
||||
return re.sub(r"[^a-z0-9]+", " ", s).strip()
|
||||
|
||||
|
||||
def score_name(tenant_name: str, tenant_slug: str, account_name: str) -> int:
|
||||
t = norm(tenant_name)
|
||||
s = norm(tenant_slug.replace("-", " "))
|
||||
a = norm(account_name)
|
||||
if not a:
|
||||
return 0
|
||||
if a == t or a == s:
|
||||
return 100
|
||||
if t and (t in a or a in t):
|
||||
return 85
|
||||
if s and (s in a or a in s):
|
||||
return 80
|
||||
tt = set(x for x in t.split() if len(x) > 2)
|
||||
aa = set(x for x in a.split() if len(x) > 2)
|
||||
if not tt or not aa:
|
||||
return 0
|
||||
hit = len(tt & aa) / len(tt)
|
||||
if hit >= 0.8:
|
||||
return 70
|
||||
if hit >= 0.5:
|
||||
return 55
|
||||
return int(hit * 50)
|
||||
|
||||
|
||||
def best_matches(tenants, accounts, threshold=55):
|
||||
"""Greedy 1:1 match like portal."""
|
||||
scored = []
|
||||
for t in tenants:
|
||||
for acc in accounts:
|
||||
sc = score_name(t["name"], t["slug"], acc["name"])
|
||||
if sc >= threshold:
|
||||
scored.append((sc, t["id"], acc))
|
||||
scored.sort(key=lambda x: -x[0])
|
||||
used_t, used_a = set(), set()
|
||||
out = {}
|
||||
for sc, tid, acc in scored:
|
||||
if tid in used_t or acc["id"] in used_a:
|
||||
continue
|
||||
used_t.add(tid)
|
||||
used_a.add(acc["id"])
|
||||
out[tid] = (acc, sc)
|
||||
return out
|
||||
|
||||
|
||||
def meta_get(token: str, path: str):
|
||||
url = f"{META}/{path}"
|
||||
sep = "&" if "?" in path else "?"
|
||||
req = urllib.request.Request(url + sep + "access_token=" + urllib.parse.quote(token))
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
return json.loads(r.read().decode())
|
||||
|
||||
|
||||
def meta_paginate(token: str, path: str):
|
||||
out = []
|
||||
url = f"{META}/{path}"
|
||||
sep = "&" if "?" in path else "?"
|
||||
url = url + sep + "access_token=" + urllib.parse.quote(token)
|
||||
while url:
|
||||
req = urllib.request.Request(url)
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
j = json.loads(r.read().decode())
|
||||
out.extend(j.get("data") or [])
|
||||
url = (j.get("paging") or {}).get("next")
|
||||
return out
|
||||
|
||||
|
||||
def google_refresh(refresh: str) -> str:
|
||||
body = urllib.parse.urlencode(
|
||||
{
|
||||
"refresh_token": refresh,
|
||||
"client_id": ENV["GOOGLE_OAUTH_CLIENT_ID"],
|
||||
"client_secret": ENV["GOOGLE_OAUTH_CLIENT_SECRET"],
|
||||
"grant_type": "refresh_token",
|
||||
}
|
||||
).encode()
|
||||
req = urllib.request.Request(
|
||||
"https://oauth2.googleapis.com/token",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
return json.loads(r.read().decode())["access_token"]
|
||||
|
||||
|
||||
def gads_access() -> str:
|
||||
refresh = ENV.get("GOOGLE_ADS_REFRESH_TOKEN") or ENV.get("GOOGLE_OAUTH_REFRESH_TOKEN")
|
||||
cid = ENV.get("GOOGLE_ADS_CLIENT_ID") or ENV.get("GOOGLE_OAUTH_CLIENT_ID")
|
||||
secret = ENV.get("GOOGLE_ADS_CLIENT_SECRET") or ENV.get("GOOGLE_OAUTH_CLIENT_SECRET")
|
||||
body = urllib.parse.urlencode(
|
||||
{
|
||||
"refresh_token": refresh,
|
||||
"client_id": cid,
|
||||
"client_secret": secret,
|
||||
"grant_type": "refresh_token",
|
||||
}
|
||||
).encode()
|
||||
req = urllib.request.Request(
|
||||
"https://oauth2.googleapis.com/token",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
return json.loads(r.read().decode())["access_token"]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
tenants = sb_get("/rest/v1/tenants?select=id,name,slug&order=name.asc")
|
||||
print(f"tenants={len(tenants)}")
|
||||
|
||||
# --- tokens ---
|
||||
g_rows = sb_get(
|
||||
"/rest/v1/agency_oauth?provider=eq.google_drive&select=access_token,refresh_token,expires_at,account_email,scopes"
|
||||
)
|
||||
m_rows = sb_get(
|
||||
"/rest/v1/agency_oauth?provider=eq.meta_ads&select=access_token,account_email,scopes,updated_at"
|
||||
)
|
||||
if not g_rows:
|
||||
print("FAIL: no google_drive oauth")
|
||||
return 1
|
||||
if not m_rows:
|
||||
print("FAIL: no meta_ads oauth")
|
||||
return 1
|
||||
|
||||
grow = g_rows[0]
|
||||
mrow = m_rows[0]
|
||||
print("Google:", grow.get("account_email"))
|
||||
print("Meta:", mrow.get("account_email"), "updated", mrow.get("updated_at"))
|
||||
|
||||
gtoken = grow["access_token"]
|
||||
exp = grow.get("expires_at")
|
||||
if exp:
|
||||
try:
|
||||
et = datetime.fromisoformat(exp.replace("Z", "+00:00"))
|
||||
if et.timestamp() < datetime.now(timezone.utc).timestamp() + 60:
|
||||
print("Refreshing Google access token…")
|
||||
gtoken = google_refresh(grow["refresh_token"])
|
||||
except Exception as e:
|
||||
print("google refresh warn", e)
|
||||
gtoken = google_refresh(grow["refresh_token"])
|
||||
|
||||
mtoken = mrow["access_token"]
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
linked_counts = defaultdict(int)
|
||||
|
||||
# ========== META ==========
|
||||
print("\n=== META Ads + Pages + IG ===")
|
||||
ad_accounts = []
|
||||
for row in meta_paginate(
|
||||
mtoken, "me/adaccounts?fields=id,account_id,name,account_status&limit=200"
|
||||
):
|
||||
aid = row.get("account_id") or row["id"].replace("act_", "")
|
||||
ad_accounts.append({"id": f"act_{aid}", "name": row.get("name") or aid})
|
||||
print(f"Meta ad accounts visible: {len(ad_accounts)}")
|
||||
|
||||
pages = []
|
||||
igs = []
|
||||
for page in meta_paginate(
|
||||
mtoken,
|
||||
"me/accounts?fields=id,name,instagram_business_account{id,username,name}&limit=100",
|
||||
):
|
||||
pages.append({"id": page["id"], "name": page.get("name") or page["id"]})
|
||||
ig = page.get("instagram_business_account") or {}
|
||||
if ig.get("id"):
|
||||
igs.append(
|
||||
{
|
||||
"id": ig["id"],
|
||||
"name": f"@{ig['username']}" if ig.get("username") else (ig.get("name") or page.get("name") or ig["id"]),
|
||||
}
|
||||
)
|
||||
print(f"FB pages visible: {len(pages)} · IG: {len(igs)}")
|
||||
|
||||
for provider, accounts, label in (
|
||||
("meta_ads", ad_accounts, "Meta Ads"),
|
||||
("facebook", pages, "Facebook Page"),
|
||||
("instagram", igs, "Instagram"),
|
||||
):
|
||||
matches = best_matches(tenants, accounts, threshold=55)
|
||||
for tid, (acc, sc) in matches.items():
|
||||
ok = sb_upsert_integ(
|
||||
tid,
|
||||
provider,
|
||||
{
|
||||
"status": "connected",
|
||||
"external_account_id": acc["id"],
|
||||
"label": acc["name"],
|
||||
"last_sync_at": now,
|
||||
"error_message": None,
|
||||
"enabled": True,
|
||||
"meta": {
|
||||
"account_name": acc["name"],
|
||||
"match_score": sc,
|
||||
"matched_at": now,
|
||||
"source": "refresh_script",
|
||||
},
|
||||
},
|
||||
)
|
||||
if ok:
|
||||
linked_counts[provider] += 1
|
||||
print(f" linked {provider}: {linked_counts[provider]}/{len(accounts)}")
|
||||
|
||||
# ========== GOOGLE DRIVE ==========
|
||||
print("\n=== Google Drive folders ===")
|
||||
folders = []
|
||||
page_token = None
|
||||
while True:
|
||||
params = {
|
||||
"q": "mimeType='application/vnd.google-apps.folder' and trashed=false",
|
||||
"fields": "nextPageToken,files(id,name)",
|
||||
"pageSize": "1000",
|
||||
"corpora": "allDrives",
|
||||
"includeItemsFromAllDrives": "true",
|
||||
"supportsAllDrives": "true",
|
||||
}
|
||||
if page_token:
|
||||
params["pageToken"] = page_token
|
||||
req = urllib.request.Request(
|
||||
"https://www.googleapis.com/drive/v3/files?" + urllib.parse.urlencode(params),
|
||||
headers={"Authorization": f"Bearer {gtoken}"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
j = json.loads(r.read().decode())
|
||||
for f in j.get("files") or []:
|
||||
folders.append({"id": f["id"], "name": f["name"]})
|
||||
page_token = j.get("nextPageToken")
|
||||
if not page_token:
|
||||
break
|
||||
print(f"Drive folders: {len(folders)}")
|
||||
matches = best_matches(tenants, folders, threshold=55)
|
||||
for tid, (acc, sc) in matches.items():
|
||||
if sb_upsert_integ(
|
||||
tid,
|
||||
"google_drive",
|
||||
{
|
||||
"status": "connected",
|
||||
"external_account_id": acc["id"],
|
||||
"label": acc["name"],
|
||||
"last_sync_at": now,
|
||||
"error_message": None,
|
||||
"meta": {"folder_name": acc["name"], "match_score": sc, "source": "refresh_script"},
|
||||
},
|
||||
):
|
||||
linked_counts["google_drive"] += 1
|
||||
print(f" linked google_drive: {linked_counts['google_drive']}")
|
||||
|
||||
# ========== GA4 ==========
|
||||
print("\n=== GA4 properties ===")
|
||||
ga4 = []
|
||||
req = urllib.request.Request(
|
||||
"https://analyticsadmin.googleapis.com/v1beta/accountSummaries",
|
||||
headers={"Authorization": f"Bearer {gtoken}"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
summaries = json.loads(r.read().decode())
|
||||
for acc in summaries.get("accountSummaries") or []:
|
||||
for p in acc.get("propertySummaries") or []:
|
||||
prop = p.get("property")
|
||||
if not prop:
|
||||
continue
|
||||
# measurement id
|
||||
mid = None
|
||||
try:
|
||||
req2 = urllib.request.Request(
|
||||
f"https://analyticsadmin.googleapis.com/v1beta/{prop}/dataStreams",
|
||||
headers={"Authorization": f"Bearer {gtoken}"},
|
||||
)
|
||||
with urllib.request.urlopen(req2, timeout=60) as r2:
|
||||
streams = json.loads(r2.read().decode())
|
||||
for s in streams.get("dataStreams") or []:
|
||||
mid = (s.get("webStreamData") or {}).get("measurementId")
|
||||
if mid:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
ga4.append(
|
||||
{
|
||||
"id": mid or prop,
|
||||
"name": p.get("displayName") or prop,
|
||||
"property_id": prop,
|
||||
"measurement_id": mid,
|
||||
}
|
||||
)
|
||||
print(f"GA4 properties: {len(ga4)}")
|
||||
matches = best_matches(tenants, ga4, threshold=50)
|
||||
for tid, (acc, sc) in matches.items():
|
||||
if sb_upsert_integ(
|
||||
tid,
|
||||
"ga4",
|
||||
{
|
||||
"status": "connected",
|
||||
"external_account_id": acc["id"],
|
||||
"label": acc["name"],
|
||||
"last_sync_at": now,
|
||||
"error_message": None,
|
||||
"meta": {
|
||||
"property_id": acc.get("property_id"),
|
||||
"measurement_id": acc.get("measurement_id"),
|
||||
"display_name": acc["name"],
|
||||
"match_score": sc,
|
||||
"source": "refresh_script",
|
||||
},
|
||||
},
|
||||
):
|
||||
linked_counts["ga4"] += 1
|
||||
print(f" linked ga4: {linked_counts['ga4']}")
|
||||
|
||||
# ========== GSC ==========
|
||||
print("\n=== Search Console ===")
|
||||
req = urllib.request.Request(
|
||||
"https://www.googleapis.com/webmasters/v3/sites",
|
||||
headers={"Authorization": f"Bearer {gtoken}"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
sites_j = json.loads(r.read().decode())
|
||||
gsc = []
|
||||
for s in sites_j.get("siteEntry") or []:
|
||||
url = s.get("siteUrl")
|
||||
if url:
|
||||
gsc.append({"id": url, "name": url})
|
||||
print(f"GSC sites: {len(gsc)}")
|
||||
matches = best_matches(tenants, gsc, threshold=40)
|
||||
for tid, (acc, sc) in matches.items():
|
||||
if sb_upsert_integ(
|
||||
tid,
|
||||
"search_console",
|
||||
{
|
||||
"status": "connected",
|
||||
"external_account_id": acc["id"],
|
||||
"label": acc["id"],
|
||||
"last_sync_at": now,
|
||||
"error_message": None,
|
||||
"meta": {"site_url": acc["id"], "match_score": sc, "source": "refresh_script"},
|
||||
},
|
||||
):
|
||||
linked_counts["gsc"] += 1
|
||||
print(f" linked gsc: {linked_counts['gsc']}")
|
||||
|
||||
# ========== Google Ads ==========
|
||||
print("\n=== Google Ads ===")
|
||||
try:
|
||||
at = gads_access()
|
||||
headers = {
|
||||
"Authorization": f"Bearer {at}",
|
||||
"developer-token": ENV["GOOGLE_ADS_DEVELOPER_TOKEN"],
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
login = (ENV.get("GOOGLE_ADS_LOGIN_CUSTOMER_ID") or "").replace("-", "")
|
||||
if login:
|
||||
headers["login-customer-id"] = login
|
||||
req = urllib.request.Request(
|
||||
f"{GADS}/customers:listAccessibleCustomers", headers=headers
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
listed = json.loads(r.read().decode())
|
||||
ids = [rn.replace("customers/", "") for rn in listed.get("resourceNames") or []]
|
||||
gads_accounts = []
|
||||
for cid in ids:
|
||||
h = dict(headers)
|
||||
h["login-customer-id"] = cid
|
||||
body = json.dumps(
|
||||
{
|
||||
"query": "SELECT customer.id, customer.descriptive_name, customer.manager FROM customer LIMIT 1"
|
||||
}
|
||||
).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{GADS}/customers/{cid}/googleAds:search",
|
||||
data=body,
|
||||
headers=h,
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
j = json.loads(r.read().decode())
|
||||
except Exception:
|
||||
continue
|
||||
c = ((j.get("results") or [{}])[0]).get("customer") or {}
|
||||
if not c:
|
||||
continue
|
||||
name = c.get("descriptiveName") or cid
|
||||
if c.get("manager"):
|
||||
# pull clients
|
||||
body2 = json.dumps(
|
||||
{
|
||||
"query": (
|
||||
"SELECT customer_client.id, customer_client.descriptive_name, "
|
||||
"customer_client.manager, customer_client.status FROM customer_client "
|
||||
"WHERE customer_client.status = 'ENABLED' AND customer_client.manager = FALSE"
|
||||
)
|
||||
}
|
||||
).encode()
|
||||
req2 = urllib.request.Request(
|
||||
f"{GADS}/customers/{cid}/googleAds:search",
|
||||
data=body2,
|
||||
headers=h,
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req2, timeout=90) as r2:
|
||||
j2 = json.loads(r2.read().decode())
|
||||
for row in j2.get("results") or []:
|
||||
cc = row.get("customerClient") or {}
|
||||
if cc.get("id"):
|
||||
gads_accounts.append(
|
||||
{
|
||||
"id": str(cc["id"]),
|
||||
"name": cc.get("descriptiveName") or str(cc["id"]),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
gads_accounts.append({"id": str(c.get("id") or cid), "name": name})
|
||||
# dedupe
|
||||
mmap = {a["id"]: a for a in gads_accounts}
|
||||
gads_accounts = list(mmap.values())
|
||||
print(f"Google Ads accounts: {len(gads_accounts)}")
|
||||
matches = best_matches(tenants, gads_accounts, threshold=55)
|
||||
for tid, (acc, sc) in matches.items():
|
||||
if sb_upsert_integ(
|
||||
tid,
|
||||
"google_ads",
|
||||
{
|
||||
"status": "connected",
|
||||
"external_account_id": acc["id"],
|
||||
"label": acc["name"],
|
||||
"last_sync_at": now,
|
||||
"error_message": None,
|
||||
"meta": {
|
||||
"account_name": acc["name"],
|
||||
"customer_id": acc["id"],
|
||||
"match_score": sc,
|
||||
"source": "refresh_script",
|
||||
},
|
||||
},
|
||||
):
|
||||
linked_counts["google_ads"] += 1
|
||||
print(f" linked google_ads: {linked_counts['google_ads']}")
|
||||
except Exception as e:
|
||||
print("Google Ads ERROR:", e)
|
||||
|
||||
# ========== AUDIT ==========
|
||||
print("\n=== AUDIT ===")
|
||||
integs = sb_get(
|
||||
"/rest/v1/integrations?provider=in.(ga4,search_console,google_drive,google_ads,meta_ads,facebook,instagram)"
|
||||
"&select=tenant_id,provider,status,external_account_id,error_message"
|
||||
)
|
||||
by = defaultdict(dict)
|
||||
for i in integs:
|
||||
by[i["tenant_id"]][i["provider"]] = i
|
||||
|
||||
def flag(i):
|
||||
if not i:
|
||||
return "—"
|
||||
if i.get("status") == "connected" and i.get("external_account_id"):
|
||||
return "OK"
|
||||
if i.get("status") == "error":
|
||||
return "ERR"
|
||||
return "—"
|
||||
|
||||
print(
|
||||
f"{'Cliente':30} {'GAds':5} {'Meta':5} {'GA4':5} {'GSC':5} {'Drive':5} {'FB':5} {'IG':5} Org"
|
||||
)
|
||||
for t in tenants:
|
||||
m = by[t["id"]]
|
||||
# organic count
|
||||
req = urllib.request.Request(
|
||||
f"{BASE}/rest/v1/organic_daily?tenant_id=eq.{t['id']}&select=day",
|
||||
headers={**SH, "Prefer": "count=exact", "Range": "0-0"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
cr = r.headers.get("content-range") or "*/0"
|
||||
org = cr.split("/")[-1]
|
||||
print(
|
||||
f"{t['name'][:30]:30} "
|
||||
f"{flag(m.get('google_ads')):5} "
|
||||
f"{flag(m.get('meta_ads')):5} "
|
||||
f"{flag(m.get('ga4')):5} "
|
||||
f"{flag(m.get('search_console')):5} "
|
||||
f"{flag(m.get('google_drive')):5} "
|
||||
f"{flag(m.get('facebook')):5} "
|
||||
f"{flag(m.get('instagram')):5} "
|
||||
f"{org}"
|
||||
)
|
||||
|
||||
print("\n=== Linked counts this run ===")
|
||||
print(dict(linked_counts))
|
||||
print("\nDone. Orgánico days still need app sync (Emparejar todo Google / abrir Orgánico).")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
203
scripts/refresh_agency_connections.ts
Normal file
203
scripts/refresh_agency_connections.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* One-shot: bootstrap Google + discover Meta → fichas, then print audit.
|
||||
* Usage: npx tsx --env-file=.env.local scripts/refresh_agency_connections.ts
|
||||
*/
|
||||
import { bootstrapAgencyGoogle } from '../src/lib/agency-google-bootstrap';
|
||||
import { discoverAndMatch } from '../src/lib/ads-discovery';
|
||||
import { serviceHeaders, supabaseUrl } from '../src/lib/agency';
|
||||
|
||||
async function get(path: string) {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) throw new Error('no service role');
|
||||
const r = await fetch(`${supabaseUrl()}${path}`, {
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!r.ok) throw new Error(`${path}: ${await r.text()}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function organicCount(tenantId: string): Promise<number> {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) return 0;
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/organic_daily?tenant_id=eq.${tenantId}&select=day`,
|
||||
{
|
||||
headers: { ...headers, Prefer: 'count=exact', Range: '0-0' },
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
const cr = r.headers.get('content-range') || '';
|
||||
if (cr.includes('/')) return Number(cr.split('/').pop()) || 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('=== 1. Google bootstrap (Drive + GA4 + GSC + Ads + sync) ===');
|
||||
const boot = await bootstrapAgencyGoogle({
|
||||
includeGoogleAds: true,
|
||||
syncOrganic: true,
|
||||
organicDays: 28,
|
||||
});
|
||||
console.log(JSON.stringify(boot, null, 2));
|
||||
|
||||
console.log('\n=== 2. Meta discover (Ads + FB + IG) ===');
|
||||
const meta = await discoverAndMatch({
|
||||
apply: true,
|
||||
providers: ['meta_ads', 'google_ads'],
|
||||
});
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
meta: {
|
||||
accounts: meta.meta?.accounts,
|
||||
linked: meta.meta?.linked,
|
||||
pagesLinked: meta.meta?.pagesLinked,
|
||||
igLinked: meta.meta?.igLinked,
|
||||
error: meta.meta?.error,
|
||||
},
|
||||
google: {
|
||||
accounts: meta.google?.accounts,
|
||||
linked: meta.google?.linked,
|
||||
error: meta.google?.error,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
console.log('\n=== 3. Audit por cliente ===');
|
||||
const tenants = (await get(
|
||||
'/rest/v1/tenants?select=id,name,slug&order=name.asc'
|
||||
)) as { id: string; name: string; slug: string }[];
|
||||
const integs = (await get(
|
||||
'/rest/v1/integrations?provider=in.(ga4,search_console,google_drive,google_ads,meta_ads,facebook,instagram)&select=tenant_id,provider,status,external_account_id,error_message,meta'
|
||||
)) as {
|
||||
tenant_id: string;
|
||||
provider: string;
|
||||
status: string;
|
||||
external_account_id?: string | null;
|
||||
error_message?: string | null;
|
||||
meta?: Record<string, unknown> | null;
|
||||
}[];
|
||||
|
||||
const by = new Map<string, typeof integs>();
|
||||
for (const i of integs) {
|
||||
const arr = by.get(i.tenant_id) || [];
|
||||
arr.push(i);
|
||||
by.set(i.tenant_id, arr);
|
||||
}
|
||||
|
||||
const providers = [
|
||||
'google_ads',
|
||||
'meta_ads',
|
||||
'ga4',
|
||||
'search_console',
|
||||
'google_drive',
|
||||
'facebook',
|
||||
'instagram',
|
||||
] as const;
|
||||
|
||||
type Row = {
|
||||
name: string;
|
||||
organic_days: number;
|
||||
channels: Record<string, string>;
|
||||
gaps: string[];
|
||||
};
|
||||
const rows: Row[] = [];
|
||||
|
||||
for (const t of tenants) {
|
||||
const map = new Map(
|
||||
(by.get(t.id) || []).map((i) => [i.provider, i] as const)
|
||||
);
|
||||
const channels: Record<string, string> = {};
|
||||
const gaps: string[] = [];
|
||||
for (const p of providers) {
|
||||
const i = map.get(p);
|
||||
if (i?.status === 'connected' && i.external_account_id) {
|
||||
const id = String(i.external_account_id);
|
||||
channels[p] = id.length > 42 ? id.slice(0, 40) + '…' : id;
|
||||
} else if (i?.status === 'error') {
|
||||
channels[p] = `ERR:${(i.error_message || '').slice(0, 40)}`;
|
||||
gaps.push(p);
|
||||
} else {
|
||||
channels[p] = '—';
|
||||
gaps.push(p);
|
||||
}
|
||||
}
|
||||
const org = await organicCount(t.id);
|
||||
rows.push({ name: t.name, organic_days: org, channels, gaps });
|
||||
}
|
||||
|
||||
console.log(
|
||||
[
|
||||
'Cliente'.padEnd(28),
|
||||
'GAds',
|
||||
'Meta',
|
||||
'GA4',
|
||||
'GSC',
|
||||
'Drive',
|
||||
'FB',
|
||||
'IG',
|
||||
'OrgDías',
|
||||
].join('\t')
|
||||
);
|
||||
for (const r of rows) {
|
||||
const c = r.channels;
|
||||
const flag = (v: string) => (v === '—' ? '—' : v.startsWith('ERR') ? 'ERR' : 'OK');
|
||||
console.log(
|
||||
[
|
||||
r.name.slice(0, 28).padEnd(28),
|
||||
flag(c.google_ads),
|
||||
flag(c.meta_ads),
|
||||
flag(c.ga4),
|
||||
flag(c.search_console),
|
||||
flag(c.google_drive),
|
||||
flag(c.facebook),
|
||||
flag(c.instagram),
|
||||
String(r.organic_days),
|
||||
].join('\t')
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n=== Detalle IDs (solo conectados) ===');
|
||||
for (const r of rows) {
|
||||
const linked = Object.entries(r.channels).filter(([, v]) => v !== '—' && !v.startsWith('ERR'));
|
||||
if (!linked.length && r.organic_days === 0) continue;
|
||||
console.log(`\n${r.name} · organic_daily=${r.organic_days}`);
|
||||
for (const [p, v] of linked) console.log(` ${p}: ${v}`);
|
||||
}
|
||||
|
||||
const withAds = rows.filter(
|
||||
(r) => r.channels.google_ads !== '—' || r.channels.meta_ads !== '—'
|
||||
).length;
|
||||
const withOrg = rows.filter(
|
||||
(r) =>
|
||||
r.channels.ga4 !== '—' ||
|
||||
r.channels.search_console !== '—' ||
|
||||
r.organic_days > 0
|
||||
).length;
|
||||
const withSocial = rows.filter(
|
||||
(r) => r.channels.facebook !== '—' || r.channels.instagram !== '—'
|
||||
).length;
|
||||
console.log('\n=== RESUMEN ===');
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
tenants: rows.length,
|
||||
with_ads: withAds,
|
||||
with_organic_link_or_data: withOrg,
|
||||
with_fb_or_ig: withSocial,
|
||||
organic_tenants_with_rows: rows.filter((r) => r.organic_days > 0).length,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
164
scripts/seed_blue_parrot_pipeline.ts
Normal file
164
scripts/seed_blue_parrot_pipeline.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Carga las 33 piezas de Blue Parrot (oct 2026 - ene 2027) en creative_pipeline.
|
||||
*
|
||||
* Requiere:
|
||||
* 1. Haber aplicado sql/creative_pipeline.sql en Supabase.
|
||||
* 2. Haber descomprimido Blue-Parrot-creatividades-con-imagen.zip en una carpeta.
|
||||
* 3. .env.local con NEXT_PUBLIC_SUPABASE_URL y SUPABASE_SERVICE_ROLE_KEY reales
|
||||
* (los mismos que usa la app en producción — ejecutar esto en el propio VPS,
|
||||
* por ejemplo desde "Open Terminal" en Dokploy, nunca pegando la service
|
||||
* role key en un chat).
|
||||
*
|
||||
* Uso:
|
||||
* npx tsx scripts/seed_blue_parrot_pipeline.ts --dir /ruta/a/Blue-Parrot-creatividades-con-imagen \
|
||||
* [--status revision_interna] [--dry-run]
|
||||
*/
|
||||
import { config } from 'dotenv';
|
||||
import { resolve, join } from 'path';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
config({ path: resolve(process.cwd(), '.env.local') });
|
||||
|
||||
// Mismo tenant_id que scripts/provision_victor_bps.ts
|
||||
const BLUE_PARROT_TENANT_ID = '8bec977a-6032-4759-aa7f-b5042081f450';
|
||||
const BUCKET = 'tenant-assets';
|
||||
|
||||
type SeedItem = {
|
||||
id: string;
|
||||
format: 'post' | 'story' | 'reel' | 'carrusel';
|
||||
month: string;
|
||||
category: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
function arg(name: string, fallback?: string): string | undefined {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
if (i === -1) return fallback;
|
||||
return process.argv[i + 1];
|
||||
}
|
||||
const DRY_RUN = process.argv.includes('--dry-run');
|
||||
|
||||
async function main() {
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
|
||||
const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
|
||||
if (!url || !key) throw new Error('Faltan NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY en .env.local');
|
||||
|
||||
const dir = arg('dir');
|
||||
if (!dir || !existsSync(dir)) {
|
||||
throw new Error('Pasa --dir /ruta/a/la/carpeta/descomprimida (el ZIP de las 33 piezas)');
|
||||
}
|
||||
const status = arg('status', 'revision_interna')!;
|
||||
|
||||
const metaPath = resolve(__dirname, 'blue-parrot-pipeline-seed.json');
|
||||
const items: SeedItem[] = JSON.parse(readFileSync(metaPath, 'utf-8'));
|
||||
|
||||
const admin = createClient(url, key, { auth: { persistSession: false, autoRefreshToken: false } });
|
||||
|
||||
// Asegura el bucket (no falla si ya existe).
|
||||
const { data: buckets } = await admin.storage.listBuckets();
|
||||
if (!buckets?.some((b) => b.id === BUCKET)) {
|
||||
await admin.storage.createBucket(BUCKET, { public: false });
|
||||
}
|
||||
|
||||
let ok = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const it of items) {
|
||||
const isCarrusel = it.format === 'carrusel';
|
||||
const coverLocal = isCarrusel
|
||||
? join(dir, `${it.id}-carrusel`, `${it.id}-1.jpg`)
|
||||
: join(dir, `${it.id}-${it.format}.jpg`);
|
||||
|
||||
if (!existsSync(coverLocal)) {
|
||||
console.error(`[SKIP] ${it.id}: no existe ${coverLocal}`);
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`[${it.id}] ${it.title}`);
|
||||
if (DRY_RUN) {
|
||||
ok++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1) upsert de la fila (sin imágenes todavía) para obtener el id.
|
||||
const { data: row, error: upErr } = await admin
|
||||
.from('creative_pipeline')
|
||||
.upsert(
|
||||
{
|
||||
tenant_id: BLUE_PARROT_TENANT_ID,
|
||||
external_ref: it.id,
|
||||
title: it.title,
|
||||
format: it.format,
|
||||
month: it.month,
|
||||
status,
|
||||
notes: it.category,
|
||||
},
|
||||
{ onConflict: 'tenant_id,external_ref' }
|
||||
)
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (upErr || !row) {
|
||||
console.error(`[FAIL] ${it.id}: ${upErr?.message}`);
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
const itemId = row.id as string;
|
||||
|
||||
// 2) sube portada
|
||||
const coverPath = `${BLUE_PARROT_TENANT_ID}/pipeline/${itemId}/cover.jpg`;
|
||||
const coverBuf = readFileSync(coverLocal);
|
||||
const coverUp = await admin.storage.from(BUCKET).upload(coverPath, coverBuf, {
|
||||
contentType: 'image/jpeg',
|
||||
upsert: true,
|
||||
});
|
||||
if (coverUp.error) {
|
||||
console.error(`[FAIL cover] ${it.id}: ${coverUp.error.message}`);
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3) sube slides (carrusel: 2,3,4,5 → 4 slides interiores + CTA)
|
||||
const slidePaths: string[] = [];
|
||||
if (isCarrusel) {
|
||||
for (let n = 2; n <= 5; n++) {
|
||||
const local = join(dir, `${it.id}-carrusel`, `${it.id}-${n}.jpg`);
|
||||
if (!existsSync(local)) continue;
|
||||
const path = `${BLUE_PARROT_TENANT_ID}/pipeline/${itemId}/slide-${n - 1}.jpg`;
|
||||
const buf = readFileSync(local);
|
||||
const up = await admin.storage.from(BUCKET).upload(path, buf, {
|
||||
contentType: 'image/jpeg',
|
||||
upsert: true,
|
||||
});
|
||||
if (up.error) {
|
||||
console.error(`[FAIL slide ${n}] ${it.id}: ${up.error.message}`);
|
||||
continue;
|
||||
}
|
||||
slidePaths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) cierra la fila con las rutas de storage
|
||||
const { error: patchErr } = await admin
|
||||
.from('creative_pipeline')
|
||||
.update({ cover_path: coverPath, cover_mime: 'image/jpeg', slide_paths: slidePaths })
|
||||
.eq('id', itemId);
|
||||
|
||||
if (patchErr) {
|
||||
console.error(`[FAIL patch] ${it.id}: ${patchErr.message}`);
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
ok++;
|
||||
}
|
||||
|
||||
console.log(`\nHecho: ${ok} ok, ${failed} con error, de ${items.length} piezas.`);
|
||||
if (failed > 0) process.exitCode = 1;
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
67
scripts/smoke_agencia_simulate.py
Normal file
67
scripts/smoke_agencia_simulate.py
Normal file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Authenticated smoke: agencia + simulate."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path("/Users/4ventures/4ventures-client-portal")
|
||||
APP = "https://app.4ventures.es"
|
||||
CREDS = ROOT / ".ops" / "marketing-credentials.txt"
|
||||
SHOT = ROOT / "scripts/dokploy-shots"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
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()
|
||||
|
||||
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/agencia", wait_until="networkidle", timeout=60000)
|
||||
page.wait_for_timeout(1500)
|
||||
SHOT.mkdir(parents=True, exist_ok=True)
|
||||
page.screenshot(path=str(SHOT / "smoke_agencia.png"), full_page=True)
|
||||
body = page.inner_text("body")
|
||||
missing_key = "Falta SUPABASE_SERVICE_ROLE_KEY" in body
|
||||
print("agencia_missing_service_key", missing_key, flush=True)
|
||||
print("agencia_snip", " | ".join([ln.strip() for ln in body.splitlines() if ln.strip()][10:22]), flush=True)
|
||||
|
||||
sim = page.evaluate(
|
||||
"""async () => {
|
||||
const r = await fetch('/api/simulate', {
|
||||
method: 'POST',
|
||||
headers: {'content-type':'application/json'},
|
||||
body: JSON.stringify({ amount: 1000, channel: 'meta' })
|
||||
});
|
||||
const text = await r.text();
|
||||
let json=null; try { json=JSON.parse(text); } catch {}
|
||||
return { status: r.status, text: text.slice(0,250), json };
|
||||
}"""
|
||||
)
|
||||
print("simulate", json.dumps(sim, ensure_ascii=False)[:400], flush=True)
|
||||
browser.close()
|
||||
|
||||
if missing_key:
|
||||
print("FAIL_AGENCIA", flush=True)
|
||||
return 2
|
||||
print("SUCCESS", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
77
scripts/smoke_chat_auth.py
Normal file
77
scripts/smoke_chat_auth.py
Normal file
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Authenticated chat smoke on app.4ventures.es."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path("/Users/4ventures/4ventures-client-portal")
|
||||
APP = "https://app.4ventures.es"
|
||||
CREDS = ROOT / ".ops" / "marketing-credentials.txt"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
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()
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
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)
|
||||
if "/login" in page.url:
|
||||
print("LOGIN_FAILED", flush=True)
|
||||
browser.close()
|
||||
return 2
|
||||
|
||||
# unauth baseline via fresh context later; here authenticated
|
||||
res = page.evaluate(
|
||||
"""async () => {
|
||||
const r = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: {'content-type':'application/json'},
|
||||
body: JSON.stringify({ message: 'hola' })
|
||||
});
|
||||
const text = await r.text();
|
||||
let json = null;
|
||||
try { json = JSON.parse(text); } catch {}
|
||||
return { status: r.status, text: text.slice(0, 300), json };
|
||||
}"""
|
||||
)
|
||||
print("auth_chat", json.dumps(res, ensure_ascii=False)[:500], flush=True)
|
||||
browser.close()
|
||||
|
||||
# unauth
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{APP}/api/chat",
|
||||
data=b'{"message":"hola"}',
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
print("unauth_chat", r.status, r.read()[:80], flush=True)
|
||||
except urllib.error.HTTPError as e:
|
||||
print("unauth_chat", e.code, e.read()[:80], flush=True)
|
||||
|
||||
ok = res.get("status") == 200 and isinstance(res.get("json"), list)
|
||||
print("SUCCESS" if ok else "PARTIAL", flush=True)
|
||||
return 0 if ok else 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())
|
||||
75
scripts/sync_ads_only.ts
Normal file
75
scripts/sync_ads_only.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Sync Meta + Google Ads → ad_daily (+ kpi_weekly rollup).
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/sync_ads_only.ts # last 30d all tenants
|
||||
* DAYS=640 npx tsx scripts/sync_ads_only.ts # backfill ~from Jan 2025
|
||||
* TENANT_SLUG=bps DAYS=640 npx tsx ...
|
||||
*/
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
config({ path: resolve(process.cwd(), '.env.local') });
|
||||
|
||||
import {
|
||||
ADS_BACKFILL_MAX_DAYS,
|
||||
syncAdsForTenants,
|
||||
} from '../src/lib/ads-sync';
|
||||
|
||||
async function main() {
|
||||
const slug = process.env.TENANT_SLUG?.trim();
|
||||
let tenantId: string | undefined;
|
||||
if (slug) {
|
||||
const { serviceHeaders, supabaseUrl } = await import('../src/lib/agency');
|
||||
const headers = serviceHeaders()!;
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/tenants?slug=eq.${encodeURIComponent(slug)}&select=id&limit=1`,
|
||||
{ headers }
|
||||
);
|
||||
const rows = (await r.json()) as { id: string }[];
|
||||
tenantId = rows[0]?.id;
|
||||
if (!tenantId) throw new Error(`Tenant slug not found: ${slug}`);
|
||||
}
|
||||
|
||||
// Days since 2025-01-01 (inclusive) when DAYS unset and BACKFILL=1
|
||||
const backfill = process.env.BACKFILL === '1' || process.env.BACKFILL === 'true';
|
||||
const daysFrom2025 = Math.ceil(
|
||||
(Date.now() - Date.UTC(2025, 0, 1)) / 86400000
|
||||
);
|
||||
const rawDays = Number(process.env.DAYS);
|
||||
const days = Math.min(
|
||||
ADS_BACKFILL_MAX_DAYS,
|
||||
Math.max(
|
||||
1,
|
||||
Number.isFinite(rawDays) && rawDays > 0
|
||||
? rawDays
|
||||
: backfill
|
||||
? daysFrom2025
|
||||
: 30
|
||||
)
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Ads sync · days=${days} · since≈${new Date(Date.now() - days * 86400000).toISOString().slice(0, 10)}` +
|
||||
(tenantId ? ` · tenant=${slug || tenantId}` : ' · all tenants')
|
||||
);
|
||||
|
||||
const { results } = await syncAdsForTenants({ tenantId, days });
|
||||
let ok = 0;
|
||||
let err = 0;
|
||||
for (const x of results) {
|
||||
const rows = x.metaRows + x.googleRows;
|
||||
if (rows > 0) ok += 1;
|
||||
if (x.errors.length) err += 1;
|
||||
console.log(
|
||||
`${x.tenantName}: meta=${x.metaRows} google=${x.googleRows} weekly=${x.weeklyRows}` +
|
||||
(x.errors.length
|
||||
? ` · ERR ${x.errors.join('; ').slice(0, 200)}`
|
||||
: '')
|
||||
);
|
||||
}
|
||||
console.log(`\nDone · synced=${ok} withErrors=${err} / ${results.length}`);
|
||||
}
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
272
scripts/sync_all_data.ts
Normal file
272
scripts/sync_all_data.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* One-shot sync: organic + ads + superhuman + SEO audits.
|
||||
* Usage:
|
||||
* npx tsx -r dotenv/config -r tsconfig-paths/register scripts/sync_all_data.ts
|
||||
* Optional: TENANT_SLUG=bps
|
||||
*/
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
config({ path: resolve(process.cwd(), '.env.local') });
|
||||
config({ path: resolve(process.cwd(), '.env') });
|
||||
|
||||
import { serviceHeaders, supabaseUrl } from '../src/lib/agency';
|
||||
import { syncAllConnectedOrganic } from '../src/lib/google-organic';
|
||||
import { syncAdsForTenants } from '../src/lib/ads-sync';
|
||||
import { syncAllSuperhumanTenants } from '../src/lib/superhuman-docs';
|
||||
import { runSeoSiteAudit } from '../src/lib/seo-audit';
|
||||
import { resolveMetaAccessTokens } from '../src/lib/ads-discovery';
|
||||
|
||||
const META = 'https://graph.facebook.com/v21.0';
|
||||
|
||||
type Tenant = { id: string; name: string; slug: string };
|
||||
|
||||
async function sbGet<T>(path: string): Promise<T> {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) throw new Error('no service role');
|
||||
const r = await fetch(`${supabaseUrl()}/rest/v1/${path}`, {
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!r.ok) throw new Error(`${path}: ${(await r.text()).slice(0, 300)}`);
|
||||
return (await r.json()) as T;
|
||||
}
|
||||
|
||||
async function sbPatch(path: string, body: unknown) {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) throw new Error('no service role');
|
||||
const r = await fetch(`${supabaseUrl()}/rest/v1/${path}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) throw new Error(`PATCH ${path}: ${(await r.text()).slice(0, 300)}`);
|
||||
}
|
||||
|
||||
async function sbPost(path: string, body: unknown) {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) throw new Error('no service role');
|
||||
const r = await fetch(`${supabaseUrl()}/rest/v1/${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) throw new Error(`POST ${path}: ${(await r.text()).slice(0, 400)}`);
|
||||
}
|
||||
|
||||
async function syncSocial(tenants: Tenant[]) {
|
||||
const tokens = await resolveMetaAccessTokens();
|
||||
const token = tokens[0];
|
||||
if (!token) return { ok: false as const, warning: 'Sin META token', results: [] as unknown[] };
|
||||
|
||||
const results: { tenant: string; fb?: string; ig?: string }[] = [];
|
||||
|
||||
for (const tenant of tenants) {
|
||||
const integs = await sbGet<
|
||||
{
|
||||
provider: string;
|
||||
external_account_id?: string | null;
|
||||
status: string;
|
||||
meta?: Record<string, unknown> | null;
|
||||
}[]
|
||||
>(
|
||||
`integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=in.(facebook,instagram)&status=eq.connected&select=provider,external_account_id,status,meta`
|
||||
);
|
||||
if (!integs.length) continue;
|
||||
const out: { tenant: string; fb?: string; ig?: string } = {
|
||||
tenant: tenant.name,
|
||||
};
|
||||
|
||||
for (const i of integs) {
|
||||
const id = i.external_account_id;
|
||||
if (!id) continue;
|
||||
try {
|
||||
let payload: Record<string, unknown> | null = null;
|
||||
if (i.provider === 'facebook') {
|
||||
const r = await fetch(
|
||||
`${META}/${encodeURIComponent(id)}?fields=name,fan_count,followers_count&access_token=${encodeURIComponent(token)}`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
if (r.ok) {
|
||||
payload = (await r.json()) as Record<string, unknown>;
|
||||
out.fb = 'ok';
|
||||
} else {
|
||||
out.fb = `err ${(await r.text()).slice(0, 80)}`;
|
||||
}
|
||||
} else {
|
||||
const r = await fetch(
|
||||
`${META}/${encodeURIComponent(id)}?fields=name,followers_count,media_count&access_token=${encodeURIComponent(token)}`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
if (r.ok) {
|
||||
payload = (await r.json()) as Record<string, unknown>;
|
||||
out.ig = 'ok';
|
||||
} else {
|
||||
out.ig = `err ${(await r.text()).slice(0, 80)}`;
|
||||
}
|
||||
}
|
||||
if (payload) {
|
||||
const now = new Date().toISOString();
|
||||
await sbPatch(
|
||||
`integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.${i.provider}`,
|
||||
{
|
||||
last_sync_at: now,
|
||||
error_message: null,
|
||||
meta: {
|
||||
...(i.meta || {}),
|
||||
social_cache: payload,
|
||||
social_cached_at: now,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'err';
|
||||
if (i.provider === 'facebook') out.fb = msg;
|
||||
else out.ig = msg;
|
||||
}
|
||||
}
|
||||
results.push(out);
|
||||
}
|
||||
return { ok: true as const, results };
|
||||
}
|
||||
|
||||
async function syncSeoAudits(tenants: Tenant[]) {
|
||||
const results: {
|
||||
tenant: string;
|
||||
score?: number;
|
||||
grade?: string;
|
||||
error?: string;
|
||||
}[] = [];
|
||||
for (const tenant of tenants) {
|
||||
const [gsc, ga4] = await Promise.all([
|
||||
sbGet<
|
||||
{ external_account_id?: string | null; meta?: { site_url?: string } }[]
|
||||
>(
|
||||
`integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.search_console&status=eq.connected&select=external_account_id,meta&limit=1`
|
||||
),
|
||||
sbGet<{ id: string }[]>(
|
||||
`integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.ga4&status=eq.connected&select=id&limit=1`
|
||||
),
|
||||
]);
|
||||
const site =
|
||||
gsc[0]?.meta?.site_url || gsc[0]?.external_account_id || null;
|
||||
if (!site) continue;
|
||||
try {
|
||||
const audit = await runSeoSiteAudit({
|
||||
gscSiteUrl: site,
|
||||
ga4Connected: ga4.length > 0,
|
||||
gscConnected: true,
|
||||
});
|
||||
await sbPost('seo_audits', {
|
||||
tenant_id: tenant.id,
|
||||
site_url: audit.siteUrl,
|
||||
score: audit.score,
|
||||
grade: audit.grade,
|
||||
warning_count: audit.warningCount,
|
||||
critical_count: audit.criticalCount,
|
||||
checks: audit.checks,
|
||||
summary: audit.summary,
|
||||
});
|
||||
results.push({
|
||||
tenant: tenant.name,
|
||||
score: audit.score,
|
||||
grade: audit.grade,
|
||||
});
|
||||
} catch (e) {
|
||||
results.push({
|
||||
tenant: tenant.name,
|
||||
error: e instanceof Error ? e.message.slice(0, 160) : 'audit error',
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!serviceHeaders()) throw new Error('Falta SUPABASE_SERVICE_ROLE_KEY');
|
||||
|
||||
let tenants = await sbGet<Tenant[]>(
|
||||
'tenants?select=id,name,slug&order=name.asc'
|
||||
);
|
||||
const slug = process.env.TENANT_SLUG?.trim();
|
||||
const tid = process.env.TENANT_ID?.trim();
|
||||
if (slug) tenants = tenants.filter((t) => t.slug === slug);
|
||||
if (tid) tenants = tenants.filter((t) => t.id === tid);
|
||||
|
||||
console.log(`\n=== SYNC ALL · ${tenants.length} tenant(s) ===\n`);
|
||||
|
||||
console.log('--- ORGANIC ---');
|
||||
const organic = await syncAllConnectedOrganic({
|
||||
tenantId: tenants.length === 1 ? tenants[0].id : null,
|
||||
days: 28,
|
||||
});
|
||||
for (const r of organic.results) {
|
||||
const name = tenants.find((t) => t.id === r.tenantId)?.name || r.tenantId;
|
||||
console.log(
|
||||
` ${name}: ${r.daysWritten}d${r.warning ? ` · ⚠ ${r.warning}` : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n--- ADS ---');
|
||||
const ads = await syncAdsForTenants({
|
||||
tenantId: tenants.length === 1 ? tenants[0].id : undefined,
|
||||
days: 30,
|
||||
});
|
||||
for (const r of ads.results) {
|
||||
console.log(
|
||||
` ${r.tenantName}: meta=${r.metaRows} google=${r.googleRows}` +
|
||||
(r.errors.length ? ` · ERR ${r.errors.join('; ').slice(0, 100)}` : '')
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n--- SUPERHUMAN ---');
|
||||
try {
|
||||
const sh = await syncAllSuperhumanTenants({
|
||||
tenantId: tenants.length === 1 ? tenants[0].id : null,
|
||||
maxEntries: 100,
|
||||
exportBodies: true,
|
||||
});
|
||||
for (const r of sh.results) {
|
||||
console.log(
|
||||
` ${r.tenantName}: ${r.status}` +
|
||||
(r.upserted ? ` · ${r.upserted}` : '') +
|
||||
(r.docName ? ` · ${r.docName}` : '') +
|
||||
(r.error ? ` · ${r.error.slice(0, 80)}` : '')
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(' ', e instanceof Error ? e.message : e);
|
||||
}
|
||||
|
||||
console.log('\n--- SOCIAL ---');
|
||||
const social = await syncSocial(tenants);
|
||||
if (!social.ok) console.log(' ', social.warning);
|
||||
for (const r of social.results as { tenant: string; fb?: string; ig?: string }[]) {
|
||||
console.log(` ${r.tenant}: fb=${r.fb || '-'} ig=${r.ig || '-'}`);
|
||||
}
|
||||
|
||||
console.log('\n--- SEO AUDIT ---');
|
||||
for (const r of await syncSeoAudits(tenants)) {
|
||||
console.log(
|
||||
` ${r.tenant}:` +
|
||||
(r.grade != null ? ` ${r.grade} (${r.score})` : '') +
|
||||
(r.error ? ` ERR ${r.error}` : '')
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n=== DONE ===\n');
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
145
scripts/sync_bps_gdoc_days.ts
Normal file
145
scripts/sync_bps_gdoc_days.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
config({ path: resolve(process.cwd(), '.env.local') });
|
||||
|
||||
import {
|
||||
exportGoogleDocPlain,
|
||||
exportGoogleDocHtml,
|
||||
upsertSeguimientoRows,
|
||||
patchSuperhumanIntegration,
|
||||
} from '../src/lib/superhuman-docs';
|
||||
import { getValidAccessToken } from '../src/lib/google-drive';
|
||||
|
||||
const BPS = '8bec977a-6032-4759-aa7f-b5042081f450';
|
||||
const FILE = '1uDnRPWf1Dqx5b9L3TuhfvutyZpSaifz7YFNrRy_eBhw';
|
||||
const DOC = '7TGywaNiUY';
|
||||
|
||||
function parseDayLabel(raw: string): { dateIso: string; label: string } | null {
|
||||
const m = raw.trim().match(/^(\d{1,2})[\/.\-](\d{1,2})[\/.\-](\d{2,4})$/);
|
||||
if (!m) return null;
|
||||
let y = Number(m[3]);
|
||||
const mo = Number(m[2]);
|
||||
const d = Number(m[1]);
|
||||
if (y < 100) y += 2000;
|
||||
if (y < 2000 || y > 2100 || mo < 1 || mo > 12 || d < 1 || d > 31) return null;
|
||||
return {
|
||||
dateIso: `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`,
|
||||
label: `${d}/${mo}/${String(y).slice(2)}`,
|
||||
};
|
||||
}
|
||||
|
||||
function splitNotesByDay(text: string) {
|
||||
const clean = text.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
|
||||
const dayRe =
|
||||
/^(?:#{1,4}\s*)?(\d{1,2})[\/.\-](\d{1,2})[\/.\-](\d{2,4})\s*$/;
|
||||
const lines = clean.split('\n');
|
||||
const hits: { idx: number; label: string; dateIso: string }[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const m = lines[i].trim().match(dayRe);
|
||||
if (!m) continue;
|
||||
const parsed = parseDayLabel(`${m[1]}/${m[2]}/${m[3]}`);
|
||||
if (!parsed) continue;
|
||||
hits.push({ idx: i, ...parsed });
|
||||
}
|
||||
if (!hits.length) return [] as { dateIso: string; label: string; body: string }[];
|
||||
const sections: { dateIso: string; label: string; body: string }[] = [];
|
||||
for (let h = 0; h < hits.length; h++) {
|
||||
const start = hits[h].idx + 1;
|
||||
const end = h + 1 < hits.length ? hits[h + 1].idx : lines.length;
|
||||
const body = lines
|
||||
.slice(start, end)
|
||||
.join('\n')
|
||||
.replace(/^\n+/, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
if (!body) continue;
|
||||
sections.push({ dateIso: hits[h].dateIso, label: hits[h].label, body });
|
||||
}
|
||||
sections.sort((a, b) => b.dateIso.localeCompare(a.dateIso));
|
||||
return sections;
|
||||
}
|
||||
|
||||
async function exportViaDownload(fileId: string, mime: string) {
|
||||
const token = await getValidAccessToken();
|
||||
const url = `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}/export?mimeType=${encodeURIComponent(mime)}`;
|
||||
const r = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!r.ok) throw new Error(`${mime} ${r.status}: ${(await r.text()).slice(0, 200)}`);
|
||||
return await r.text();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let plain = '';
|
||||
try {
|
||||
plain = await exportGoogleDocPlain(FILE);
|
||||
console.log('plain via helper', plain.length);
|
||||
} catch (e) {
|
||||
console.log('plain helper fail', e instanceof Error ? e.message : e);
|
||||
}
|
||||
if (!plain) {
|
||||
try {
|
||||
plain = await exportViaDownload(FILE, 'text/plain');
|
||||
console.log('plain via download', plain.length);
|
||||
} catch (e) {
|
||||
console.log('plain download fail', e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const html = await exportGoogleDocHtml(FILE);
|
||||
console.log('html ok', html.length);
|
||||
} catch (e) {
|
||||
console.log('html fail', e instanceof Error ? e.message : e);
|
||||
}
|
||||
|
||||
if (!plain) throw new Error('no plain export');
|
||||
console.log('--- sample ---\n', plain.slice(0, 2000));
|
||||
const secs = splitNotesByDay(plain);
|
||||
console.log('sections', secs.length, secs.slice(0, 8).map((s) => s.label));
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const rows = secs.slice(0, 120).map((sec) => {
|
||||
const firstLine =
|
||||
sec.body
|
||||
.split('\n')
|
||||
.map((l) => l.replace(/^[\s*•\-]+/, '').trim())
|
||||
.find((l) => l.length > 8) || '';
|
||||
return {
|
||||
tenant_id: BPS,
|
||||
source: 'superhuman' as const,
|
||||
entry_type: 'meeting' as const,
|
||||
external_id: `gdoc:${FILE}:${sec.dateIso}`,
|
||||
title: `Seguimiento · ${sec.label}`,
|
||||
summary: firstLine.slice(0, 220) || null,
|
||||
body_md: `<pre>${sec.body
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')}</pre>`,
|
||||
browser_link: `https://docs.google.com/document/d/${FILE}/edit`,
|
||||
metrics: {},
|
||||
occurred_at: `${sec.dateIso}T12:00:00.000Z`,
|
||||
synced_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
});
|
||||
|
||||
const n = await upsertSeguimientoRows(rows);
|
||||
await patchSuperhumanIntegration(BPS, {
|
||||
status: 'connected',
|
||||
external_account_id: DOC,
|
||||
label: '01.BLUE PARROT SCHOOL',
|
||||
last_sync_at: now,
|
||||
error_message: null,
|
||||
meta: {
|
||||
doc_name: '01.BLUE PARROT SCHOOL',
|
||||
gdoc_id: FILE,
|
||||
gdoc_days: n,
|
||||
synced_at: now,
|
||||
},
|
||||
});
|
||||
console.log('upserted day notes', n);
|
||||
}
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
59
scripts/sync_bps_seguimiento.ts
Normal file
59
scripts/sync_bps_seguimiento.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
config({ path: resolve(process.cwd(), '.env.local') });
|
||||
|
||||
import {
|
||||
buildSeguimientoRowsForDoc,
|
||||
upsertSeguimientoRows,
|
||||
patchSuperhumanIntegration,
|
||||
findSeguimientoGoogleDoc,
|
||||
} from '../src/lib/superhuman-docs';
|
||||
|
||||
const BPS = '8bec977a-6032-4759-aa7f-b5042081f450';
|
||||
const DOC = '7TGywaNiUY';
|
||||
|
||||
async function main() {
|
||||
const hit = await findSeguimientoGoogleDoc([
|
||||
'Seguimiento BPS',
|
||||
'Seguimiento Semanal BPS',
|
||||
'Seguimiento Blue Parrot School',
|
||||
'Seguimiento Semanal Blue Parrot School',
|
||||
]);
|
||||
console.log('drive hit', hit);
|
||||
|
||||
const rows = await buildSeguimientoRowsForDoc({
|
||||
tenantId: BPS,
|
||||
tenantName: 'BPS',
|
||||
docId: DOC,
|
||||
maxEntries: 120,
|
||||
exportBodies: true,
|
||||
});
|
||||
const gdoc = rows.filter((r) => r.external_id.startsWith('gdoc:'));
|
||||
console.log('total', rows.length, 'gdoc days', gdoc.length);
|
||||
console.log(
|
||||
gdoc.slice(0, 5).map((r) => ({
|
||||
title: r.title,
|
||||
day: r.occurred_at,
|
||||
len: (r.body_md || '').length,
|
||||
}))
|
||||
);
|
||||
const n = await upsertSeguimientoRows(rows);
|
||||
await patchSuperhumanIntegration(BPS, {
|
||||
status: 'connected',
|
||||
external_account_id: DOC,
|
||||
label: '01.BLUE PARROT SCHOOL',
|
||||
last_sync_at: new Date().toISOString(),
|
||||
error_message: null,
|
||||
meta: {
|
||||
doc_name: '01.BLUE PARROT SCHOOL',
|
||||
entries: n,
|
||||
gdoc_days: gdoc.length,
|
||||
synced_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
console.log('upserted', n);
|
||||
}
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
343
scripts/sync_clientify_tenant.py
Normal file
343
scripts/sync_clientify_tenant.py
Normal file
@@ -0,0 +1,343 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sync Clientify contacts → leads and deals → deals for one tenant.
|
||||
|
||||
Requires in .env.local:
|
||||
CLIENTIFY_API_KEY (or CLIENTIFY_API_KEY_<SLUG>)
|
||||
NEXT_PUBLIC_SUPABASE_URL
|
||||
SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
Usage:
|
||||
python3 scripts/sync_clientify_tenant.py --tenant-id a7a6eb49-... --label agua-miia
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CFY = "https://api.clientify.com/v1"
|
||||
|
||||
# Clientify deal status_desc → warehouse stage
|
||||
STAGE_MAP = {
|
||||
"won": "won",
|
||||
"lost": "lost",
|
||||
"open": "open",
|
||||
}
|
||||
|
||||
|
||||
def load_env() -> None:
|
||||
for line in (ROOT / ".env.local").read_text().splitlines():
|
||||
if "=" in line and not line.startswith("#"):
|
||||
k, v = line.split("=", 1)
|
||||
k = k.strip()
|
||||
if k and k not in os.environ:
|
||||
os.environ[k] = v.strip().strip('"').strip("'")
|
||||
|
||||
|
||||
def env_key(label: str) -> str:
|
||||
slug = label.upper().replace("-", "_").replace(" ", "_")
|
||||
return (
|
||||
os.environ.get(f"CLIENTIFY_API_KEY_{slug}")
|
||||
or os.environ.get("CLIENTIFY_API_KEY")
|
||||
or ""
|
||||
)
|
||||
|
||||
|
||||
def sb_headers() -> dict[str, str]:
|
||||
key = os.environ["SUPABASE_SERVICE_ROLE_KEY"]
|
||||
return {
|
||||
"apikey": key,
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def sb(method: str, path: str, body: Any = None, prefer: str = "return=minimal"):
|
||||
url = os.environ["NEXT_PUBLIC_SUPABASE_URL"].rstrip("/") + path
|
||||
headers = dict(sb_headers())
|
||||
if prefer:
|
||||
headers["Prefer"] = prefer
|
||||
data = None if body is None else json.dumps(body).encode()
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as r:
|
||||
raw = r.read().decode()
|
||||
return r.status, (json.loads(raw) if raw.strip() else None)
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode()[:500]
|
||||
|
||||
|
||||
def cfy_get(token: str, path: str) -> dict:
|
||||
req = urllib.request.Request(
|
||||
CFY + path,
|
||||
headers={"Authorization": f"Token {token}", "Accept": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
def cfy_paginate(token: str, path: str, page_size: int = 100) -> list[dict]:
|
||||
out: list[dict] = []
|
||||
# path may already have query
|
||||
sep = "&" if "?" in path else "?"
|
||||
url_path = f"{path}{sep}page_size={page_size}"
|
||||
page = 1
|
||||
while True:
|
||||
q = f"{url_path}&page={page}" if "page=" not in url_path else url_path
|
||||
# rebuild clean
|
||||
base, _, query = path.partition("?")
|
||||
params = urllib.parse.parse_qs(query)
|
||||
params["page_size"] = [str(page_size)]
|
||||
params["page"] = [str(page)]
|
||||
q = base + "?" + urllib.parse.urlencode({k: v[0] for k, v in params.items()})
|
||||
j = cfy_get(token, q)
|
||||
batch = j.get("results") or []
|
||||
out.extend(batch)
|
||||
print(f" {path} page={page} +{len(batch)} total={len(out)}/{j.get('count')}", flush=True)
|
||||
if not j.get("next") or not batch:
|
||||
break
|
||||
page += 1
|
||||
return out
|
||||
|
||||
|
||||
def first_email(c: dict) -> str | None:
|
||||
emails = c.get("emails") or []
|
||||
if isinstance(emails, list) and emails:
|
||||
e = emails[0]
|
||||
if isinstance(e, dict):
|
||||
return (e.get("email") or e.get("value") or "").strip() or None
|
||||
return str(e).strip() or None
|
||||
return None
|
||||
|
||||
|
||||
def first_phone(c: dict) -> str | None:
|
||||
phones = c.get("phones") or []
|
||||
if isinstance(phones, list) and phones:
|
||||
p = phones[0]
|
||||
if isinstance(p, dict):
|
||||
return (p.get("phone") or p.get("value") or "").strip() or None
|
||||
return str(p).strip() or None
|
||||
return None
|
||||
|
||||
|
||||
def contact_id_from_url(url: str | None) -> str | None:
|
||||
if not url:
|
||||
return None
|
||||
# https://api.clientify.com/v1/contacts/12345/
|
||||
parts = url.rstrip("/").split("/")
|
||||
return parts[-1] if parts else None
|
||||
|
||||
|
||||
def map_stage(deal: dict) -> str:
|
||||
desc = (deal.get("status_desc") or "").strip().lower()
|
||||
if desc in STAGE_MAP:
|
||||
return STAGE_MAP[desc]
|
||||
st = deal.get("status")
|
||||
# Observed: 1 open-ish, 3 won, 4 lost (API filter unreliable)
|
||||
if st == 3 or desc == "won":
|
||||
return "won"
|
||||
if st == 4 or desc == "lost":
|
||||
return "lost"
|
||||
return "open"
|
||||
|
||||
|
||||
def parse_date(s: str | None) -> str | None:
|
||||
if not s:
|
||||
return None
|
||||
# date-only or iso
|
||||
try:
|
||||
if "T" in s:
|
||||
return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone(timezone.utc).isoformat()
|
||||
return datetime.fromisoformat(s + "T12:00:00+00:00").astimezone(timezone.utc).isoformat()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
load_env()
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--tenant-id", required=True)
|
||||
ap.add_argument("--label", default="tenant")
|
||||
ap.add_argument("--contacts-only", action="store_true")
|
||||
ap.add_argument("--deals-only", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
token = env_key(args.label)
|
||||
if not token:
|
||||
print("missing CLIENTIFY_API_KEY", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
tid = args.tenant_id
|
||||
print(f"Sync Clientify → warehouse tenant={tid} label={args.label}", flush=True)
|
||||
|
||||
do_contacts = not args.deals_only
|
||||
do_deals = not args.contacts_only
|
||||
|
||||
contact_to_lead: dict[str, str] = {}
|
||||
|
||||
if do_contacts:
|
||||
print("Fetching contacts…", flush=True)
|
||||
contacts = cfy_paginate(token, "/contacts/")
|
||||
# wipe previous clientify leads for tenant
|
||||
st, _ = sb(
|
||||
"DELETE",
|
||||
f"/rest/v1/leads?tenant_id=eq.{tid}&crm=eq.clientify",
|
||||
)
|
||||
print(f"DELETE leads → {st}", flush=True)
|
||||
|
||||
rows = []
|
||||
for c in contacts:
|
||||
cid = str(c["id"])
|
||||
lid = str(uuid.uuid5(uuid.NAMESPACE_URL, f"clientify-lead:{tid}:{cid}"))
|
||||
contact_to_lead[cid] = lid
|
||||
source = (
|
||||
(c.get("contact_source") or c.get("channel") or c.get("medium") or "clientify")
|
||||
)
|
||||
if isinstance(source, str):
|
||||
source = source.strip().lower()[:80] or "clientify"
|
||||
else:
|
||||
source = "clientify"
|
||||
status = (c.get("status") or "new")
|
||||
if isinstance(status, str):
|
||||
status = status.strip().lower()[:40] or "new"
|
||||
else:
|
||||
status = "new"
|
||||
rows.append(
|
||||
{
|
||||
"id": lid,
|
||||
"tenant_id": tid,
|
||||
"first_name": (
|
||||
" ".join(
|
||||
x
|
||||
for x in [(c.get("first_name") or "").strip(), (c.get("last_name") or "").strip()]
|
||||
if x
|
||||
)
|
||||
or None
|
||||
),
|
||||
"email": first_email(c),
|
||||
"phone": first_phone(c),
|
||||
"source": source,
|
||||
"medium": (c.get("medium") or None),
|
||||
"crm": "clientify",
|
||||
"crm_contact_id": cid,
|
||||
"status": status,
|
||||
"created_at": parse_date(c.get("created"))
|
||||
or datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
ok = 0
|
||||
for i in range(0, len(rows), 200):
|
||||
chunk = rows[i : i + 200]
|
||||
st, err = sb("POST", "/rest/v1/leads", chunk)
|
||||
if st not in (200, 201):
|
||||
print("FAIL leads", st, err, flush=True)
|
||||
return 1
|
||||
ok += len(chunk)
|
||||
print(f" INSERT leads {ok}/{len(rows)}", flush=True)
|
||||
print(f"OK contacts→leads {ok}", flush=True)
|
||||
|
||||
if do_deals:
|
||||
if not contact_to_lead and do_contacts is False:
|
||||
# load existing map
|
||||
st, data = sb(
|
||||
"GET",
|
||||
f"/rest/v1/leads?tenant_id=eq.{tid}&crm=eq.clientify&select=id,crm_contact_id&limit=20000",
|
||||
prefer="",
|
||||
)
|
||||
if st == 200 and isinstance(data, list):
|
||||
for r in data:
|
||||
if r.get("crm_contact_id"):
|
||||
contact_to_lead[str(r["crm_contact_id"])] = r["id"]
|
||||
|
||||
print("Fetching deals…", flush=True)
|
||||
deals = cfy_paginate(token, "/deals/")
|
||||
st, _ = sb(
|
||||
"DELETE",
|
||||
f"/rest/v1/deals?tenant_id=eq.{tid}&crm_deal_id=like.clientify:*",
|
||||
)
|
||||
print(f"DELETE deals → {st}", flush=True)
|
||||
|
||||
rows = []
|
||||
by_stage = {"won": 0, "lost": 0, "open": 0}
|
||||
for d in deals:
|
||||
did = str(d["id"])
|
||||
stage = map_stage(d)
|
||||
by_stage[stage] = by_stage.get(stage, 0) + 1
|
||||
cid = contact_id_from_url(d.get("contact"))
|
||||
lead_id = contact_to_lead.get(cid) if cid else None
|
||||
amount = float(d.get("amount") or d.get("amount_user") or 0)
|
||||
closed = parse_date(d.get("actual_closed_date"))
|
||||
pipe_url = str(d.get("pipeline") or "")
|
||||
pipe_id = ""
|
||||
if "/pipelines/" in pipe_url:
|
||||
pipe_id = pipe_url.rstrip("/").split("/")[-1]
|
||||
raw_src = (
|
||||
d.get("deal_source")
|
||||
or d.get("contact_source")
|
||||
or ""
|
||||
)
|
||||
if isinstance(raw_src, str):
|
||||
raw_src = raw_src.strip()[:80]
|
||||
else:
|
||||
raw_src = ""
|
||||
tags = d.get("tags") or []
|
||||
tag_blob = " ".join(str(t) for t in tags).lower() if isinstance(tags, list) else ""
|
||||
name = (d.get("name") or "")[:200]
|
||||
if not raw_src:
|
||||
if any(x in tag_blob for x in ("facebook", "instagram", "meta", " fb ")):
|
||||
raw_src = "meta"
|
||||
elif "google" in tag_blob:
|
||||
raw_src = "google"
|
||||
elif "woocommerce" in tag_blob or name.lower().startswith("order "):
|
||||
raw_src = "ecommerce"
|
||||
row = {
|
||||
"id": str(uuid.uuid5(uuid.NAMESPACE_URL, f"clientify-deal:{tid}:{did}")),
|
||||
"tenant_id": tid,
|
||||
"lead_id": lead_id,
|
||||
"crm_deal_id": f"clientify:{did}",
|
||||
"stage": stage,
|
||||
"amount": amount,
|
||||
"won_at": closed if stage == "won" else None,
|
||||
"lost_at": closed if stage == "lost" else None,
|
||||
"pipeline_id": pipe_id or None,
|
||||
"pipeline_name": (d.get("pipeline_desc") or None),
|
||||
"source": raw_src.lower() if raw_src else None,
|
||||
"name": name or None,
|
||||
}
|
||||
rows.append(row)
|
||||
|
||||
ok = 0
|
||||
for i in range(0, len(rows), 200):
|
||||
chunk = rows[i : i + 200]
|
||||
st, err = sb("POST", "/rest/v1/deals", chunk)
|
||||
if st not in (200, 201):
|
||||
print("FAIL deals", st, err, flush=True)
|
||||
return 1
|
||||
ok += len(chunk)
|
||||
print(f" INSERT deals {ok}/{len(rows)}", flush=True)
|
||||
won_amt = sum(r["amount"] for r in rows if r["stage"] == "won")
|
||||
print(f"OK deals {ok} by_stage={by_stage} won_amount={won_amt:.2f}", flush=True)
|
||||
|
||||
# touch integration
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
sb(
|
||||
"PATCH",
|
||||
f"/rest/v1/integrations?tenant_id=eq.{tid}&provider=eq.clientify",
|
||||
{"status": "connected", "last_sync_at": now, "error_message": None},
|
||||
)
|
||||
print("DONE", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
5
sql/ad_daily_leads_numeric.sql
Normal file
5
sql/ad_daily_leads_numeric.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- Store fractional Google Ads conversions so period totals match Ads Manager
|
||||
-- (Math.round of the exact sum) for any date range.
|
||||
ALTER TABLE public.ad_daily
|
||||
ALTER COLUMN platform_leads TYPE numeric(14, 6)
|
||||
USING platform_leads::numeric(14, 6);
|
||||
18
sql/ad_daily_link_clicks.sql
Normal file
18
sql/ad_daily_link_clicks.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- Meta: clics en el enlace (Ads Manager) aparte de clicks = clics todos.
|
||||
ALTER TABLE public.ad_daily
|
||||
ADD COLUMN IF NOT EXISTS link_clicks numeric(14, 6) NOT NULL DEFAULT 0;
|
||||
|
||||
-- Backfill from legacy Meta marker stored in ad_name (`__lc:N`).
|
||||
UPDATE public.ad_daily
|
||||
SET link_clicks = (regexp_match(ad_name, '^__lc:([0-9.]+)$'))[1]::numeric
|
||||
WHERE platform = 'meta'
|
||||
AND ad_name ~ '^__lc:'
|
||||
AND (link_clicks IS NULL OR link_clicks = 0);
|
||||
|
||||
-- Google: link clicks ≈ all clicks at campaign level.
|
||||
UPDATE public.ad_daily
|
||||
SET link_clicks = clicks
|
||||
WHERE platform = 'google'
|
||||
AND (link_clicks IS NULL OR link_clicks = 0)
|
||||
AND clicks IS NOT NULL
|
||||
AND clicks > 0;
|
||||
16
sql/agency_control.sql
Normal file
16
sql/agency_control.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Agency control: follow/archive clients + enable/disable channels
|
||||
-- Idempotent.
|
||||
|
||||
alter table public.tenants
|
||||
add column if not exists agency_followed boolean not null default true;
|
||||
|
||||
alter table public.tenants
|
||||
add column if not exists agency_notes text;
|
||||
|
||||
alter table public.integrations
|
||||
add column if not exists enabled boolean not null default true;
|
||||
|
||||
comment on column public.tenants.agency_followed is
|
||||
'Agency portfolio: false = archived / not actively managed in Centro de Mando';
|
||||
comment on column public.integrations.enabled is
|
||||
'Agency wants this channel for the client (false = deliberately off)';
|
||||
116
sql/agency_portfolio.sql
Normal file
116
sql/agency_portfolio.sql
Normal file
@@ -0,0 +1,116 @@
|
||||
-- Agency portfolio: integrations + alerts per tenant
|
||||
-- Idempotent. Run as postgres / service role.
|
||||
|
||||
create table if not exists public.integrations (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
provider text not null
|
||||
check (provider in (
|
||||
'meta_ads',
|
||||
'google_ads',
|
||||
'ga4',
|
||||
'search_console',
|
||||
'tag_manager',
|
||||
'clientify',
|
||||
'klaviyo',
|
||||
'mailchimp'
|
||||
)),
|
||||
status text not null default 'disconnected'
|
||||
check (status in ('disconnected', 'pending', 'connected', 'error')),
|
||||
external_account_id text,
|
||||
label text,
|
||||
last_sync_at timestamptz,
|
||||
error_message text,
|
||||
meta jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, provider)
|
||||
);
|
||||
|
||||
create index if not exists integrations_tenant_idx
|
||||
on public.integrations (tenant_id, provider);
|
||||
|
||||
alter table public.integrations enable row level security;
|
||||
|
||||
drop policy if exists integrations_tenant_select on public.integrations;
|
||||
drop policy if exists integrations_tenant_write on public.integrations;
|
||||
|
||||
-- Clients see only their tenant; agency uses service role for cross-tenant.
|
||||
create policy integrations_tenant_select on public.integrations
|
||||
for select using (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
create policy integrations_tenant_write on public.integrations
|
||||
for all using (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
)
|
||||
with check (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
create table if not exists public.agency_alerts (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid references public.tenants(id) on delete cascade,
|
||||
severity text not null default 'info'
|
||||
check (severity in ('info', 'warning', 'critical')),
|
||||
source text not null default 'system',
|
||||
title text not null,
|
||||
body text,
|
||||
href text,
|
||||
read_at timestamptz,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists agency_alerts_unread_idx
|
||||
on public.agency_alerts (created_at desc)
|
||||
where read_at is null;
|
||||
|
||||
alter table public.agency_alerts enable row level security;
|
||||
|
||||
drop policy if exists agency_alerts_tenant_select on public.agency_alerts;
|
||||
|
||||
create policy agency_alerts_tenant_select on public.agency_alerts
|
||||
for select using (
|
||||
tenant_id is null
|
||||
or tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
-- Seed disconnected integration slots for every existing tenant
|
||||
insert into public.integrations (tenant_id, provider, status, label)
|
||||
select t.id, p.provider, 'disconnected', p.label
|
||||
from public.tenants t
|
||||
cross join (
|
||||
values
|
||||
('meta_ads', 'Meta Ads'),
|
||||
('google_ads', 'Google Ads (MCC)'),
|
||||
('ga4', 'Google Analytics 4'),
|
||||
('search_console', 'Search Console'),
|
||||
('tag_manager', 'Tag Manager'),
|
||||
('clientify', 'Clientify'),
|
||||
('klaviyo', 'Klaviyo'),
|
||||
('mailchimp', 'Mailchimp')
|
||||
) as p(provider, label)
|
||||
on conflict (tenant_id, provider) do nothing;
|
||||
|
||||
-- Bootstrap alert so the UI isn't empty on day one
|
||||
insert into public.agency_alerts (tenant_id, severity, source, title, body, href)
|
||||
select null, 'info', 'system',
|
||||
'Portafolio agencia activo',
|
||||
'Selector de cliente + fichas de integración listas. Conecta Meta/MCC/GA4 por tenant.',
|
||||
'/dashboard/agencia'
|
||||
where not exists (
|
||||
select 1 from public.agency_alerts where title = 'Portafolio agencia activo'
|
||||
);
|
||||
38
sql/agent_runs_rls.sql
Normal file
38
sql/agent_runs_rls.sql
Normal file
@@ -0,0 +1,38 @@
|
||||
-- Idempotent: enable RLS + tenant policies on agent_runs
|
||||
-- Safe to re-run.
|
||||
|
||||
alter table public.agent_runs enable row level security;
|
||||
|
||||
drop policy if exists agent_runs_tenant_select on public.agent_runs;
|
||||
drop policy if exists agent_runs_tenant_insert on public.agent_runs;
|
||||
drop policy if exists agent_runs_tenant_update on public.agent_runs;
|
||||
|
||||
create policy agent_runs_tenant_select on public.agent_runs
|
||||
for select using (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
create policy agent_runs_tenant_insert on public.agent_runs
|
||||
for insert with check (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
create policy agent_runs_tenant_update on public.agent_runs
|
||||
for update using (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
-- Verify
|
||||
select c.relname, c.relrowsecurity
|
||||
from pg_class c
|
||||
join pg_namespace n on n.oid = c.relnamespace
|
||||
where n.nspname = 'public' and c.relname = 'agent_runs';
|
||||
64
sql/asset_files.sql
Normal file
64
sql/asset_files.sql
Normal file
@@ -0,0 +1,64 @@
|
||||
-- File assets library (validated uploads per tenant)
|
||||
-- Idempotent.
|
||||
|
||||
create table if not exists public.asset_files (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
title text not null,
|
||||
folder text not null default '',
|
||||
file_name text not null,
|
||||
mime_type text,
|
||||
bytes bigint,
|
||||
storage_path text not null,
|
||||
sha256 text,
|
||||
status text not null default 'approved'
|
||||
check (status in ('approved', 'archived')),
|
||||
created_by uuid references auth.users(id),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists asset_files_tenant_folder_idx
|
||||
on public.asset_files (tenant_id, folder, created_at desc);
|
||||
|
||||
alter table public.asset_files enable row level security;
|
||||
|
||||
drop policy if exists asset_files_tenant_select on public.asset_files;
|
||||
drop policy if exists asset_files_tenant_insert on public.asset_files;
|
||||
drop policy if exists asset_files_tenant_update on public.asset_files;
|
||||
drop policy if exists asset_files_tenant_delete on public.asset_files;
|
||||
|
||||
create policy asset_files_tenant_select on public.asset_files
|
||||
for select using (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
create policy asset_files_tenant_insert on public.asset_files
|
||||
for insert with check (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
create policy asset_files_tenant_update on public.asset_files
|
||||
for update using (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
create policy asset_files_tenant_delete on public.asset_files
|
||||
for delete using (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
-- Private bucket (created via API if missing). Policies for storage.objects:
|
||||
-- path layout: {tenant_id}/{optional/folder/}{uuid}_{filename}
|
||||
31
sql/cms_integrations.sql
Normal file
31
sql/cms_integrations.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- CMS / ecommerce connection slots per tenant
|
||||
-- Idempotent.
|
||||
|
||||
alter table public.integrations drop constraint if exists integrations_provider_check;
|
||||
alter table public.integrations
|
||||
add constraint integrations_provider_check
|
||||
check (provider in (
|
||||
'meta_ads',
|
||||
'google_ads',
|
||||
'ga4',
|
||||
'search_console',
|
||||
'tag_manager',
|
||||
'clientify',
|
||||
'klaviyo',
|
||||
'mailchimp',
|
||||
'google_drive',
|
||||
'wordpress',
|
||||
'prestashop',
|
||||
'shopify'
|
||||
));
|
||||
|
||||
insert into public.integrations (tenant_id, provider, status, label)
|
||||
select t.id, p.provider, 'disconnected', p.label
|
||||
from public.tenants t
|
||||
cross join (
|
||||
values
|
||||
('wordpress', 'WordPress'),
|
||||
('prestashop', 'PrestaShop'),
|
||||
('shopify', 'Shopify')
|
||||
) as p(provider, label)
|
||||
on conflict (tenant_id, provider) do nothing;
|
||||
180
sql/content_collab.sql
Normal file
180
sql/content_collab.sql
Normal file
@@ -0,0 +1,180 @@
|
||||
-- Dual approval (agency + client) + threaded comments for calendar posts & assets
|
||||
-- Idempotent. Private portal only (no public publish).
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Calendar posts (normalized; private per tenant)
|
||||
-- ---------------------------------------------------------------------------
|
||||
create table if not exists public.calendar_posts (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
external_key text,
|
||||
month text,
|
||||
date date not null,
|
||||
time_slot text,
|
||||
platform text not null
|
||||
check (platform in (
|
||||
'instagram', 'facebook', 'linkedin', 'tiktok', 'youtube', 'x', 'other'
|
||||
)),
|
||||
format text,
|
||||
pillar text,
|
||||
title text not null,
|
||||
hook text,
|
||||
body text,
|
||||
cta text,
|
||||
hashtags text,
|
||||
objective_metric text,
|
||||
status text not null default 'draft'
|
||||
check (status in ('draft', 'in_review', 'scheduled', 'published', 'archived')),
|
||||
agency_approved_at timestamptz,
|
||||
agency_approved_by uuid references auth.users(id),
|
||||
client_approved_at timestamptz,
|
||||
client_approved_by uuid references auth.users(id),
|
||||
meta jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, external_key)
|
||||
);
|
||||
|
||||
create index if not exists calendar_posts_tenant_month_idx
|
||||
on public.calendar_posts (tenant_id, month, date);
|
||||
create index if not exists calendar_posts_tenant_platform_idx
|
||||
on public.calendar_posts (tenant_id, platform, date);
|
||||
|
||||
alter table public.calendar_posts enable row level security;
|
||||
|
||||
drop policy if exists calendar_posts_tenant_select on public.calendar_posts;
|
||||
drop policy if exists calendar_posts_tenant_write on public.calendar_posts;
|
||||
|
||||
create policy calendar_posts_tenant_select on public.calendar_posts
|
||||
for select using (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
create policy calendar_posts_tenant_write on public.calendar_posts
|
||||
for all using (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
)
|
||||
with check (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Shared comments (calendar_post | asset)
|
||||
-- ---------------------------------------------------------------------------
|
||||
create table if not exists public.content_comments (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
resource_type text not null check (resource_type in ('calendar_post', 'asset')),
|
||||
resource_id text not null,
|
||||
body text not null,
|
||||
author_id uuid references auth.users(id),
|
||||
author_email text,
|
||||
author_party text not null check (author_party in ('agency', 'client')),
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists content_comments_resource_idx
|
||||
on public.content_comments (tenant_id, resource_type, resource_id, created_at);
|
||||
|
||||
alter table public.content_comments enable row level security;
|
||||
|
||||
drop policy if exists content_comments_tenant_select on public.content_comments;
|
||||
drop policy if exists content_comments_tenant_insert on public.content_comments;
|
||||
|
||||
create policy content_comments_tenant_select on public.content_comments
|
||||
for select using (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
create policy content_comments_tenant_insert on public.content_comments
|
||||
for insert with check (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Dual approval events (calendar_post | asset)
|
||||
-- ---------------------------------------------------------------------------
|
||||
create table if not exists public.content_approvals (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
resource_type text not null check (resource_type in ('calendar_post', 'asset')),
|
||||
resource_id text not null,
|
||||
party text not null check (party in ('agency', 'client')),
|
||||
approved boolean not null default true,
|
||||
note text,
|
||||
actor_id uuid references auth.users(id),
|
||||
actor_email text,
|
||||
created_at timestamptz not null default now(),
|
||||
unique (tenant_id, resource_type, resource_id, party)
|
||||
);
|
||||
|
||||
alter table public.content_approvals enable row level security;
|
||||
|
||||
drop policy if exists content_approvals_tenant_select on public.content_approvals;
|
||||
drop policy if exists content_approvals_tenant_write on public.content_approvals;
|
||||
|
||||
create policy content_approvals_tenant_select on public.content_approvals
|
||||
for select using (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
create policy content_approvals_tenant_write on public.content_approvals
|
||||
for all using (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
)
|
||||
with check (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Assets: dual validation columns
|
||||
-- ---------------------------------------------------------------------------
|
||||
alter table public.asset_files
|
||||
add column if not exists review_status text not null default 'in_review';
|
||||
|
||||
alter table public.asset_files
|
||||
drop constraint if exists asset_files_review_status_check;
|
||||
|
||||
alter table public.asset_files
|
||||
add constraint asset_files_review_status_check
|
||||
check (review_status in ('draft', 'in_review', 'validated', 'archived'));
|
||||
|
||||
alter table public.asset_files
|
||||
add column if not exists agency_approved_at timestamptz;
|
||||
alter table public.asset_files
|
||||
add column if not exists agency_approved_by uuid references auth.users(id);
|
||||
alter table public.asset_files
|
||||
add column if not exists client_approved_at timestamptz;
|
||||
alter table public.asset_files
|
||||
add column if not exists client_approved_by uuid references auth.users(id);
|
||||
|
||||
comment on table public.calendar_posts is
|
||||
'Private RRSS calendar per tenant. Dual agency/client approval. No public publish.';
|
||||
comment on table public.content_comments is
|
||||
'Comments on calendar posts or assets (agency + client).';
|
||||
comment on table public.content_approvals is
|
||||
'Dual-check approvals: agency and client must both approve for full OK.';
|
||||
97
sql/creative_pipeline.sql
Normal file
97
sql/creative_pipeline.sql
Normal file
@@ -0,0 +1,97 @@
|
||||
-- Creative pipeline: producción interna de creatividades (Blue Parrot y resto
|
||||
-- de clientes) separada de `creatives` (que es el repositorio de anuncios
|
||||
-- Meta/Google ya aprobados). Aquí vive el flujo editorial completo:
|
||||
-- Propuesta -> En diseño -> Revisión interna -> Aprobado / Rechazado -> Publicado
|
||||
-- Visible también para el cliente en /dashboard/panel-cliente (RLS por tenant).
|
||||
-- Idempotente.
|
||||
|
||||
create table if not exists public.creative_pipeline (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
|
||||
-- referencia externa estable usada por los scripts de generación (ej. "oct-04")
|
||||
external_ref text,
|
||||
|
||||
title text not null,
|
||||
format text not null default 'post'
|
||||
check (format in ('post', 'story', 'reel', 'carrusel')),
|
||||
|
||||
-- mes/calendario al que pertenece la pieza (agrupación en el board)
|
||||
month text, -- 'YYYY-MM'
|
||||
|
||||
status text not null default 'propuesta'
|
||||
check (status in (
|
||||
'propuesta', 'en_diseno', 'revision_interna',
|
||||
'aprobado', 'rechazado', 'publicado'
|
||||
)),
|
||||
|
||||
-- portada (todas las piezas tienen una)
|
||||
cover_path text,
|
||||
cover_mime text,
|
||||
|
||||
-- piezas carrusel: array ordenado de storage paths de los slides interiores + CTA
|
||||
-- ej. ["tenant/.../slide-2.jpg", "tenant/.../slide-3.jpg", ...]
|
||||
slide_paths jsonb not null default '[]'::jsonb,
|
||||
|
||||
caption text,
|
||||
notes text,
|
||||
|
||||
created_by uuid references auth.users(id),
|
||||
approved_by uuid references auth.users(id),
|
||||
approved_at timestamptz,
|
||||
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create unique index if not exists creative_pipeline_tenant_ref_idx
|
||||
on public.creative_pipeline (tenant_id, external_ref)
|
||||
where external_ref is not null;
|
||||
|
||||
create index if not exists creative_pipeline_tenant_month_idx
|
||||
on public.creative_pipeline (tenant_id, month, status);
|
||||
|
||||
alter table public.creative_pipeline enable row level security;
|
||||
|
||||
-- Lectura: cliente ve las piezas de su propio tenant (incluye revisión interna,
|
||||
-- ya que este panel es también para el cliente, no solo para el equipo).
|
||||
create policy creative_pipeline_tenant_select on public.creative_pipeline
|
||||
for select using (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
-- Alta/edición de contenido vía API del equipo (service role) — bloqueado para
|
||||
-- clientes directos por RLS; el endpoint PATCH permite al cliente mover el
|
||||
-- estado a 'aprobado'/'rechazado' únicamente mediante el mismo filtro de tenant.
|
||||
create policy creative_pipeline_tenant_insert on public.creative_pipeline
|
||||
for insert with check (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
create policy creative_pipeline_tenant_update on public.creative_pipeline
|
||||
for update using (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
create policy creative_pipeline_tenant_delete on public.creative_pipeline
|
||||
for delete using (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
comment on table public.creative_pipeline is
|
||||
'Pipeline interno de producción de creatividades (RRSS), separado de creatives (ads). Mismo bucket tenant-assets, prefijo pipeline/.';
|
||||
|
||||
-- Storage: reutiliza el bucket privado `tenant-assets` (ver src/lib/assets.ts),
|
||||
-- path = {tenant_id}/pipeline/{id}/cover.jpg y {tenant_id}/pipeline/{id}/slide-N.jpg
|
||||
11
sql/creatives_delete_policy.sql
Normal file
11
sql/creatives_delete_policy.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Allow tenant-scoped delete on creatives (clients); agency uses service role.
|
||||
alter table public.creatives enable row level security;
|
||||
|
||||
drop policy if exists creatives_tenant_delete on public.creatives;
|
||||
create policy creatives_tenant_delete on public.creatives
|
||||
for delete using (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
89
sql/creatives_preview.sql
Normal file
89
sql/creatives_preview.sql
Normal file
@@ -0,0 +1,89 @@
|
||||
-- Preview fields for creatives pipeline (ads + RRSS placement mocks)
|
||||
-- Idempotent.
|
||||
|
||||
alter table public.creatives
|
||||
add column if not exists channel text;
|
||||
|
||||
alter table public.creatives
|
||||
add column if not exists placement text;
|
||||
|
||||
alter table public.creatives
|
||||
add column if not exists format text;
|
||||
|
||||
alter table public.creatives
|
||||
add column if not exists headline text;
|
||||
|
||||
alter table public.creatives
|
||||
add column if not exists primary_text text;
|
||||
|
||||
alter table public.creatives
|
||||
add column if not exists description text;
|
||||
|
||||
alter table public.creatives
|
||||
add column if not exists cta text;
|
||||
|
||||
alter table public.creatives
|
||||
add column if not exists media_url text;
|
||||
|
||||
alter table public.creatives
|
||||
add column if not exists brand_name text;
|
||||
|
||||
alter table public.creatives
|
||||
add column if not exists landing_url text;
|
||||
|
||||
alter table public.creatives
|
||||
add column if not exists meta jsonb not null default '{}'::jsonb;
|
||||
|
||||
-- Soft checks (nullable for legacy rows)
|
||||
do $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1 from pg_constraint where conname = 'creatives_channel_check'
|
||||
) then
|
||||
alter table public.creatives
|
||||
add constraint creatives_channel_check
|
||||
check (
|
||||
channel is null
|
||||
or channel in (
|
||||
'meta_ads',
|
||||
'google_ads',
|
||||
'instagram',
|
||||
'facebook',
|
||||
'linkedin',
|
||||
'tiktok',
|
||||
'stories',
|
||||
'other'
|
||||
)
|
||||
);
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
do $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1 from pg_constraint where conname = 'creatives_format_check'
|
||||
) then
|
||||
alter table public.creatives
|
||||
add constraint creatives_format_check
|
||||
check (
|
||||
format is null
|
||||
or format in (
|
||||
'image',
|
||||
'video',
|
||||
'carousel',
|
||||
'reel',
|
||||
'story',
|
||||
'rsa',
|
||||
'pmax',
|
||||
'other'
|
||||
)
|
||||
);
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
comment on column public.creatives.channel is
|
||||
'Surface: meta_ads | google_ads | instagram | facebook | linkedin | stories…';
|
||||
comment on column public.creatives.placement is
|
||||
'Placement mock: feed | stories | reels | search | display…';
|
||||
comment on column public.creatives.media_url is
|
||||
'Public or signed URL for creative media preview';
|
||||
35
sql/creatives_upload_pmax.sql
Normal file
35
sql/creatives_upload_pmax.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
-- Expand creatives.format for Google Performance Max asset ratios + upload fields
|
||||
-- Idempotent.
|
||||
|
||||
alter table public.creatives drop constraint if exists creatives_format_check;
|
||||
|
||||
alter table public.creatives
|
||||
add constraint creatives_format_check
|
||||
check (
|
||||
format is null
|
||||
or format in (
|
||||
'image',
|
||||
'video',
|
||||
'carousel',
|
||||
'reel',
|
||||
'story',
|
||||
'rsa',
|
||||
'pmax',
|
||||
'pmax_landscape',
|
||||
'pmax_square',
|
||||
'pmax_portrait',
|
||||
'pmax_vertical',
|
||||
'pmax_logo',
|
||||
'youtube',
|
||||
'other'
|
||||
)
|
||||
);
|
||||
|
||||
alter table public.creatives
|
||||
add column if not exists storage_path text;
|
||||
|
||||
alter table public.creatives
|
||||
add column if not exists mime_type text;
|
||||
|
||||
comment on column public.creatives.storage_path is
|
||||
'Path in tenant-assets bucket for uploaded creative media';
|
||||
4
sql/deals_pipeline.sql
Normal file
4
sql/deals_pipeline.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Pipeline on deals so KPI ventas can match Clientify board filters (e.g. Online).
|
||||
ALTER TABLE public.deals
|
||||
ADD COLUMN IF NOT EXISTS pipeline_id text,
|
||||
ADD COLUMN IF NOT EXISTS pipeline_name text;
|
||||
4
sql/deals_source.sql
Normal file
4
sql/deals_source.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Attribution source on deals (Clientify deal_source / contact / lead).
|
||||
ALTER TABLE public.deals
|
||||
ADD COLUMN IF NOT EXISTS source text,
|
||||
ADD COLUMN IF NOT EXISTS name text;
|
||||
42
sql/google_drive.sql
Normal file
42
sql/google_drive.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- Google Drive agency OAuth + per-tenant folder links
|
||||
-- Idempotent.
|
||||
|
||||
-- Agency-wide OAuth tokens (service role only; no client policies)
|
||||
create table if not exists public.agency_oauth (
|
||||
provider text primary key,
|
||||
account_email text,
|
||||
access_token text not null,
|
||||
refresh_token text,
|
||||
expires_at timestamptz,
|
||||
scopes text,
|
||||
meta jsonb not null default '{}'::jsonb,
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table public.agency_oauth enable row level security;
|
||||
|
||||
-- Widen integrations.provider check to include google_drive
|
||||
alter table public.integrations drop constraint if exists integrations_provider_check;
|
||||
alter table public.integrations
|
||||
add constraint integrations_provider_check
|
||||
check (provider in (
|
||||
'meta_ads',
|
||||
'google_ads',
|
||||
'ga4',
|
||||
'search_console',
|
||||
'tag_manager',
|
||||
'clientify',
|
||||
'klaviyo',
|
||||
'mailchimp',
|
||||
'google_drive'
|
||||
));
|
||||
|
||||
-- Seed google_drive slots for every tenant
|
||||
insert into public.integrations (tenant_id, provider, status, label)
|
||||
select t.id, 'google_drive', 'disconnected', 'Google Drive'
|
||||
from public.tenants t
|
||||
on conflict (tenant_id, provider) do nothing;
|
||||
|
||||
-- Optional Drive file id on portal assets (mirror)
|
||||
alter table public.asset_files
|
||||
add column if not exists drive_file_id text;
|
||||
22
sql/leads_status_timing.sql
Normal file
22
sql/leads_status_timing.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- CRM date correctness: preserve lead birth vs status change timing.
|
||||
-- Run in Supabase SQL editor (service role / dashboard).
|
||||
|
||||
alter table public.leads
|
||||
add column if not exists updated_at timestamptz;
|
||||
|
||||
alter table public.leads
|
||||
add column if not exists status_changed_at timestamptz;
|
||||
|
||||
comment on column public.leads.created_at is
|
||||
'When the contact entered Clientify / first seen (alta).';
|
||||
comment on column public.leads.updated_at is
|
||||
'Last webhook/sync touch.';
|
||||
comment on column public.leads.status_changed_at is
|
||||
'When status last changed (e.g. → cliente). Use this for “ventas en el periodo”.';
|
||||
|
||||
-- Backfill: unknown status timing → treat as created_at (best effort).
|
||||
update public.leads
|
||||
set
|
||||
updated_at = coalesce(updated_at, created_at, now()),
|
||||
status_changed_at = coalesce(status_changed_at, created_at, now())
|
||||
where updated_at is null or status_changed_at is null;
|
||||
75
sql/organic_geo.sql
Normal file
75
sql/organic_geo.sql
Normal file
@@ -0,0 +1,75 @@
|
||||
-- Organic SEO (GA4/GSC) + GEO (AI visibility) warehouse
|
||||
-- Idempotent.
|
||||
|
||||
create table if not exists public.organic_daily (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
day date not null,
|
||||
gsc_clicks numeric not null default 0,
|
||||
gsc_impressions numeric not null default 0,
|
||||
gsc_ctr numeric,
|
||||
gsc_avg_position numeric,
|
||||
ga4_sessions numeric not null default 0,
|
||||
ga4_users numeric not null default 0,
|
||||
ga4_organic_sessions numeric not null default 0,
|
||||
ga4_conversions numeric not null default 0,
|
||||
source text not null default 'manual'
|
||||
check (source in ('manual', 'sync', 'import')),
|
||||
meta jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
unique (tenant_id, day)
|
||||
);
|
||||
|
||||
create index if not exists organic_daily_tenant_day_idx
|
||||
on public.organic_daily (tenant_id, day desc);
|
||||
|
||||
create table if not exists public.geo_snapshots (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
label text not null default 'Medición GEO',
|
||||
period_start date,
|
||||
period_end date,
|
||||
-- Share of Model: % de primeras respuestas / menciones de la marca
|
||||
share_of_model numeric,
|
||||
-- Engines: { chatgpt, gemini, ai_overviews } visibility %
|
||||
engines jsonb not null default '{}'::jsonb,
|
||||
-- Official brand site citation share among sources
|
||||
official_citation_pct numeric,
|
||||
-- Sentiment -1..1 or 0..100 (store 0..100)
|
||||
sentiment_score numeric,
|
||||
-- Competitor share of model rows: [{ brand, share, rank }]
|
||||
competitors jsonb not null default '[]'::jsonb,
|
||||
-- Intent / category visibility: [{ category, visibility, rank }]
|
||||
categories jsonb not null default '[]'::jsonb,
|
||||
-- Top sources feeding AI answers
|
||||
top_sources jsonb not null default '[]'::jsonb,
|
||||
prompts_analyzed integer,
|
||||
responses_analyzed integer,
|
||||
study_url text,
|
||||
notes text,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists geo_snapshots_tenant_created_idx
|
||||
on public.geo_snapshots (tenant_id, created_at desc);
|
||||
|
||||
alter table public.organic_daily enable row level security;
|
||||
alter table public.geo_snapshots enable row level security;
|
||||
|
||||
drop policy if exists organic_daily_tenant_select on public.organic_daily;
|
||||
create policy organic_daily_tenant_select on public.organic_daily
|
||||
for select using (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists geo_snapshots_tenant_select on public.geo_snapshots;
|
||||
create policy geo_snapshots_tenant_select on public.geo_snapshots
|
||||
for select using (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
87
sql/seguimiento.sql
Normal file
87
sql/seguimiento.sql
Normal file
@@ -0,0 +1,87 @@
|
||||
-- Seguimiento: Superhuman Docs timeline + integration slot
|
||||
-- Idempotent. Never stores passwords.
|
||||
|
||||
alter table public.integrations drop constraint if exists integrations_provider_check;
|
||||
alter table public.integrations
|
||||
add constraint integrations_provider_check
|
||||
check (provider in (
|
||||
'meta_ads',
|
||||
'google_ads',
|
||||
'ga4',
|
||||
'search_console',
|
||||
'tag_manager',
|
||||
'clientify',
|
||||
'klaviyo',
|
||||
'mailchimp',
|
||||
'google_drive',
|
||||
'wordpress',
|
||||
'prestashop',
|
||||
'shopify',
|
||||
'instagram',
|
||||
'facebook',
|
||||
'tiktok',
|
||||
'linkedin',
|
||||
'superhuman_docs'
|
||||
));
|
||||
|
||||
insert into public.integrations (tenant_id, provider, status, label)
|
||||
select t.id, 'superhuman_docs', 'disconnected', 'Superhuman Docs'
|
||||
from public.tenants t
|
||||
on conflict (tenant_id, provider) do nothing;
|
||||
|
||||
create table if not exists public.seguimiento_entries (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
source text not null default 'superhuman'
|
||||
check (source in ('superhuman', 'manual')),
|
||||
entry_type text not null default 'topic'
|
||||
check (entry_type in ('meeting', 'topic', 'kpis', 'note')),
|
||||
external_id text not null,
|
||||
title text not null,
|
||||
summary text,
|
||||
body_md text,
|
||||
browser_link text,
|
||||
metrics jsonb not null default '{}'::jsonb,
|
||||
occurred_at timestamptz,
|
||||
synced_at timestamptz not null default now(),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, source, external_id)
|
||||
);
|
||||
|
||||
create index if not exists seguimiento_entries_tenant_occurred_idx
|
||||
on public.seguimiento_entries (tenant_id, occurred_at desc nulls last);
|
||||
|
||||
create index if not exists seguimiento_entries_tenant_synced_idx
|
||||
on public.seguimiento_entries (tenant_id, synced_at desc);
|
||||
|
||||
alter table public.seguimiento_entries enable row level security;
|
||||
|
||||
drop policy if exists seguimiento_entries_tenant_select on public.seguimiento_entries;
|
||||
drop policy if exists seguimiento_entries_tenant_write on public.seguimiento_entries;
|
||||
|
||||
create policy seguimiento_entries_tenant_select on public.seguimiento_entries
|
||||
for select using (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
-- Clients read-only via RLS; agency writes with service role.
|
||||
create policy seguimiento_entries_tenant_write on public.seguimiento_entries
|
||||
for all using (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
)
|
||||
with check (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
comment on table public.seguimiento_entries is
|
||||
'Client-facing seguimiento timeline (Superhuman Docs). No passwords.';
|
||||
31
sql/seo_audits.sql
Normal file
31
sql/seo_audits.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- SEO site audit history (technical checks + score)
|
||||
-- Idempotent.
|
||||
|
||||
create table if not exists public.seo_audits (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
site_url text not null,
|
||||
score numeric not null default 0,
|
||||
grade text not null default 'F',
|
||||
warning_count integer not null default 0,
|
||||
critical_count integer not null default 0,
|
||||
checks jsonb not null default '[]'::jsonb,
|
||||
summary text,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists seo_audits_tenant_created_idx
|
||||
on public.seo_audits (tenant_id, created_at desc);
|
||||
|
||||
alter table public.seo_audits enable row level security;
|
||||
|
||||
drop policy if exists seo_audits_tenant_select on public.seo_audits;
|
||||
create policy seo_audits_tenant_select on public.seo_audits
|
||||
for select using (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
-- Agency writes via service role (bypasses RLS).
|
||||
36
sql/social_integrations.sql
Normal file
36
sql/social_integrations.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- Organic social connection slots per tenant
|
||||
-- Idempotent. (Distinct from meta_ads / paid.)
|
||||
|
||||
alter table public.integrations drop constraint if exists integrations_provider_check;
|
||||
alter table public.integrations
|
||||
add constraint integrations_provider_check
|
||||
check (provider in (
|
||||
'meta_ads',
|
||||
'google_ads',
|
||||
'ga4',
|
||||
'search_console',
|
||||
'tag_manager',
|
||||
'clientify',
|
||||
'klaviyo',
|
||||
'mailchimp',
|
||||
'google_drive',
|
||||
'wordpress',
|
||||
'prestashop',
|
||||
'shopify',
|
||||
'instagram',
|
||||
'facebook',
|
||||
'tiktok',
|
||||
'linkedin'
|
||||
));
|
||||
|
||||
insert into public.integrations (tenant_id, provider, status, label)
|
||||
select t.id, p.provider, 'disconnected', p.label
|
||||
from public.tenants t
|
||||
cross join (
|
||||
values
|
||||
('instagram', 'Instagram'),
|
||||
('facebook', 'Facebook Page'),
|
||||
('tiktok', 'TikTok'),
|
||||
('linkedin', 'LinkedIn')
|
||||
) as p(provider, label)
|
||||
on conflict (tenant_id, provider) do nothing;
|
||||
13
sql/targets_budget.sql
Normal file
13
sql/targets_budget.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Monthly objectives: budget (ppto) + consecution targets
|
||||
-- Idempotent.
|
||||
|
||||
alter table public.targets
|
||||
add column if not exists spend_target numeric;
|
||||
|
||||
alter table public.targets
|
||||
add column if not exists spend_actual numeric;
|
||||
|
||||
comment on column public.targets.spend_target is
|
||||
'Presupuesto ads objetivo del mes (ppto)';
|
||||
comment on column public.targets.spend_actual is
|
||||
'Gasto ads real del mes (opcional; si null se calcula de ad_daily)';
|
||||
13
sql/tenant_ficha.sql
Normal file
13
sql/tenant_ficha.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Per-tenant ficha: logo + dashboard module settings
|
||||
-- Idempotent.
|
||||
|
||||
alter table public.tenants
|
||||
add column if not exists logo_url text;
|
||||
|
||||
alter table public.tenants
|
||||
add column if not exists settings jsonb not null default '{}'::jsonb;
|
||||
|
||||
comment on column public.tenants.logo_url is
|
||||
'Client logo for portal shell (URL or storage path under tenant-assets).';
|
||||
comment on column public.tenants.settings is
|
||||
'JSON: { modules: { kpis, crm, seguimiento, estrategia, calendario, ad-tracker, activos, creatives, asistente }, branding: {} }';
|
||||
214
src/app/api/ad-tracker/creatives/route.ts
Normal file
214
src/app/api/ad-tracker/creatives/route.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
|
||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||
import { AdDailyRow, linkClicksOf } from '@/lib/metrics';
|
||||
import { normalizePlatform } from '@/lib/ad-tracker-range';
|
||||
import {
|
||||
AdCreativeCard,
|
||||
buildMetaWarehouseCards,
|
||||
fetchMetaAccountClickSplit,
|
||||
fetchMetaCreativeCards,
|
||||
} from '@/lib/ad-creatives-meta';
|
||||
import {
|
||||
buildGoogleCardsFromDaily,
|
||||
fetchGoogleCreativeCards,
|
||||
} from '@/lib/ad-creatives-google';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function metaClickSplitFromDaily(
|
||||
daily: AdDailyRow[],
|
||||
since: string,
|
||||
until: string
|
||||
): { clicks: number; linkClicks: number; spend: number } {
|
||||
let clicks = 0;
|
||||
let linkClicks = 0;
|
||||
let spend = 0;
|
||||
for (const r of daily) {
|
||||
if (normalizePlatform(r.platform) !== 'meta') continue;
|
||||
const d = (r.date || '').slice(0, 10);
|
||||
if (d < since || d > until) continue;
|
||||
clicks += Number(r.clicks || 0) || 0;
|
||||
linkClicks += linkClicksOf(r);
|
||||
spend += Number(r.spend || 0) || 0;
|
||||
}
|
||||
return {
|
||||
clicks,
|
||||
linkClicks: linkClicks || clicks,
|
||||
spend: Math.round(spend * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/ad-tracker/creatives?since=&until=&platform=meta|google|all&mode=fast|live
|
||||
*
|
||||
* mode=fast → warehouse only (instant, same metrics as Ads sync)
|
||||
* mode=live → enrich with Meta/Google creative assets (slower)
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||
return NextResponse.json(
|
||||
{ error: 'select_tenant', hint: 'Elige un cliente en Contexto' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const since = req.nextUrl.searchParams.get('since') || '';
|
||||
const until = req.nextUrl.searchParams.get('until') || '';
|
||||
const platform = (req.nextUrl.searchParams.get('platform') || 'all').toLowerCase();
|
||||
const mode = (req.nextUrl.searchParams.get('mode') || 'live').toLowerCase();
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(since) || !/^\d{4}-\d{2}-\d{2}$/.test(until)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'bad_range', hint: 'since/until YYYY-MM-DD' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const agency = isAgencyRole(userRole(user));
|
||||
let daily: AdDailyRow[] = [];
|
||||
const selectWithLc =
|
||||
'date,platform,campaign_name,ad_id,ad_name,impressions,clicks,link_clicks,spend,platform_leads';
|
||||
const selectNoLc =
|
||||
'date,platform,campaign_name,ad_id,ad_name,impressions,clicks,spend,platform_leads';
|
||||
if (agency && ctx.viaView) {
|
||||
const tid = encodeURIComponent(ctx.tenantId);
|
||||
let d = await restGet<AdDailyRow[]>(
|
||||
`/rest/v1/ad_daily?tenant_id=eq.${tid}&date=gte.${since}&date=lte.${until}&select=${selectWithLc}&order=date.desc&limit=5000`
|
||||
);
|
||||
if (!d.ok && /link_clicks/i.test(String(d.error || ''))) {
|
||||
d = await restGet<AdDailyRow[]>(
|
||||
`/rest/v1/ad_daily?tenant_id=eq.${tid}&date=gte.${since}&date=lte.${until}&select=${selectNoLc}&order=date.desc&limit=5000`
|
||||
);
|
||||
}
|
||||
daily = d.data || [];
|
||||
} else {
|
||||
let q = await supabase
|
||||
.from('ad_daily')
|
||||
.select(selectWithLc)
|
||||
.gte('date', since)
|
||||
.lte('date', until)
|
||||
.order('date', { ascending: false })
|
||||
.limit(5000);
|
||||
if (q.error && /link_clicks/i.test(q.error.message || '')) {
|
||||
q = await supabase
|
||||
.from('ad_daily')
|
||||
.select(selectNoLc)
|
||||
.gte('date', since)
|
||||
.lte('date', until)
|
||||
.order('date', { ascending: false })
|
||||
.limit(5000);
|
||||
}
|
||||
daily = (q.data as AdDailyRow[]) || [];
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
let metaAds: AdCreativeCard[] = [];
|
||||
let googleAds: AdCreativeCard[] = [];
|
||||
let metaClickSplit: {
|
||||
clicks: number;
|
||||
linkClicks: number;
|
||||
spend: number;
|
||||
} | null = null;
|
||||
|
||||
// Fast path: warehouse only — correct spend/clicks/leads from sync, no Graph wait
|
||||
if (mode === 'fast') {
|
||||
if (platform === 'meta' || platform === 'all') {
|
||||
metaAds = buildMetaWarehouseCards(daily, since, until, 16);
|
||||
metaClickSplit = metaClickSplitFromDaily(daily, since, until);
|
||||
}
|
||||
if (platform === 'google' || platform === 'all') {
|
||||
googleAds = buildGoogleCardsFromDaily(daily, since, until, 24);
|
||||
}
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
mode: 'fast',
|
||||
tenantId: ctx.tenantId,
|
||||
since,
|
||||
until,
|
||||
ads: [...metaAds, ...googleAds],
|
||||
metaCount: metaAds.length,
|
||||
googleCount: googleAds.length,
|
||||
metaClickSplit,
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
|
||||
if (platform === 'meta' || platform === 'all') {
|
||||
try {
|
||||
const [meta, clickSplit] = await Promise.all([
|
||||
fetchMetaCreativeCards({
|
||||
tenantId: ctx.tenantId,
|
||||
dailyRows: daily,
|
||||
since,
|
||||
until,
|
||||
limit: 16,
|
||||
}),
|
||||
fetchMetaAccountClickSplit({
|
||||
tenantId: ctx.tenantId,
|
||||
since,
|
||||
until,
|
||||
}).catch(() => null),
|
||||
]);
|
||||
metaAds = meta.ads.length
|
||||
? meta.ads
|
||||
: buildMetaWarehouseCards(daily, since, until, 16);
|
||||
metaClickSplit =
|
||||
clickSplit || metaClickSplitFromDaily(daily, since, until);
|
||||
if (
|
||||
meta.warning &&
|
||||
/sin token meta|sin cuenta meta|sin actividad meta/i.test(meta.warning)
|
||||
) {
|
||||
warnings.push(meta.warning);
|
||||
}
|
||||
} catch {
|
||||
metaAds = buildMetaWarehouseCards(daily, since, until, 16);
|
||||
metaClickSplit = metaClickSplitFromDaily(daily, since, until);
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'google' || platform === 'all') {
|
||||
try {
|
||||
const google = await fetchGoogleCreativeCards({
|
||||
tenantId: ctx.tenantId,
|
||||
dailyRows: daily,
|
||||
since,
|
||||
until,
|
||||
limit: 24,
|
||||
});
|
||||
googleAds = google.ads.length
|
||||
? google.ads
|
||||
: buildGoogleCardsFromDaily(daily, since, until, 24);
|
||||
if (
|
||||
google.warning &&
|
||||
/caducado|oauth|no configurada|sin cuenta/i.test(google.warning)
|
||||
) {
|
||||
warnings.push(google.warning);
|
||||
}
|
||||
} catch {
|
||||
googleAds = buildGoogleCardsFromDaily(daily, since, until, 24);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
mode: 'live',
|
||||
tenantId: ctx.tenantId,
|
||||
since,
|
||||
until,
|
||||
ads: [...metaAds, ...googleAds],
|
||||
metaCount: metaAds.length,
|
||||
googleCount: googleAds.length,
|
||||
metaClickSplit,
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
83
src/app/api/ad-tracker/media/route.ts
Normal file
83
src/app/api/ad-tracker/media/route.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** CDN prefixes safe to proxy for Ad Tracker previews. */
|
||||
const ALLOWED = [
|
||||
'https://scontent.',
|
||||
'https://external.',
|
||||
'https://facebook.com/',
|
||||
'https://www.facebook.com/',
|
||||
'https://fbcdn.net/',
|
||||
'https://scontent.xx.fbcdn.net/',
|
||||
'https://tpc.googlesyndication.com/',
|
||||
'https://lh3.googleusercontent.com/',
|
||||
'https://i.ytimg.com/',
|
||||
'https://img.youtube.com/',
|
||||
];
|
||||
|
||||
function allowed(url: string): boolean {
|
||||
if (!url.startsWith('https://')) return false;
|
||||
if (ALLOWED.some((p) => url.startsWith(p))) return true;
|
||||
// Meta CDN hosts vary by region: scontent.fmad19-2.fna.fbcdn.net
|
||||
try {
|
||||
const host = new URL(url).hostname;
|
||||
return (
|
||||
host.endsWith('.fbcdn.net') ||
|
||||
host.endsWith('.facebook.com') ||
|
||||
host.endsWith('.cdninstagram.com') ||
|
||||
host.endsWith('.instagram.com') ||
|
||||
host.endsWith('.googleusercontent.com') ||
|
||||
host.endsWith('.googlesyndication.com') ||
|
||||
host === 'i.ytimg.com' ||
|
||||
host === 'img.youtube.com'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/ad-tracker/media?url=…
|
||||
* Proxies Meta/Google creative images so the browser always gets a same-origin preview.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = req.nextUrl.searchParams.get('url') || '';
|
||||
if (!allowed(url)) {
|
||||
return NextResponse.json({ error: 'url_not_allowed' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (compatible; 4VenturesAdTracker/1.0; +https://app.4ventures.es)',
|
||||
Accept: 'image/*,*/*;q=0.8',
|
||||
},
|
||||
cache: 'force-cache',
|
||||
next: { revalidate: 86400 },
|
||||
});
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: 'upstream' }, { status: 502 });
|
||||
}
|
||||
const contentType = res.headers.get('content-type') || 'image/jpeg';
|
||||
const body = await res.arrayBuffer();
|
||||
return new NextResponse(body, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'private, max-age=3600',
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'fetch_failed' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
91
src/app/api/admin/active-tenant/route.ts
Normal file
91
src/app/api/admin/active-tenant/route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import {
|
||||
VIEW_TENANT_COOKIE,
|
||||
VIEW_TENANT_NAME_COOKIE,
|
||||
VIEW_TENANT_SLUG_COOKIE,
|
||||
isAgencyRole,
|
||||
serviceHeaders,
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** POST { tenantId } — set superadmin view context cookie. null clears. */
|
||||
export async function POST(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
if (!isAgencyRole(userRole(user))) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const tenantId =
|
||||
typeof body.tenantId === 'string' && body.tenantId.trim()
|
||||
? body.tenantId.trim()
|
||||
: null;
|
||||
|
||||
const cookieOpts = {
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
sameSite: 'lax' as const,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
httpOnly: false,
|
||||
};
|
||||
|
||||
if (!tenantId) {
|
||||
const res = NextResponse.json({ ok: true, cleared: true });
|
||||
res.cookies.set(VIEW_TENANT_COOKIE, '', { ...cookieOpts, maxAge: 0 });
|
||||
res.cookies.set(VIEW_TENANT_SLUG_COOKIE, '', { ...cookieOpts, maxAge: 0 });
|
||||
res.cookies.set(VIEW_TENANT_NAME_COOKIE, '', { ...cookieOpts, maxAge: 0 });
|
||||
return res;
|
||||
}
|
||||
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Falta SUPABASE_SERVICE_ROLE_KEY' },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}&select=id,name,slug`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) {
|
||||
return NextResponse.json({ error: 'Tenant lookup failed' }, { status: 500 });
|
||||
}
|
||||
const rows = await r.json();
|
||||
const tenant = Array.isArray(rows) ? rows[0] : null;
|
||||
if (!tenant) {
|
||||
return NextResponse.json({ error: 'Tenant no encontrado' }, { status: 404 });
|
||||
}
|
||||
|
||||
const res = NextResponse.json({
|
||||
ok: true,
|
||||
tenant: { id: tenant.id, name: tenant.name, slug: tenant.slug },
|
||||
});
|
||||
res.cookies.set(VIEW_TENANT_COOKIE, tenant.id, cookieOpts);
|
||||
res.cookies.set(VIEW_TENANT_SLUG_COOKIE, tenant.slug || '', cookieOpts);
|
||||
res.cookies.set(VIEW_TENANT_NAME_COOKIE, tenant.name || '', cookieOpts);
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
// readable from client via document.cookie too; this is for SSR helpers
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
76
src/app/api/admin/ads/discover/route.ts
Normal file
76
src/app/api/admin/ads/discover/route.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import {
|
||||
isAgencyRole,
|
||||
serviceHeaders,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
import { discoverAndMatch, applyProviderMatches } from '@/lib/ads-discovery';
|
||||
import type { AccountMatch } from '@/lib/match';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 120;
|
||||
|
||||
async function requireAgency() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user || !isAgencyRole(userRole(user))) {
|
||||
return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
|
||||
}
|
||||
if (!serviceHeaders()) {
|
||||
return {
|
||||
error: NextResponse.json({ error: 'no service role' }, { status: 503 }),
|
||||
};
|
||||
}
|
||||
return { user };
|
||||
}
|
||||
|
||||
/** POST — discover Meta/Google accounts and auto-match to tenants.
|
||||
* Body: { apply?: boolean, providers?: ['meta_ads'|'google_ads'],
|
||||
* assign?: { provider, tenantId, accountId, accountName } }
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const gate = await requireAgency();
|
||||
if ('error' in gate && gate.error) return gate.error;
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
|
||||
if (body.assign?.tenantId && body.assign?.accountId && body.assign?.provider) {
|
||||
const provider = body.assign.provider as 'meta_ads' | 'google_ads';
|
||||
if (provider !== 'meta_ads' && provider !== 'google_ads') {
|
||||
return NextResponse.json({ error: 'provider inválido' }, { status: 400 });
|
||||
}
|
||||
const fake: AccountMatch[] = [
|
||||
{
|
||||
tenantId: body.assign.tenantId,
|
||||
tenantName: '',
|
||||
tenantSlug: '',
|
||||
accountId: String(body.assign.accountId),
|
||||
accountName: String(body.assign.accountName || body.assign.accountId),
|
||||
score: 100,
|
||||
status: 'matched',
|
||||
},
|
||||
];
|
||||
const { linked } = await applyProviderMatches(
|
||||
provider,
|
||||
fake,
|
||||
provider === 'meta_ads' ? 'Meta Ads' : 'Google Ads'
|
||||
);
|
||||
return NextResponse.json({ linked, manual: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await discoverAndMatch({
|
||||
apply: body.apply !== false,
|
||||
providers: body.providers,
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: e instanceof Error ? e.message : 'discover failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
25
src/app/api/admin/ads/meta-token/route.ts
Normal file
25
src/app/api/admin/ads/meta-token/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||
import { saveMetaAccessToken } from '@/lib/ads-discovery';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** POST { token } — save Meta system user token (after App Review). */
|
||||
export async function POST(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user || !isAgencyRole(userRole(user))) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const token = typeof body.token === 'string' ? body.token : '';
|
||||
const result = await saveMetaAccessToken(token);
|
||||
if (!result.ok) {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json({ ok: true, name: result.name });
|
||||
}
|
||||
31
src/app/api/admin/ads/status/route.ts
Normal file
31
src/app/api/admin/ads/status/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||
import {
|
||||
googleAdsConfigured,
|
||||
metaConfigured,
|
||||
metaEnvConfigured,
|
||||
} from '@/lib/ads-discovery';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** GET — whether Meta / Google Ads discovery credentials are ready. */
|
||||
export async function GET() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user || !isAgencyRole(userRole(user))) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const metaReady = await metaConfigured();
|
||||
return NextResponse.json({
|
||||
meta: {
|
||||
configured: metaReady,
|
||||
envFallback: metaEnvConfigured(),
|
||||
pendingReview: !metaReady,
|
||||
},
|
||||
googleAds: { configured: googleAdsConfigured() },
|
||||
});
|
||||
}
|
||||
44
src/app/api/admin/ads/sync/route.ts
Normal file
44
src/app/api/admin/ads/sync/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||
import { syncAdsForTenants } from '@/lib/ads-sync';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300;
|
||||
|
||||
/** POST { tenantId?, days? } — sync Meta/Google insights → ad_daily + kpi_weekly */
|
||||
export async function POST(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user || !isAgencyRole(userRole(user))) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const tenantId =
|
||||
typeof body.tenantId === 'string' ? body.tenantId : undefined;
|
||||
const days =
|
||||
typeof body.days === 'number' && body.days > 0 && body.days <= 1000
|
||||
? body.days
|
||||
: 30;
|
||||
|
||||
try {
|
||||
const { results, days: d } = await syncAdsForTenants({ tenantId, days });
|
||||
const ok = results.filter((r) => r.metaRows + r.googleRows > 0).length;
|
||||
const errs = results.filter((r) => r.errors.length).length;
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
days: d,
|
||||
synced: ok,
|
||||
withErrors: errs,
|
||||
results,
|
||||
});
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: e instanceof Error ? e.message : 'sync error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
104
src/app/api/admin/alerts/route.ts
Normal file
104
src/app/api/admin/alerts/route.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import {
|
||||
isAgencyRole,
|
||||
serviceHeaders,
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
if (!isAgencyRole(userRole(user))) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) {
|
||||
return NextResponse.json({ alerts: [], warning: 'no service role' });
|
||||
}
|
||||
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/agency_alerts?select=*&order=created_at.desc&limit=50`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) {
|
||||
return NextResponse.json({
|
||||
alerts: [],
|
||||
warning: 'Aplicar sql/agency_portfolio.sql',
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ alerts: await r.json() });
|
||||
}
|
||||
|
||||
/** POST { id } mark read — or { title, body, severity, tenantId } create */
|
||||
export async function POST(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
if (!isAgencyRole(userRole(user))) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) {
|
||||
return NextResponse.json({ error: 'no service role' }, { status: 503 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const base = supabaseUrl();
|
||||
|
||||
if (typeof body.id === 'string' && body.markRead) {
|
||||
const r = await fetch(
|
||||
`${base}/rest/v1/agency_alerts?id=eq.${encodeURIComponent(body.id)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ read_at: new Date().toISOString() }),
|
||||
}
|
||||
);
|
||||
if (!r.ok) {
|
||||
return NextResponse.json({ error: await r.text() }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const title = typeof body.title === 'string' ? body.title.trim() : '';
|
||||
if (!title) {
|
||||
return NextResponse.json({ error: 'title requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const row = {
|
||||
title,
|
||||
body: typeof body.body === 'string' ? body.body : null,
|
||||
severity: body.severity || 'info',
|
||||
tenant_id: body.tenantId || null,
|
||||
source: body.source || 'manual',
|
||||
href: body.href || null,
|
||||
};
|
||||
|
||||
const r = await fetch(`${base}/rest/v1/agency_alerts`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation',
|
||||
},
|
||||
body: JSON.stringify(row),
|
||||
});
|
||||
if (!r.ok) {
|
||||
return NextResponse.json({ error: await r.text() }, { status: 500 });
|
||||
}
|
||||
const created = await r.json();
|
||||
return NextResponse.json({ alert: created[0] || created }, { status: 201 });
|
||||
}
|
||||
@@ -38,6 +38,53 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: error.message || 'Error al provisionar cliente en base de datos.' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Seed integration slots (best-effort; ignore if table missing)
|
||||
try {
|
||||
const tenantId =
|
||||
data?.tenant_id || data?.id || data?.client?.tenant_id || null;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
|
||||
if (tenantId && serviceKey) {
|
||||
const providers = [
|
||||
['meta_ads', 'Meta Ads'],
|
||||
['google_ads', 'Google Ads (MCC)'],
|
||||
['ga4', 'Google Analytics 4'],
|
||||
['search_console', 'Search Console'],
|
||||
['tag_manager', 'Tag Manager'],
|
||||
['clientify', 'Clientify'],
|
||||
['klaviyo', 'Klaviyo'],
|
||||
['mailchimp', 'Mailchimp'],
|
||||
['google_drive', 'Google Drive'],
|
||||
['wordpress', 'WordPress'],
|
||||
['prestashop', 'PrestaShop'],
|
||||
['shopify', 'Shopify'],
|
||||
['instagram', 'Instagram'],
|
||||
['facebook', 'Facebook Page'],
|
||||
['tiktok', 'TikTok'],
|
||||
['linkedin', 'LinkedIn'],
|
||||
];
|
||||
await fetch(`${url}/rest/v1/integrations`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
Authorization: `Bearer ${serviceKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'resolution=ignore-duplicates',
|
||||
},
|
||||
body: JSON.stringify(
|
||||
providers.map(([provider, label]) => ({
|
||||
tenant_id: tenantId,
|
||||
provider,
|
||||
status: 'disconnected',
|
||||
label,
|
||||
}))
|
||||
),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('seed integrations skipped', e);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, client: data });
|
||||
} catch (err: any) {
|
||||
console.error('Server error creating client:', err);
|
||||
|
||||
88
src/app/api/admin/drive/callback/route.ts
Normal file
88
src/app/api/admin/drive/callback/route.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||
import {
|
||||
exchangeCode,
|
||||
fetchGoogleEmail,
|
||||
saveAgencyOAuth,
|
||||
} from '@/lib/google-drive';
|
||||
import { bootstrapAgencyGoogle } from '@/lib/agency-google-bootstrap';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 120;
|
||||
|
||||
function appUrl(): string {
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_APP_URL?.trim() ||
|
||||
'https://app.4ventures.es'
|
||||
);
|
||||
}
|
||||
|
||||
/** GET — OAuth callback: save tokens + auto-emparejar Drive/GA4/GSC/Ads → fichas. */
|
||||
export async function GET(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user || !isAgencyRole(userRole(user))) {
|
||||
return NextResponse.redirect(`${appUrl()}/login`);
|
||||
}
|
||||
|
||||
const code = req.nextUrl.searchParams.get('code');
|
||||
const state = req.nextUrl.searchParams.get('state');
|
||||
const err = req.nextUrl.searchParams.get('error');
|
||||
const cookieState = req.cookies.get('4v_drive_oauth_state')?.value;
|
||||
|
||||
if (err) {
|
||||
return NextResponse.redirect(
|
||||
`${appUrl()}/dashboard/agencia?drive=error&msg=${encodeURIComponent(err)}`
|
||||
);
|
||||
}
|
||||
if (!code || !state || !cookieState || state !== cookieState) {
|
||||
return NextResponse.redirect(
|
||||
`${appUrl()}/dashboard/agencia?drive=error&msg=state`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await exchangeCode(code);
|
||||
const email = await fetchGoogleEmail(tokens.access_token);
|
||||
await saveAgencyOAuth({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || null,
|
||||
expiresIn: tokens.expires_in,
|
||||
scopes: tokens.scope,
|
||||
email,
|
||||
});
|
||||
|
||||
let linked = 0;
|
||||
let ambiguous = 0;
|
||||
let bootstrapMsg = '';
|
||||
try {
|
||||
const boot = await bootstrapAgencyGoogle({
|
||||
includeGoogleAds: true,
|
||||
syncOrganic: true,
|
||||
organicDays: 28,
|
||||
});
|
||||
linked = boot.drive?.linked || 0;
|
||||
ambiguous = boot.drive?.ambiguous || 0;
|
||||
bootstrapMsg = boot.message;
|
||||
} catch (e) {
|
||||
bootstrapMsg = e instanceof Error ? e.message : 'bootstrap failed';
|
||||
}
|
||||
|
||||
const res = NextResponse.redirect(
|
||||
`${appUrl()}/dashboard/agencia?drive=ok&linked=${linked}&ambiguous=${ambiguous}` +
|
||||
(bootstrapMsg
|
||||
? `&boot=${encodeURIComponent(bootstrapMsg.slice(0, 160))}`
|
||||
: '')
|
||||
);
|
||||
res.cookies.set('4v_drive_oauth_state', '', { maxAge: 0, path: '/' });
|
||||
return res;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'oauth_failed';
|
||||
return NextResponse.redirect(
|
||||
`${appUrl()}/dashboard/agencia?drive=error&msg=${encodeURIComponent(msg.slice(0, 120))}`
|
||||
);
|
||||
}
|
||||
}
|
||||
42
src/app/api/admin/drive/connect/route.ts
Normal file
42
src/app/api/admin/drive/connect/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||
import {
|
||||
driveAuthUrl,
|
||||
driveConfigured,
|
||||
} from '@/lib/google-drive';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** GET — start Google OAuth for agency Drive (marketing@). */
|
||||
export async function GET() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user || !isAgencyRole(userRole(user))) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
if (!driveConfigured()) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Faltan GOOGLE_OAUTH_CLIENT_ID y GOOGLE_OAUTH_CLIENT_SECRET en el entorno.',
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const state = randomBytes(24).toString('hex');
|
||||
const res = NextResponse.redirect(driveAuthUrl(state));
|
||||
res.cookies.set('4v_drive_oauth_state', state, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 600,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
109
src/app/api/admin/drive/match/route.ts
Normal file
109
src/app/api/admin/drive/match/route.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import {
|
||||
isAgencyRole,
|
||||
serviceHeaders,
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
import {
|
||||
DRIVE_PROVIDER,
|
||||
applyMatches,
|
||||
listDriveFolders,
|
||||
matchFoldersToTenants,
|
||||
} from '@/lib/google-drive';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
async function requireAgency() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user || !isAgencyRole(userRole(user))) {
|
||||
return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
|
||||
}
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) {
|
||||
return {
|
||||
error: NextResponse.json({ error: 'no service role' }, { status: 503 }),
|
||||
};
|
||||
}
|
||||
return { headers };
|
||||
}
|
||||
|
||||
/** POST — re-run auto-match (or body.assign for manual pick). */
|
||||
export async function POST(req: NextRequest) {
|
||||
const gate = await requireAgency();
|
||||
if ('error' in gate && gate.error) return gate.error;
|
||||
const { headers } = gate as {
|
||||
headers: { apikey: string; Authorization: string };
|
||||
};
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
|
||||
// Manual assign: { tenantId, folderId, folderName }
|
||||
if (body.tenantId && body.folderId) {
|
||||
const patch = {
|
||||
status: 'connected',
|
||||
external_account_id: String(body.folderId),
|
||||
label: String(body.folderName || 'Google Drive'),
|
||||
last_sync_at: new Date().toISOString(),
|
||||
error_message: null,
|
||||
meta: {
|
||||
folder_name: body.folderName || null,
|
||||
match_score: 100,
|
||||
matched_at: new Date().toISOString(),
|
||||
manual: true,
|
||||
},
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
const url = `${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(body.tenantId)}&provider=eq.${DRIVE_PROVIDER}`;
|
||||
const r = await fetch(url, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation',
|
||||
},
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (!r.ok) {
|
||||
return NextResponse.json({ error: await r.text() }, { status: 500 });
|
||||
}
|
||||
const rows = await r.json();
|
||||
return NextResponse.json({ integration: rows[0] || rows });
|
||||
}
|
||||
|
||||
try {
|
||||
const tRes = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!tRes.ok) {
|
||||
return NextResponse.json({ error: await tRes.text() }, { status: 500 });
|
||||
}
|
||||
const tenants = (await tRes.json()) as {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}[];
|
||||
const folders = await listDriveFolders();
|
||||
const matches = await matchFoldersToTenants(tenants, folders);
|
||||
const apply = body.apply !== false;
|
||||
let linked = 0;
|
||||
if (apply) {
|
||||
linked = (await applyMatches(matches)).linked;
|
||||
}
|
||||
return NextResponse.json({
|
||||
linked,
|
||||
folders: folders.length,
|
||||
matches,
|
||||
});
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: e instanceof Error ? e.message : 'match failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
31
src/app/api/admin/drive/status/route.ts
Normal file
31
src/app/api/admin/drive/status/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||
import { driveConfigured, getAgencyOAuthStatus } from '@/lib/google-drive';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** GET — agency Drive connection status (no tokens). */
|
||||
export async function GET() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user || !isAgencyRole(userRole(user))) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await getAgencyOAuthStatus();
|
||||
return NextResponse.json({
|
||||
...status,
|
||||
configured: driveConfigured(),
|
||||
});
|
||||
} catch (e) {
|
||||
return NextResponse.json({
|
||||
connected: false,
|
||||
configured: driveConfigured(),
|
||||
warning: e instanceof Error ? e.message : 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
197
src/app/api/admin/google/organic/route.ts
Normal file
197
src/app/api/admin/google/organic/route.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import {
|
||||
isAgencyRole,
|
||||
serviceHeaders,
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
import { getAgencyOAuthStatus } from '@/lib/google-drive';
|
||||
import { bootstrapAgencyGoogle } from '@/lib/agency-google-bootstrap';
|
||||
import {
|
||||
applyOrganicMatches,
|
||||
listGa4Properties,
|
||||
listGscSites,
|
||||
matchOrganicToTenants,
|
||||
syncAllConnectedOrganic,
|
||||
} from '@/lib/google-organic';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 120;
|
||||
|
||||
async function requireAgency() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user || !isAgencyRole(userRole(user))) {
|
||||
return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
|
||||
}
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) {
|
||||
return {
|
||||
error: NextResponse.json({ error: 'no service role' }, { status: 503 }),
|
||||
};
|
||||
}
|
||||
return { headers };
|
||||
}
|
||||
|
||||
/** GET — list GA4 properties + GSC sites visible to marketing@ token. */
|
||||
export async function GET() {
|
||||
const gate = await requireAgency();
|
||||
if ('error' in gate && gate.error) return gate.error;
|
||||
|
||||
const status = await getAgencyOAuthStatus();
|
||||
if (!status.connected) {
|
||||
return NextResponse.json({
|
||||
connected: false,
|
||||
warning: 'Conecta Google (marketing@) en Agencia primero.',
|
||||
ga4: [],
|
||||
gsc: [],
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const [ga4, gsc] = await Promise.all([
|
||||
status.hasAnalytics !== false
|
||||
? listGa4Properties().catch((e) => {
|
||||
throw Object.assign(e, { where: 'ga4' });
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
status.hasSearchConsole !== false
|
||||
? listGscSites().catch((e) => {
|
||||
throw Object.assign(e, { where: 'gsc' });
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
connected: true,
|
||||
email: status.email,
|
||||
hasAnalytics: status.hasAnalytics,
|
||||
hasSearchConsole: status.hasSearchConsole,
|
||||
ga4,
|
||||
gsc,
|
||||
warning:
|
||||
!status.hasAnalytics || !status.hasSearchConsole
|
||||
? 'Reconecta Google para conceder scopes Analytics + Search Console (además de Drive).'
|
||||
: null,
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'error';
|
||||
const scopeHint = msg.toLowerCase().includes('scope')
|
||||
? ' Reconecta Google con scopes Analytics + GSC.'
|
||||
: '';
|
||||
return NextResponse.json(
|
||||
{ error: msg + scopeHint, connected: true, email: status.email },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST
|
||||
* - { action: 'bootstrap' } → Drive + GA4 + GSC + Google Ads → fichas + sync
|
||||
* - { action: 'match', apply? } → solo GA4/GSC
|
||||
* - { action: 'sync', tenantId?, days? } → sync organic_daily
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const gate = await requireAgency();
|
||||
if ('error' in gate && gate.error) return gate.error;
|
||||
const { headers } = gate as {
|
||||
headers: { apikey: string; Authorization: string };
|
||||
};
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const action =
|
||||
body.action === 'sync'
|
||||
? 'sync'
|
||||
: body.action === 'bootstrap'
|
||||
? 'bootstrap'
|
||||
: 'match';
|
||||
|
||||
if (action === 'bootstrap') {
|
||||
try {
|
||||
const result = await bootstrapAgencyGoogle({
|
||||
includeGoogleAds: body.includeGoogleAds !== false,
|
||||
syncOrganic: body.syncOrganic !== false,
|
||||
organicDays: Number(body.days) || 28,
|
||||
});
|
||||
return NextResponse.json(result, { status: result.ok ? 200 : 422 });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: e instanceof Error ? e.message : 'bootstrap error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'match') {
|
||||
try {
|
||||
const [ga4, gsc, tRes] = await Promise.all([
|
||||
listGa4Properties(),
|
||||
listGscSites(),
|
||||
fetch(
|
||||
`${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`,
|
||||
{ headers, cache: 'no-store' }
|
||||
),
|
||||
]);
|
||||
if (!tRes.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await tRes.text() },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
const tenants = (await tRes.json()) as {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}[];
|
||||
const matches = await matchOrganicToTenants(tenants, ga4, gsc);
|
||||
let applied = { ga4: 0, gsc: 0 };
|
||||
if (body.apply !== false) {
|
||||
applied = await applyOrganicMatches(
|
||||
matches.filter(
|
||||
(m) =>
|
||||
(m.ga4 && m.ga4.score >= 50) || (m.gsc && m.gsc.score >= 40)
|
||||
)
|
||||
);
|
||||
}
|
||||
return NextResponse.json({
|
||||
ga4Count: ga4.length,
|
||||
gscCount: gsc.length,
|
||||
matches,
|
||||
applied,
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'error';
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
msg +
|
||||
(msg.toLowerCase().includes('scope')
|
||||
? ' → Reconecta Google (scopes Analytics + GSC).'
|
||||
: ''),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const days = Math.min(90, Math.max(3, Number(body.days) || 28));
|
||||
const tenantFilter =
|
||||
typeof body.tenantId === 'string' ? body.tenantId.trim() : '';
|
||||
|
||||
try {
|
||||
const result = await syncAllConnectedOrganic({
|
||||
tenantId: tenantFilter || null,
|
||||
days,
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: e instanceof Error ? e.message : 'sync error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
242
src/app/api/admin/integrations/route.ts
Normal file
242
src/app/api/admin/integrations/route.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import {
|
||||
INTEGRATION_PROVIDERS,
|
||||
isAgencyRole,
|
||||
serviceHeaders,
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
import {
|
||||
activateGoogleFromFicha,
|
||||
LIVE_GOOGLE_PROVIDERS,
|
||||
normalizeDriveFolderId,
|
||||
} from '@/lib/integration-activate';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
async function requireAgency() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
|
||||
if (!isAgencyRole(userRole(user))) {
|
||||
return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
|
||||
}
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ error: 'Falta SUPABASE_SERVICE_ROLE_KEY' },
|
||||
{ status: 503 }
|
||||
),
|
||||
};
|
||||
}
|
||||
return { user, headers };
|
||||
}
|
||||
|
||||
/** GET ?tenantId= — integrations for one tenant (seed missing providers). */
|
||||
export async function GET(req: NextRequest) {
|
||||
const gate = await requireAgency();
|
||||
if ('error' in gate && gate.error) return gate.error;
|
||||
const { headers } = gate as { headers: { apikey: string; Authorization: string } };
|
||||
|
||||
const tenantId = req.nextUrl.searchParams.get('tenantId');
|
||||
if (!tenantId) {
|
||||
return NextResponse.json({ error: 'tenantId requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const base = supabaseUrl();
|
||||
const listRes = await fetch(
|
||||
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&select=*`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
|
||||
if (!listRes.ok) {
|
||||
const msg = await listRes.text();
|
||||
return NextResponse.json(
|
||||
{
|
||||
integrations: [],
|
||||
providers: INTEGRATION_PROVIDERS,
|
||||
warning: msg.includes('does not exist')
|
||||
? 'Aplicar sql/agency_portfolio.sql'
|
||||
: msg.slice(0, 200),
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
let integrations = await listRes.json();
|
||||
const have = new Set(
|
||||
(integrations as { provider: string }[]).map((i) => i.provider)
|
||||
);
|
||||
const missing = INTEGRATION_PROVIDERS.filter((p) => !have.has(p.id));
|
||||
|
||||
if (missing.length) {
|
||||
const rows = missing.map((p) => ({
|
||||
tenant_id: tenantId,
|
||||
provider: p.id,
|
||||
status: 'disconnected',
|
||||
label: p.label,
|
||||
}));
|
||||
await fetch(`${base}/rest/v1/integrations`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json', Prefer: 'return=representation' },
|
||||
body: JSON.stringify(rows),
|
||||
});
|
||||
const again = await fetch(
|
||||
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&select=*`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (again.ok) integrations = await again.json();
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
integrations,
|
||||
providers: INTEGRATION_PROVIDERS,
|
||||
});
|
||||
}
|
||||
|
||||
/** PATCH — update integration; GA4/GSC/Drive verify + sync via agency Google OAuth. */
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const gate = await requireAgency();
|
||||
if ('error' in gate && gate.error) return gate.error;
|
||||
const { headers } = gate as { headers: { apikey: string; Authorization: string } };
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const tenantId = typeof body.tenantId === 'string' ? body.tenantId.trim() : '';
|
||||
const provider = typeof body.provider === 'string' ? body.provider.trim() : '';
|
||||
const status = typeof body.status === 'string' ? body.status.trim() : '';
|
||||
const externalAccountId =
|
||||
typeof body.externalAccountId === 'string'
|
||||
? body.externalAccountId.trim()
|
||||
: undefined;
|
||||
const label =
|
||||
typeof body.label === 'string' ? body.label.trim() : undefined;
|
||||
const errorMessage =
|
||||
typeof body.errorMessage === 'string' ? body.errorMessage.trim() : undefined;
|
||||
const enabled =
|
||||
typeof body.enabled === 'boolean' ? body.enabled : undefined;
|
||||
|
||||
if (!tenantId || !provider) {
|
||||
return NextResponse.json(
|
||||
{ error: 'tenantId y provider requeridos' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (status && !['disconnected', 'pending', 'connected', 'error'].includes(status)) {
|
||||
return NextResponse.json({ error: 'status inválido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const wantsLiveConnect =
|
||||
LIVE_GOOGLE_PROVIDERS.has(provider) &&
|
||||
(status === 'connected' ||
|
||||
(status === 'pending' && Boolean(externalAccountId)));
|
||||
|
||||
if (wantsLiveConnect) {
|
||||
let rawId = externalAccountId || '';
|
||||
if (!rawId) {
|
||||
const cur = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${encodeURIComponent(provider)}&select=external_account_id`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (cur.ok) {
|
||||
const rows = (await cur.json()) as { external_account_id?: string }[];
|
||||
rawId = rows[0]?.external_account_id || '';
|
||||
}
|
||||
}
|
||||
if (!rawId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Indica ID o URL antes de conectar',
|
||||
activation: { ok: false, message: 'Falta ID' },
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const activation = await activateGoogleFromFicha({
|
||||
tenantId,
|
||||
provider: provider as 'ga4' | 'search_console' | 'google_drive',
|
||||
rawId,
|
||||
});
|
||||
const list = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${encodeURIComponent(provider)}&select=*`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
const rows = list.ok ? await list.json() : [];
|
||||
return NextResponse.json(
|
||||
{
|
||||
integration: rows[0] || null,
|
||||
activation,
|
||||
error: activation.ok ? undefined : activation.errorMessage || activation.message,
|
||||
},
|
||||
{ status: activation.ok ? 200 : 422 }
|
||||
);
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
if (status) patch.status = status;
|
||||
if (externalAccountId !== undefined) {
|
||||
let id = externalAccountId || null;
|
||||
if (provider === 'google_drive' && id) {
|
||||
id = normalizeDriveFolderId(id) || id;
|
||||
}
|
||||
patch.external_account_id = id;
|
||||
}
|
||||
if (label !== undefined) patch.label = label || null;
|
||||
if (errorMessage !== undefined) patch.error_message = errorMessage || null;
|
||||
if (enabled !== undefined) patch.enabled = enabled;
|
||||
if (status === 'connected') {
|
||||
patch.error_message = null;
|
||||
}
|
||||
|
||||
if (!status && enabled === undefined && externalAccountId === undefined) {
|
||||
return NextResponse.json(
|
||||
{ error: 'status, enabled o externalAccountId requeridos' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const base = supabaseUrl();
|
||||
const url = `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${encodeURIComponent(provider)}`;
|
||||
const r = await fetch(url, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation',
|
||||
},
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
|
||||
if (!r.ok) {
|
||||
const ins = await fetch(`${base}/rest/v1/integrations`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation,resolution=merge-duplicates',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tenant_id: tenantId,
|
||||
provider,
|
||||
status: status || 'disconnected',
|
||||
...patch,
|
||||
}),
|
||||
});
|
||||
if (!ins.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await ins.text() },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
const created = await ins.json();
|
||||
return NextResponse.json({ integration: created[0] || created });
|
||||
}
|
||||
|
||||
const updated = await r.json();
|
||||
return NextResponse.json({ integration: updated[0] || updated });
|
||||
}
|
||||
141
src/app/api/admin/portfolio/route.ts
Normal file
141
src/app/api/admin/portfolio/route.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import {
|
||||
INTEGRATION_PROVIDERS,
|
||||
isAgencyRole,
|
||||
serviceHeaders,
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** GET portfolio: tenants + integration summary + unread alerts (agency only). */
|
||||
export async function GET() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
if (!isAgencyRole(userRole(user))) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Falta SUPABASE_SERVICE_ROLE_KEY' },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const base = supabaseUrl();
|
||||
const [tenantsRes, integRes, alertsRes] = await Promise.all([
|
||||
fetch(
|
||||
`${base}/rest/v1/tenants?select=id,name,slug,created_at,agency_followed,agency_notes&order=name.asc`,
|
||||
{
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
}
|
||||
),
|
||||
fetch(
|
||||
`${base}/rest/v1/integrations?select=id,tenant_id,provider,status,label,external_account_id,last_sync_at,error_message,enabled,meta`,
|
||||
{ headers, cache: 'no-store' }
|
||||
),
|
||||
fetch(
|
||||
`${base}/rest/v1/agency_alerts?select=*&read_at=is.null&order=created_at.desc&limit=40`,
|
||||
{ headers, cache: 'no-store' }
|
||||
),
|
||||
]);
|
||||
|
||||
let tenants = tenantsRes.ok ? await tenantsRes.json() : [];
|
||||
// Fallback if agency_followed column missing
|
||||
if (!tenantsRes.ok) {
|
||||
const again = await fetch(
|
||||
`${base}/rest/v1/tenants?select=id,name,slug,created_at&order=name.asc`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
tenants = again.ok ? await again.json() : [];
|
||||
}
|
||||
|
||||
let integrations: unknown[] = [];
|
||||
let integMissing = false;
|
||||
if (integRes.ok) {
|
||||
integrations = await integRes.json();
|
||||
} else {
|
||||
const msg = await integRes.text();
|
||||
integMissing = msg.toLowerCase().includes('does not exist');
|
||||
}
|
||||
const alerts = alertsRes.ok ? await alertsRes.json() : [];
|
||||
|
||||
type Integ = {
|
||||
tenant_id: string;
|
||||
provider: string;
|
||||
status: string;
|
||||
label?: string | null;
|
||||
external_account_id?: string | null;
|
||||
enabled?: boolean;
|
||||
error_message?: string | null;
|
||||
meta?: Record<string, unknown> | null;
|
||||
};
|
||||
const byTenant = new Map<string, Integ[]>();
|
||||
for (const row of integrations as Integ[]) {
|
||||
const list = byTenant.get(row.tenant_id) || [];
|
||||
list.push(row);
|
||||
byTenant.set(row.tenant_id, list);
|
||||
}
|
||||
|
||||
const portfolio = (
|
||||
tenants as {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
created_at: string;
|
||||
agency_followed?: boolean;
|
||||
agency_notes?: string | null;
|
||||
}[]
|
||||
).map((t) => {
|
||||
const rows = byTenant.get(t.id) || [];
|
||||
const active = rows.filter((r) => r.enabled !== false);
|
||||
const connected = active.filter((r) => r.status === 'connected').length;
|
||||
const errors = active.filter((r) => r.status === 'error').length;
|
||||
const pending = active.filter((r) => r.status === 'pending').length;
|
||||
const byProvider: Record<string, Integ | undefined> = {};
|
||||
for (const p of INTEGRATION_PROVIDERS) {
|
||||
byProvider[p.id] = rows.find((r) => r.provider === p.id);
|
||||
}
|
||||
return {
|
||||
...t,
|
||||
agency_followed: t.agency_followed !== false,
|
||||
integrations: rows,
|
||||
byProvider,
|
||||
stats: {
|
||||
connected,
|
||||
errors,
|
||||
pending,
|
||||
enabledChannels: active.length,
|
||||
totalProviders: INTEGRATION_PROVIDERS.length,
|
||||
health:
|
||||
errors > 0
|
||||
? 'critical'
|
||||
: connected === 0
|
||||
? 'empty'
|
||||
: pending > 0
|
||||
? 'warning'
|
||||
: 'ok',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
portfolio,
|
||||
alerts,
|
||||
providers: INTEGRATION_PROVIDERS,
|
||||
schemaReady: !integMissing,
|
||||
warning: integMissing
|
||||
? 'Falta aplicar sql/agency_portfolio.sql en Supabase'
|
||||
: null,
|
||||
});
|
||||
}
|
||||
123
src/app/api/admin/superhuman/sync/route.ts
Normal file
123
src/app/api/admin/superhuman/sync/route.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import {
|
||||
isAgencyRole,
|
||||
serviceHeaders,
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
import {
|
||||
syncAllSuperhumanTenants,
|
||||
superhumanConfigured,
|
||||
} from '@/lib/superhuman-docs';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300;
|
||||
|
||||
async function requireAgency() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user || !isAgencyRole(userRole(user))) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Forbidden: sincronizar Superhuman requiere rol agencia (admin/team).',
|
||||
},
|
||||
{ status: 403 }
|
||||
),
|
||||
};
|
||||
}
|
||||
if (!superhumanConfigured()) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ error: 'Falta SUPERHUMAN_DOCS_API_KEY en el entorno' },
|
||||
{ status: 503 }
|
||||
),
|
||||
};
|
||||
}
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) {
|
||||
return {
|
||||
error: NextResponse.json({ error: 'no service role' }, { status: 503 }),
|
||||
};
|
||||
}
|
||||
return { headers, user };
|
||||
}
|
||||
|
||||
/** GET — match status of Superhuman docs ↔ tenants. */
|
||||
export async function GET() {
|
||||
const gate = await requireAgency();
|
||||
if ('error' in gate && gate.error) return gate.error;
|
||||
|
||||
try {
|
||||
const { listDocs, matchDocsToTenants, SUPERHUMAN_PROVIDER } = await import(
|
||||
'@/lib/superhuman-docs'
|
||||
);
|
||||
const headers = (gate as { headers: { apikey: string; Authorization: string } })
|
||||
.headers;
|
||||
const [docs, tenantsRes] = await Promise.all([
|
||||
listDocs(),
|
||||
fetch(
|
||||
`${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`,
|
||||
{ headers, cache: 'no-store' }
|
||||
),
|
||||
]);
|
||||
const tenants = tenantsRes.ok
|
||||
? ((await tenantsRes.json()) as { id: string; name: string; slug: string }[])
|
||||
: [];
|
||||
const matches = matchDocsToTenants(tenants, docs);
|
||||
const integRes = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/integrations?provider=eq.${SUPERHUMAN_PROVIDER}&select=tenant_id,status,external_account_id,label,last_sync_at,meta,error_message`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
const integrations = integRes.ok ? await integRes.json() : [];
|
||||
|
||||
return NextResponse.json({
|
||||
configured: true,
|
||||
docCount: docs.length,
|
||||
matches,
|
||||
integrations,
|
||||
});
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: e instanceof Error ? e.message : 'sync status failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST — sync seguimiento pages into portal.
|
||||
* body: { tenantId?: string, exportBodies?: boolean, maxEntries?: number }
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const gate = await requireAgency();
|
||||
if ('error' in gate && gate.error) return gate.error;
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const onlyTenant: string | undefined = body.tenantId
|
||||
? String(body.tenantId)
|
||||
: undefined;
|
||||
const exportBodies = body.exportBodies !== false;
|
||||
const maxEntries = Math.min(Number(body.maxEntries) || 80, 150);
|
||||
|
||||
try {
|
||||
const result = await syncAllSuperhumanTenants({
|
||||
tenantId: onlyTenant || null,
|
||||
maxEntries,
|
||||
exportBodies,
|
||||
});
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
...result,
|
||||
});
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: e instanceof Error ? e.message : 'sync failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
187
src/app/api/admin/sync/route.ts
Normal file
187
src/app/api/admin/sync/route.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
|
||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
import { syncAdsForTenants } from '@/lib/ads-sync';
|
||||
import { syncAllClientifyTenants } from '@/lib/clientify-sync';
|
||||
import { syncAllConnectedOrganic } from '@/lib/google-organic';
|
||||
import {
|
||||
getAgencyOAuthStatus,
|
||||
applyMatches,
|
||||
matchFoldersToTenants,
|
||||
listClientFoldersUnderClientes,
|
||||
} from '@/lib/google-drive';
|
||||
import {
|
||||
syncAllSuperhumanTenants,
|
||||
superhumanConfigured,
|
||||
} from '@/lib/superhuman-docs';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300;
|
||||
|
||||
export type SyncModule =
|
||||
| 'metrics'
|
||||
| 'crm'
|
||||
| 'seguimiento'
|
||||
| 'ads'
|
||||
| 'social'
|
||||
| 'organico'
|
||||
| 'activos'
|
||||
| 'all';
|
||||
|
||||
/**
|
||||
* POST { module, days?, tenantId? }
|
||||
* Agency-triggered refresh for dashboard modules.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user || !isAgencyRole(userRole(user))) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const module = (body.module || 'all') as SyncModule;
|
||||
const days =
|
||||
typeof body.days === 'number' && body.days > 0 && body.days <= 90
|
||||
? body.days
|
||||
: 14;
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
const tenantId =
|
||||
typeof body.tenantId === 'string'
|
||||
? body.tenantId
|
||||
: ctx.portfolioWide
|
||||
? undefined
|
||||
: ctx.tenantId || undefined;
|
||||
|
||||
const out: Record<string, unknown> = {
|
||||
ok: true,
|
||||
module,
|
||||
tenantId: tenantId || null,
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const want = (m: SyncModule) => module === 'all' || module === m;
|
||||
|
||||
try {
|
||||
if (want('crm') || want('metrics')) {
|
||||
out.crm = await syncAllClientifyTenants({ tenantId });
|
||||
}
|
||||
} catch (e) {
|
||||
out.crm = {
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : 'crm failed',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
if (want('ads') || want('metrics')) {
|
||||
const ads = await syncAdsForTenants({ tenantId, days });
|
||||
out.ads = {
|
||||
ok: true,
|
||||
synced: ads.results.filter((r) => r.metaRows + r.googleRows > 0).length,
|
||||
withErrors: ads.results.filter((r) => r.errors.length).length,
|
||||
results: ads.results.slice(0, 30),
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
out.ads = {
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : 'ads failed',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
if (want('organico') || want('metrics')) {
|
||||
const oauth = await getAgencyOAuthStatus();
|
||||
if (!oauth.connected) {
|
||||
out.organico = { ok: false, warning: 'Google agencia no conectado' };
|
||||
} else {
|
||||
out.organico = {
|
||||
ok: true,
|
||||
...(await syncAllConnectedOrganic({ days, tenantId })),
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
out.organico = {
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : 'organic failed',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
if (want('seguimiento')) {
|
||||
if (!superhumanConfigured()) {
|
||||
out.seguimiento = {
|
||||
ok: false,
|
||||
warning: 'SUPERHUMAN_DOCS_API_KEY no configurado',
|
||||
};
|
||||
} else if (tenantId) {
|
||||
out.seguimiento = {
|
||||
ok: true,
|
||||
...(await syncAllSuperhumanTenants({
|
||||
tenantId,
|
||||
maxEntries: 80,
|
||||
})),
|
||||
};
|
||||
} else {
|
||||
out.seguimiento = {
|
||||
ok: true,
|
||||
...(await syncAllSuperhumanTenants({ maxEntries: 80 })),
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
out.seguimiento = {
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : 'seguimiento failed',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
if (want('activos')) {
|
||||
const oauth = await getAgencyOAuthStatus();
|
||||
if (!oauth.connected) {
|
||||
out.activos = { ok: false, warning: 'Google Drive no conectado' };
|
||||
} else {
|
||||
let tenants: { id: string; name: string; slug: string }[] = [];
|
||||
const headers = serviceHeaders();
|
||||
const base = supabaseUrl();
|
||||
if (headers && base) {
|
||||
const tr = await fetch(
|
||||
`${base}/rest/v1/tenants?select=id,name,slug&agency_followed=neq.false`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (tr.ok) tenants = (await tr.json()) as typeof tenants;
|
||||
}
|
||||
const { folders } = await listClientFoldersUnderClientes();
|
||||
const matches = await matchFoldersToTenants(tenants, folders);
|
||||
const { linked } = await applyMatches(matches);
|
||||
out.activos = {
|
||||
ok: true,
|
||||
matched: matches.filter((m) => m.status === 'matched').length,
|
||||
linked,
|
||||
hint: 'Carpetas Drive re-emparejadas. Recarga Activos para ver archivos.',
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
out.activos = {
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : 'activos failed',
|
||||
};
|
||||
}
|
||||
|
||||
if (want('social')) {
|
||||
out.social = {
|
||||
ok: true,
|
||||
hint: 'Social se refresca al abrir la pestaña (Graph live + cache). Recarga la página.',
|
||||
reload: true,
|
||||
};
|
||||
}
|
||||
|
||||
return NextResponse.json(out);
|
||||
}
|
||||
132
src/app/api/admin/tenants/logo/route.ts
Normal file
132
src/app/api/admin/tenants/logo/route.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import {
|
||||
isAgencyRole,
|
||||
serviceHeaders,
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
import {
|
||||
ASSETS_BUCKET,
|
||||
ensureAssetsBucket,
|
||||
sanitizeFileName,
|
||||
serviceSupabase,
|
||||
} from '@/lib/assets';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const MAX_LOGO = 2 * 1024 * 1024; // 2 MB
|
||||
const ALLOWED = new Set([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
]);
|
||||
|
||||
async function requireAgency() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
|
||||
}
|
||||
if (!isAgencyRole(userRole(user))) {
|
||||
return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
|
||||
}
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) {
|
||||
return {
|
||||
error: NextResponse.json({ error: 'no service role' }, { status: 503 }),
|
||||
};
|
||||
}
|
||||
return { headers };
|
||||
}
|
||||
|
||||
/** POST multipart: tenantId + file — upload logo and set tenants.logo_url */
|
||||
export async function POST(req: NextRequest) {
|
||||
const gate = await requireAgency();
|
||||
if ('error' in gate && gate.error) return gate.error;
|
||||
const { headers } = gate as {
|
||||
headers: { apikey: string; Authorization: string };
|
||||
};
|
||||
|
||||
const form = await req.formData();
|
||||
const tenantId = String(form.get('tenantId') || '').trim();
|
||||
const file = form.get('file');
|
||||
if (!tenantId || !(file instanceof File)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'tenantId y file requeridos' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (file.size > MAX_LOGO) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Logo máximo 2 MB' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const mime = file.type || 'application/octet-stream';
|
||||
if (!ALLOWED.has(mime)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Usa PNG, JPG, WEBP o SVG' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const bucketOk = await ensureAssetsBucket();
|
||||
if (!bucketOk.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: bucketOk.error || 'bucket' },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const sb = serviceSupabase();
|
||||
if (!sb) {
|
||||
return NextResponse.json({ error: 'no service role' }, { status: 503 });
|
||||
}
|
||||
|
||||
const safe = sanitizeFileName(file.name || 'logo.png');
|
||||
const path = `${tenantId}/branding/${randomUUID()}_${safe}`;
|
||||
const buf = Buffer.from(await file.arrayBuffer());
|
||||
const { error: upErr } = await sb.storage
|
||||
.from(ASSETS_BUCKET)
|
||||
.upload(path, buf, { contentType: mime, upsert: true });
|
||||
if (upErr) {
|
||||
return NextResponse.json({ error: upErr.message }, { status: 500 });
|
||||
}
|
||||
|
||||
// Store storage path; served via /api/branding/logo
|
||||
const logoUrl = `storage:${path}`;
|
||||
const patch = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation',
|
||||
},
|
||||
body: JSON.stringify({ logo_url: logoUrl }),
|
||||
}
|
||||
);
|
||||
if (!patch.ok) {
|
||||
const msg = await patch.text();
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: msg.includes('logo_url')
|
||||
? 'Aplicar sql/tenant_ficha.sql'
|
||||
: msg.slice(0, 300),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
logoUrl,
|
||||
serveUrl: `/api/branding/logo?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
});
|
||||
}
|
||||
170
src/app/api/admin/tenants/route.ts
Normal file
170
src/app/api/admin/tenants/route.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import {
|
||||
isAgencyRole,
|
||||
serviceHeaders,
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
import type { TenantSettings } from '@/lib/tenant-modules';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
async function requireAgency() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
|
||||
}
|
||||
if (!isAgencyRole(userRole(user))) {
|
||||
return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
|
||||
}
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) {
|
||||
return {
|
||||
error: NextResponse.json({ error: 'no service role' }, { status: 503 }),
|
||||
};
|
||||
}
|
||||
return { headers };
|
||||
}
|
||||
|
||||
/** GET ?tenantId= — full ficha payload */
|
||||
export async function GET(req: NextRequest) {
|
||||
const gate = await requireAgency();
|
||||
if ('error' in gate && gate.error) return gate.error;
|
||||
const { headers } = gate as {
|
||||
headers: { apikey: string; Authorization: string };
|
||||
};
|
||||
|
||||
const tenantId = req.nextUrl.searchParams.get('tenantId')?.trim() || '';
|
||||
if (!tenantId) {
|
||||
return NextResponse.json({ error: 'tenantId requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const base = supabaseUrl();
|
||||
const [tRes, iRes] = await Promise.all([
|
||||
fetch(
|
||||
`${base}/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}&select=id,name,slug,logo_url,settings,agency_followed,agency_notes,created_at`,
|
||||
{ headers, cache: 'no-store' }
|
||||
),
|
||||
fetch(
|
||||
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&select=provider,status,enabled,external_account_id,label,last_sync_at,error_message,meta`,
|
||||
{ headers, cache: 'no-store' }
|
||||
),
|
||||
]);
|
||||
|
||||
if (!tRes.ok) {
|
||||
const msg = await tRes.text();
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: msg.includes('logo_url') || msg.includes('settings')
|
||||
? 'Aplicar sql/tenant_ficha.sql'
|
||||
: msg.slice(0, 300),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const tenants = await tRes.json();
|
||||
const tenant = tenants[0];
|
||||
if (!tenant) {
|
||||
return NextResponse.json({ error: 'Cliente no encontrado' }, { status: 404 });
|
||||
}
|
||||
|
||||
let integrations: unknown[] = [];
|
||||
let integWarning: string | null = null;
|
||||
if (iRes.ok) {
|
||||
integrations = await iRes.json();
|
||||
} else {
|
||||
integWarning = (await iRes.text()).slice(0, 200);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
tenant: {
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
slug: tenant.slug,
|
||||
logoUrl: tenant.logo_url || null,
|
||||
settings: (tenant.settings || {}) as TenantSettings,
|
||||
followed: tenant.agency_followed !== false,
|
||||
notes: tenant.agency_notes || '',
|
||||
createdAt: tenant.created_at,
|
||||
},
|
||||
integrations,
|
||||
warning: integWarning,
|
||||
});
|
||||
}
|
||||
|
||||
/** PATCH — follow/notes + ficha fields (name, logoUrl, settings). */
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const gate = await requireAgency();
|
||||
if ('error' in gate && gate.error) return gate.error;
|
||||
const { headers } = gate as {
|
||||
headers: { apikey: string; Authorization: string };
|
||||
};
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const tenantId =
|
||||
typeof body.tenantId === 'string' ? body.tenantId.trim() : '';
|
||||
if (!tenantId) {
|
||||
return NextResponse.json({ error: 'tenantId requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (typeof body.followed === 'boolean') {
|
||||
patch.agency_followed = body.followed;
|
||||
}
|
||||
if (typeof body.notes === 'string') {
|
||||
patch.agency_notes = body.notes.trim() || null;
|
||||
}
|
||||
if (typeof body.name === 'string' && body.name.trim()) {
|
||||
patch.name = body.name.trim();
|
||||
}
|
||||
if (typeof body.logoUrl === 'string') {
|
||||
patch.logo_url = body.logoUrl.trim() || null;
|
||||
}
|
||||
if (body.logoUrl === null) {
|
||||
patch.logo_url = null;
|
||||
}
|
||||
if (body.settings && typeof body.settings === 'object') {
|
||||
patch.settings = body.settings as TenantSettings;
|
||||
}
|
||||
|
||||
if (!Object.keys(patch).length) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Nada que actualizar' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation',
|
||||
},
|
||||
body: JSON.stringify(patch),
|
||||
}
|
||||
);
|
||||
if (!r.ok) {
|
||||
const msg = await r.text();
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
msg.includes('logo_url') || msg.includes('settings')
|
||||
? 'Aplicar sql/tenant_ficha.sql en Supabase'
|
||||
: msg.includes('agency_followed')
|
||||
? 'Aplicar sql/agency_control.sql'
|
||||
: msg.slice(0, 300),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
const rows = await r.json();
|
||||
return NextResponse.json({ tenant: rows[0] || rows });
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user